From 6c817756c5cca849864119405be9fa3f9e968057 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 14:34:59 -0700 Subject: Add BIP-352 receiving crypto (scan, tweak, keys, address) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit silentpay.js gains the receiving half of silent payments, validated against all official BIP-352 vectors (29/29 receiving sub-cases): - inputPubKey(): recover the eligible input pubkey from P2PKH/P2WPKH/P2SH-P2WPKH/ P2TR inputs (skips uncompressed keys, excludes NUMS script-path, hash160-matches malleated P2PKH scriptSigs) - silentPaymentTweak(): the oracle tweak input_hash*A (sum over eligible inputs, input hash over all outpoints, point-at-infinity guarded) — used by the indexer - silentPaymentScan(): ecdh = scan_priv*tweak, derive P_k = B_spend + t_k*G, match the tx's taproot outputs, return per-output spend tweaks - silentPaymentOutputPrivKey(), deriveSilentPaymentKeys() (m/352' paths), encodeSilentPaymentAddress() Labels not yet implemented (wallet uses a single no-label address). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/silentpay.js | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) (limited to 'src/silentpay.js') diff --git a/src/silentpay.js b/src/silentpay.js index 0700999..1a58a41 100644 --- a/src/silentpay.js +++ b/src/silentpay.js @@ -17,9 +17,12 @@ import { secp256k1 } from '@noble/curves/secp256k1'; import { sha256 } from '@noble/hashes/sha256'; +import { ripemd160 } from '@noble/hashes/ripemd160'; import { hexToBytes, bytesToHex, concatBytes } from '@noble/hashes/utils'; import { bech32m } from '@scure/base'; +const hash160 = (b) => ripemd160(sha256(b)); + const Point = secp256k1.ProjectivePoint; const G = Point.BASE; const N = secp256k1.CURVE.n; @@ -109,3 +112,177 @@ export function silentPaymentScripts(inputs, outputs) { export function silentPaymentPlaceholder(index) { return taprootScript(sha256(concatBytes(new TextEncoder().encode('halsp-placeholder'), be32(index)))); } + +// =========================================================================== +// Receiving (BIP-352). +// +// A browser light client can't scan the chain itself, so an indexing server (an +// SP "oracle") provides, per block, the tweaks — one 33-byte point (input_hash·A, +// the sum of a tx's eligible input pubkeys folded with its input hash) per +// eligible transaction — plus the taproot UTXOs created in that block. The +// receiver then does the private part on-device: ecdh = b_scan · tweak, then for +// k = 0,1,… derives P_k = B_spend + t_k·G and matches it against the block's +// outputs. The scan key never leaves the device. +// +// The first half here (input pubkey extraction + tweak) is what the indexer +// computes from raw blocks; the second half (scan, output key, key derivation, +// address) is what the wallet uses. They share one implementation so the math +// is validated once, against the official BIP-352 vectors. +// =========================================================================== + +const numTo32 = (x) => hexToBytes(x.toString(16).padStart(64, '0')); + +// secp256k1 NUMS point H (BIP-341): a taproot input whose script-path reveals +// this as the internal key is provably unspendable and NOT an eligible input. +const NUMS_H = '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0'; + +// Parse a serialized witness (hex): compact-size item count, then each item as +// compact-size length + bytes. +function parseWitness(hex) { + if (!hex) return []; + const b = hexToBytes(hex); + let o = 0; + const rd = () => { + let n = b[o++]; + if (n === 0xfd) { n = b[o] | (b[o + 1] << 8); o += 2; } + else if (n === 0xfe) { n = b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] * 0x1000000); o += 4; } + return n; + }; + const count = rd(); + const items = []; + for (let i = 0; i < count; i++) { const len = rd(); items.push(b.slice(o, o + len)); o += len; } + return items; +} + +// Pull the data pushes out of a scriptSig (hex) — enough to recover the pubkey +// from P2PKH or the redeemScript from P2SH (push opcodes only). +function scriptPushes(hex) { + if (!hex) return []; + const b = hexToBytes(hex); + let o = 0; + const pushes = []; + while (o < b.length) { + const op = b[o++]; + let len = -1; + if (op >= 0x01 && op <= 0x4b) len = op; + else if (op === 0x4c) len = b[o++]; + else if (op === 0x4d) { len = b[o] | (b[o + 1] << 8); o += 2; } + else if (op === 0x4e) { len = b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] * 0x1000000); o += 4; } + else continue; // non-push opcode + if (len < 0 || o + len > b.length) break; + pushes.push(b.slice(o, o + len)); o += len; + } + return pushes; +} + +// Only compressed pubkeys are eligible — uncompressed-key inputs are skipped. +const looksLikePubKey = (b) => !!b && b.length === 33 && (b[0] === 0x02 || b[0] === 0x03); + +// Recover the BIP-352-eligible input public key (33-byte compressed) from a tx +// input, or null if the input type isn't eligible. Handles P2PKH, P2WPKH, +// P2SH-P2WPKH and P2TR (key-path) inputs. +export function inputPubKey({ scriptSig = '', witness = '', scriptPubKey = '' }) { + try { + const spk = hexToBytes(scriptPubKey); + // P2TR: OP_1 PUSH32 + if (spk.length === 34 && spk[0] === 0x51 && spk[1] === 0x20) { + let w = parseWitness(witness); + if (w.length > 1 && w[w.length - 1][0] === 0x50) w = w.slice(0, -1); // strip annex + if (w.length > 1 && bytesToHex(w[w.length - 1].slice(1, 33)) === NUMS_H) return null; // NUMS script-path + return Point.fromHex(concatBytes(Uint8Array.of(0x02), spk.slice(2, 34))).toRawBytes(true); // even-Y lift + } + // P2WPKH: OP_0 PUSH20 + if (spk.length === 22 && spk[0] === 0x00 && spk[1] === 0x14) { + const w = parseWitness(witness); + const pk = w[w.length - 1]; + return looksLikePubKey(pk) ? Point.fromHex(pk).toRawBytes(true) : null; + } + // P2SH-P2WPKH: scriptSig pushes redeemScript 0014<20>; witness = [sig, pubkey] + if (spk.length === 23 && spk[0] === 0xa9 && spk[1] === 0x14 && spk[22] === 0x87) { + const rs = scriptPushes(scriptSig)[0]; + if (!rs || rs.length !== 22 || rs[0] !== 0x00 || rs[1] !== 0x14) return null; + const w = parseWitness(witness); + const pk = w[w.length - 1]; + return looksLikePubKey(pk) ? Point.fromHex(pk).toRawBytes(true) : null; + } + // P2PKH: OP_DUP OP_HASH160 PUSH20 ... — the eligible pubkey is the + // scriptSig push whose hash160 matches (handles malleated scriptSigs). + if (spk.length === 25 && spk[0] === 0x76 && spk[1] === 0xa9 && spk[2] === 0x14) { + const target = bytesToHex(spk.slice(3, 23)); + const pushes = scriptPushes(scriptSig); + for (let i = pushes.length - 1; i >= 0; i--) { + const pk = pushes[i]; + if (looksLikePubKey(pk) && bytesToHex(hash160(pk)) === target) return Point.fromHex(pk).toRawBytes(true); + } + return null; + } + } catch {} + return null; +} + +// The oracle tweak for a transaction: 33-byte point input_hash·A, where A is the +// sum of the eligible input pubkeys and input_hash = H_BIP0352/Inputs(outpoint_L +// || A) with outpoint_L the smallest outpoint among ALL the tx's inputs. +// inputs: [{ txid (display hex), vout, pubkey? (Uint8Array 33) }] — every input +// of the tx; `pubkey` present only for BIP-352-eligible inputs. +// Returns null if the tx has no eligible inputs (so it can't be a silent payment). +export function silentPaymentTweak(inputs) { + let A = null; + for (const i of inputs) if (i.pubkey) A = A ? A.add(Point.fromHex(i.pubkey)) : Point.fromHex(i.pubkey); + if (!A) return null; + let Abytes; + try { Abytes = A.toRawBytes(true); } catch { return null; } // A is the point at infinity → not a silent payment + let smallest = null; + for (const i of inputs) { + const op = concatBytes(hexToBytes(i.txid).reverse(), le32(i.vout)); + if (!smallest || bytesToHex(op) < bytesToHex(smallest)) smallest = op; + } + const inputHash = modN(toInt(tagged('BIP0352/Inputs', concatBytes(smallest, Abytes)))); + return A.multiply(inputHash).toRawBytes(true); +} + +// Scan a transaction's taproot outputs for payments to us. +// scanPriv: Uint8Array(32) our scan private key (b_scan) +// spendPub: Uint8Array(33) our spend public key (B_spend) +// tweak: Uint8Array(33) the oracle tweak for this tx (input_hash·A) +// outputs: array of 32-byte x-only output pubkeys (hex) in the tx +// Returns the matched outputs: [{ output (x-only hex), k, tweak (t_k hex 32) }]; +// `tweak` (t_k) is added to the spend private key to spend the coin. +export function silentPaymentScan({ scanPriv, spendPub, tweak, outputs, maxK = 30 }) { + const ecdh = Point.fromHex(tweak).multiply(modN(toInt(scanPriv))).toRawBytes(true); + const B = Point.fromHex(spendPub); + const remaining = new Set(outputs.map((o) => o.toLowerCase())); + const found = []; + for (let k = 0; k < maxK && remaining.size; k++) { + const tk = modN(toInt(tagged('BIP0352/SharedSecret', concatBytes(ecdh, be32(k))))); + const xonly = bytesToHex(B.add(G.multiply(tk)).toRawBytes(true).slice(1)); + if (!remaining.has(xonly)) break; // no output at this k → done with this tx + found.push({ output: xonly, k, tweak: bytesToHex(numTo32(tk)) }); + remaining.delete(xonly); + } + return found; +} + +// Spend private key for a found SP output: d = (spend_priv + t_k) mod n. Used as +// the taproot key-path key (the signer negates for odd-Y per BIP-340). +export function silentPaymentOutputPrivKey(spendPriv, tweakK) { + return numTo32(modN(toInt(spendPriv) + toInt(tweakK))); +} + +// Derive the BIP-352 scan + spend keys from a BIP32 master node. +// spend: m/352'/coin'/account'/0'/0 scan: m/352'/coin'/account'/1'/0 +export function deriveSilentPaymentKeys(master, coin, account = 0) { + const spend = master.derive(`m/352'/${coin}'/${account}'/0'/0`); + const scan = master.derive(`m/352'/${coin}'/${account}'/1'/0`); + return { + scanPriv: scan.privateKey, scanPub: scan.publicKey, + spendPriv: spend.privateKey, spendPub: spend.publicKey, + }; +} + +// Encode an sp1…/tsp1… address from scan + spend public keys (33 bytes each). +export function encodeSilentPaymentAddress(scanPub, spendPub, { testnet = false } = {}) { + const hrp = testnet ? 'tsp' : 'sp'; + const words = [0, ...bech32m.toWords(concatBytes(scanPub, spendPub))]; + return bech32m.encode(hrp, words, 1023); +} -- cgit v1.2.3 From 1beae75ebd34142cc63342fbf7c8a798af45bb5a Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 14:42:11 -0700 Subject: Add regtest SP tweak indexer; accept Core's array-witness in inputPubKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/sp-indexer.js: a minimal BIP-352 oracle (Bun) that walks Core and serves, per block, one entry per eligible tx — the tweak (input_hash*A) plus that tx's taproot outputs — for the wallet to scan on-device. Shares src/silentpay.js so the tweak math is the vector-validated code. Added to the regtest compose as the 'sp' service (oven/bun, pointed at bc:18443). inputPubKey now accepts a witness as either serialized hex (vectors) or an array of stack items (Core getblock RPC). Verified end-to-end on regtest: a real SP payment is indexed and the scan recovers the output, value, and spend tweak. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/silentpay.js | 28 ++++++++----- tools/sp-indexer.js | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 tools/sp-indexer.js (limited to 'src/silentpay.js') diff --git a/src/silentpay.js b/src/silentpay.js index 1a58a41..16de66b 100644 --- a/src/silentpay.js +++ b/src/silentpay.js @@ -1,4 +1,4 @@ -// Silent Payments (BIP-352) — sending side only. +// Silent Payments (BIP-352) — sending and receiving. // // A silent payment address (sp1…) is a static, reusable address the receiver // publishes once. The sender derives a unique one-time taproot output for each @@ -6,14 +6,12 @@ // so there's no on-chain link between payments and no address reuse — without // any interaction with the receiver. // -// This module only does the SENDER half (deriving the output). Receiving needs -// chain scanning (a per-tx ECDH tweak), which a light client can't do without a -// dedicated index server — out of scope here. +// Sending (silentPaymentScripts) derives the output; we only spend compressed +// P2WPKH inputs, so each input contributes its private key as-is. Receiving (see +// the second half) can't scan the chain in a browser, so an indexing server +// supplies per-tx tweaks + taproot UTXOs and we match them on-device. // -// Validated against the official BIP-352 send test vectors. We only spend -// compressed P2WPKH inputs, which keeps the eligible-input rules trivial: each -// input contributes its private key as-is (no taproot even-Y negation, no -// uncompressed-key or P2SH edge cases). +// Validated against the official BIP-352 send + receive test vectors. import { secp256k1 } from '@noble/curves/secp256k1'; import { sha256 } from '@noble/hashes/sha256'; @@ -154,6 +152,14 @@ function parseWitness(hex) { return items; } +// Witness stack items as byte arrays. Accepts either a serialized witness (hex +// string, as in the BIP-352 vectors) or an array of hex stack items (as Core's +// getblock RPC returns in txinwitness). +function witnessItems(witness) { + if (Array.isArray(witness)) return witness.map(hexToBytes); + return parseWitness(witness); +} + // Pull the data pushes out of a scriptSig (hex) — enough to recover the pubkey // from P2PKH or the redeemScript from P2SH (push opcodes only). function scriptPushes(hex) { @@ -186,14 +192,14 @@ export function inputPubKey({ scriptSig = '', witness = '', scriptPubKey = '' }) const spk = hexToBytes(scriptPubKey); // P2TR: OP_1 PUSH32 if (spk.length === 34 && spk[0] === 0x51 && spk[1] === 0x20) { - let w = parseWitness(witness); + let w = witnessItems(witness); if (w.length > 1 && w[w.length - 1][0] === 0x50) w = w.slice(0, -1); // strip annex if (w.length > 1 && bytesToHex(w[w.length - 1].slice(1, 33)) === NUMS_H) return null; // NUMS script-path return Point.fromHex(concatBytes(Uint8Array.of(0x02), spk.slice(2, 34))).toRawBytes(true); // even-Y lift } // P2WPKH: OP_0 PUSH20 if (spk.length === 22 && spk[0] === 0x00 && spk[1] === 0x14) { - const w = parseWitness(witness); + const w = witnessItems(witness); const pk = w[w.length - 1]; return looksLikePubKey(pk) ? Point.fromHex(pk).toRawBytes(true) : null; } @@ -201,7 +207,7 @@ export function inputPubKey({ scriptSig = '', witness = '', scriptPubKey = '' }) if (spk.length === 23 && spk[0] === 0xa9 && spk[1] === 0x14 && spk[22] === 0x87) { const rs = scriptPushes(scriptSig)[0]; if (!rs || rs.length !== 22 || rs[0] !== 0x00 || rs[1] !== 0x14) return null; - const w = parseWitness(witness); + const w = witnessItems(witness); const pk = w[w.length - 1]; return looksLikePubKey(pk) ? Point.fromHex(pk).toRawBytes(true) : null; } diff --git a/tools/sp-indexer.js b/tools/sp-indexer.js new file mode 100644 index 0000000..12f76f7 --- /dev/null +++ b/tools/sp-indexer.js @@ -0,0 +1,118 @@ +// Minimal BIP-352 silent-payment tweak indexer (an "oracle") for regtest dev. +// +// It walks the chain from a Bitcoin Core node and, per block, emits one entry per +// silent-payment-eligible transaction: the tweak (input_hash·A over the tx's +// eligible inputs) plus that transaction's taproot outputs. A wallet fetches +// these, computes ecdh = scan_priv·tweak on-device, derives P_k = B_spend + t_k·G, +// and matches it against the entry's outputs — the scan key never leaves the +// client. Discovered outputs are ordinary taproot UTXOs, so spends/confirmations +// are tracked by the normal Electrum backend; this server only does discovery. +// +// Shares the SP crypto with the wallet (../src/silentpay.js) so the tweak math is +// the same code validated against the BIP-352 vectors. In-memory, regtest-sized; +// re-indexes on restart. Run with bun. + +import { inputPubKey, silentPaymentTweak } from '../src/silentpay.js'; + +const RPC = process.env.CORE_RPC || 'http://127.0.0.1:18443'; +const RPC_USER = process.env.RPC_USER || 'admin1'; +const RPC_PASS = process.env.RPC_PASS || '123'; +const PORT = Number(process.env.SP_PORT || 8888); +const START = Number(process.env.START_HEIGHT || 0); +const POLL_MS = Number(process.env.POLL_MS || 2000); + +const auth = 'Basic ' + Buffer.from(`${RPC_USER}:${RPC_PASS}`).toString('base64'); +let rpcId = 0; +async function rpc(method, params = []) { + const res = await fetch(RPC, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: auth }, + body: JSON.stringify({ jsonrpc: '1.0', id: ++rpcId, method, params }), + }); + if (!res.ok) throw new Error(`rpc ${method} HTTP ${res.status}`); + const j = await res.json(); + if (j.error) throw new Error(`rpc ${method}: ${j.error.message}`); + return j.result; +} + +// height -> { hash, items: [{ txid, tweak(hex), outputs: [{ vout, value(sat), xonly(hex) }] }] } +const blocks = new Map(); +let indexed = -1; // highest indexed height + +// Is a scriptPubKey (hex) a P2TR output? OP_1 PUSH32 = 5120<64 hex>. +const taprootXonly = (hex) => (hex && hex.length === 68 && hex.startsWith('5120') ? hex.slice(4) : null); + +function scanTx(tx) { + // Eligible-input set: every input with its recovered pubkey (null if ineligible + // or coinbase); the tweak needs all outpoints but only eligible pubkeys. + if (tx.vin.some((i) => i.coinbase)) return null; // coinbase is never a silent payment + const inputs = tx.vin.map((i) => ({ + txid: i.txid, + vout: i.vout, + pubkey: inputPubKey({ + scriptSig: (i.scriptSig && i.scriptSig.hex) || '', + witness: i.txinwitness || [], + scriptPubKey: (i.prevout && i.prevout.scriptPubKey && i.prevout.scriptPubKey.hex) || '', + }) || undefined, + })); + const outputs = []; + for (const o of tx.vout) { + const x = taprootXonly(o.scriptPubKey && o.scriptPubKey.hex); + if (x) outputs.push({ vout: o.n, value: Math.round(o.value * 1e8), xonly: x }); + } + if (!outputs.length) return null; // no taproot output → can't be an SP payment + let tweak; + try { tweak = silentPaymentTweak(inputs); } catch { return null; } + if (!tweak) return null; // no eligible inputs + return { txid: tx.txid, tweak: Buffer.from(tweak).toString('hex'), outputs }; +} + +async function indexBlock(height) { + const hash = await rpc('getblockhash', [height]); + const block = await rpc('getblock', [hash, 3]); // verbosity 3: full txs with prevouts + const items = []; + for (const tx of block.tx) { const it = scanTx(tx); if (it) items.push(it); } + blocks.set(height, { hash, items }); + indexed = height; +} + +async function sync() { + try { + const tip = await rpc('getblockcount', []); + for (let h = Math.max(START, indexed + 1); h <= tip; h++) { + await indexBlock(h); + if (h % 100 === 0 || h === tip) console.log(`indexed height ${h}/${tip} (${blocks.get(h).items.length} sp-tx)`); + } + } catch (e) { + console.error('sync error:', e.message); + } +} + +const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' }; +const json = (obj, status = 200) => new Response(JSON.stringify(obj), { status, headers: { 'content-type': 'application/json', ...CORS } }); + +Bun.serve({ + port: PORT, + async fetch(req) { + const url = new URL(req.url); + const p = url.pathname; + if (req.method === 'OPTIONS') return new Response(null, { headers: CORS }); + if (p === '/height') return json({ height: indexed }); + let m; + if ((m = p.match(/^\/block\/(\d+)$/))) { + const h = Number(m[1]); + const b = blocks.get(h); + if (!b) return json({ error: 'not indexed', height: h, tip: indexed }, 404); + return json({ height: h, hash: b.hash, items: b.items }); + } + if ((m = p.match(/^\/tweaks\/(\d+)$/))) { + const b = blocks.get(Number(m[1])); + return json(b ? b.items.map((i) => i.tweak) : []); + } + return json({ error: 'not found' }, 404); + }, +}); + +console.log(`sp-indexer: core=${RPC} start=${START} serving :${PORT}`); +await sync(); +setInterval(sync, POLL_MS); -- cgit v1.2.3 From 56a2736be51d2f35d68ca88b477a948efe3398ce Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 17:17:58 -0700 Subject: Catch-up scaling: dust filter + per-block Bloom filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit silentpay.js: silentPaymentCandidate() (our P_0 for a tweak) + buildBloom/bloomHas (double-hashed sha256, ~0.8% FP). Validated: candidate == P_0 on all no-label vectors. Indexer /scan now returns, per block, the tweaks + a Bloom over that block's taproot output keys (no outputs), with an optional dustLimit. The wallet derives a candidate per tweak, tests the filter, and only fetches /block on a hit — so a big historical scan costs candidate math + a handful of fetches instead of downloading and scanning every block. Live WS pushes still carry full items. SP_DUST_LIMIT defaults to 0 (receive everything; the indexer honors any value). Verified end-to-end on regtest: the filter flow finds the output. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/silentpay.js | 40 ++++++++++++++++++++++++++++++++++++++++ src/wallet.js | 40 +++++++++++++++++++++++++++------------- tools/sp-indexer.js | 23 ++++++++++++++++------- 3 files changed, 83 insertions(+), 20 deletions(-) (limited to 'src/silentpay.js') diff --git a/src/silentpay.js b/src/silentpay.js index 16de66b..1732ced 100644 --- a/src/silentpay.js +++ b/src/silentpay.js @@ -292,3 +292,43 @@ export function encodeSilentPaymentAddress(scanPub, spendPub, { testnet = false const words = [0, ...bech32m.toWords(concatBytes(scanPub, spendPub))]; return bech32m.encode(hrp, words, 1023); } + +// ---- catch-up scaling: candidate keys + per-block Bloom filters ----------- +// For a large historical scan, downloading every block's outputs is wasteful. +// The indexer gives, per block, the tweaks + a Bloom filter over that block's +// taproot output keys. The client derives its candidate key P_0 from each tweak +// and tests the filter; only on a hit does it pull the block's outputs and run +// the full scan. (P_0 always exists if we're paid, so testing it alone suffices.) + +// Our candidate output key (P_0 = B_spend + t_0·G, x-only hex) for one tweak. +export function silentPaymentCandidate({ scanPriv, spendPub, tweak }) { + const ecdh = Point.fromHex(tweak).multiply(modN(toInt(scanPriv))).toRawBytes(true); + const t0 = modN(toInt(tagged('BIP0352/SharedSecret', concatBytes(ecdh, be32(0))))); + return bytesToHex(Point.fromHex(spendPub).add(G.multiply(t0)).toRawBytes(true).slice(1)); +} + +// k bit indices for a 32-byte x-only key (hex) in an m-bit filter, via +// double-hashing one sha256(key): idx_i = (h1 + i·h2) mod m. +function* bloomIndices(keyHex, m, k) { + const h = sha256(hexToBytes(keyHex)); + const u32 = (o) => (h[o] | (h[o + 1] << 8) | (h[o + 2] << 16) | (h[o + 3] * 0x1000000)) >>> 0; + const h1 = u32(0); + const h2 = u32(4) | 1; // odd, so it's coprime-ish with m + for (let i = 0; i < k; i++) yield ((h1 + i * h2) >>> 0) % m; +} + +// Build a Bloom filter over x-only keys (hex). { m (bits), k (hashes), bits (hex) }. +export function buildBloom(keysHex, bitsPerKey = 10, k = 7) { + const m = Math.max(8, keysHex.length * bitsPerKey); + const bytes = new Uint8Array(Math.ceil(m / 8)); + for (const key of keysHex) for (const idx of bloomIndices(key, m, k)) bytes[idx >> 3] |= 1 << (idx & 7); + return { m, k, bits: bytesToHex(bytes) }; +} + +// Is an x-only key (hex) possibly in the filter? (false = definitely not.) +export function bloomHas(filter, keyHex) { + if (!filter || !filter.m) return true; // no filter ⇒ can't rule out → scan it + const bytes = hexToBytes(filter.bits); + for (const idx of bloomIndices(keyHex, filter.m, filter.k)) if (!(bytes[idx >> 3] & (1 << (idx & 7)))) return false; + return true; +} diff --git a/src/wallet.js b/src/wallet.js index 5b5ed90..7b4c8ae 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -24,7 +24,7 @@ import { concatBytes } from '@scure/btc-signer/utils.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, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey } from './silentpay.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 @@ -39,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 }, @@ -319,21 +324,30 @@ export class Wallet { try { if (rescan) { this.spUtxos = []; this.lastSpScan = 0; } const from = (this.lastSpScan || 0) + 1; - const res = await (await fetch(`${indexer}/scan/${from}`)).json(); + // 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 = []; - for (const it of res.items || []) { - const matches = silentPaymentScan({ - scanPriv: keys.scanPriv, spendPub: keys.spendPub, - tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly), - }); - for (const m of matches) { - const o = it.outputs.find((o) => o.xonly === m.output); - const id = `${it.txid}:${o.vout}`; - if (have.has(id)) continue; - have.add(id); - found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak }); + for (const block of res.blocks || []) { + let hit = false; + for (const tw of block.tweaks) { + const cand = silentPaymentCandidate({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(tw) }); + if (bloomHas(block.filter, cand)) { hit = true; break; } + } + if (!hit) continue; + const blk = await (await fetch(`${indexer}/block/${block.height}`)).json(); + for (const it of blk.items || []) { + const matches = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) }); + for (const m of matches) { + const o = it.outputs.find((o) => o.xonly === m.output); + const id = `${it.txid}:${o.vout}`; + if (have.has(id)) continue; + have.add(id); + found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak }); + } } } if (tip > this.lastSpScan) this.lastSpScan = tip; diff --git a/tools/sp-indexer.js b/tools/sp-indexer.js index 09f9a19..934c55c 100644 --- a/tools/sp-indexer.js +++ b/tools/sp-indexer.js @@ -16,7 +16,7 @@ import { Database } from 'bun:sqlite'; import net from 'node:net'; import http from 'node:http'; -import { inputPubKey, silentPaymentTweak } from '../src/silentpay.js'; +import { inputPubKey, silentPaymentTweak, buildBloom } from '../src/silentpay.js'; const RPC = process.env.CORE_RPC || 'http://127.0.0.1:18443'; const RPC_USER = process.env.RPC_USER || 'admin1'; @@ -216,13 +216,22 @@ const server = Bun.serve({ const b = qGetBlock.get(Number(m[1])); return json(b ? JSON.parse(b.items).map((i) => i.tweak) : []); } - // All SP-eligible tx items from height `from` to the tip, in one response — - // each tagged with its height. Lets a client scan a range without one fetch - // per block. (A future BIP-158/Bloom filter will cut this for large ranges.) + // Catch-up over a height range: per block, the tweaks (dust-filtered) + a + // Bloom filter over that block's taproot output keys — NOT the outputs. The + // client derives its candidate key per tweak, tests the filter, and only + // fetches /block for the rare hits. dustLimit (sats) drops txs whose taproot + // outputs are all below it (fewer tweaks to check). if ((m = p.match(/^\/scan\/(\d+)$/))) { - const items = []; - for (const row of qScanFrom.all(Number(m[1]))) for (const it of JSON.parse(row.items)) items.push({ height: row.height, ...it }); - return json({ tip: indexed, items }); + const dust = Number(url.searchParams.get('dustLimit') || 0); + const blocks = []; + for (const row of qScanFrom.all(Number(m[1]))) { + let items = JSON.parse(row.items); + if (dust > 0) items = items.filter((it) => it.outputs.some((o) => o.value >= dust)); + if (!items.length) continue; + const outKeys = items.flatMap((it) => it.outputs.map((o) => o.xonly)); + blocks.push({ height: row.height, tweaks: items.map((it) => it.tweak), filter: buildBloom(outKeys) }); + } + return json({ tip: indexed, blocks }); } // Current unconfirmed (mempool) SP-eligible txs — for the pending catch-up on // connect; live updates arrive as {type:'mempool'} WS pushes. -- cgit v1.2.3 From 76daca707a7b9cb9aafab1ceb8999ec3c708a63d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 04:14:07 -0700 Subject: Spend SP coins like any coin — confirmed-only, and able to fund SP payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things: - Only confirmed SP coins enter the spend pool. Unconfirmed ones are excluded (the sender's tx could be replaced, invalidating our spend) — matching the spendable headline, which already counted confirmed-only. - SP→SP sending: our own silent-payment coins can now fund a payment TO an sp1 address (previously excluded). _applySilentPayments includes SP inputs with their key d = spend_priv + t_k, and silentPaymentScripts negates a taproot input's key when it gives odd Y, per BIP-352 (so the sender's A = a·G matches what the indexer reconstructs). signing was already SP-aware. Validated: BIP-352 sending vectors pass for all clean key-path-taproot-input cases (the remaining vector failures are NUMS script-path / labels / uncompressed keys — input types the wallet never spends). End-to-end on regtest: an SP coin funds a payment to another sp address, testmempoolaccept passes, and the recipient scans and finds it. Note: silentPaymentScripts derives input_hash from the eligible inputs it's given, which is correct because the wallet only ever spends all-eligible coins (P2WPKH + key-path SP); it isn't a general-purpose sender for txs with ineligible inputs. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/silentpay.js | 15 +++++++++++---- src/wallet.js | 28 ++++++++++++++++++---------- 2 files changed, 29 insertions(+), 14 deletions(-) (limited to 'src/silentpay.js') diff --git a/src/silentpay.js b/src/silentpay.js index 1732ced..eeabc1a 100644 --- a/src/silentpay.js +++ b/src/silentpay.js @@ -69,17 +69,24 @@ export function decodeSilentPaymentAddress(addr) { // Derive the one-time taproot output scripts for a set of silent-payment outputs // that this transaction's inputs are funding. -// inputs: [{ txid (display hex), vout, priv (Uint8Array 32) }] — ALL eligible -// (P2WPKH) inputs of the tx; the derivation commits to every one. +// inputs: [{ txid (display hex), vout, priv (Uint8Array 32), taproot? }] — ALL +// eligible inputs of the tx; the derivation commits to every one. Set +// taproot:true for P2TR inputs (e.g. our own silent-payment coins) so +// their key is taken with even Y, per BIP-352. // outputs: [{ scan, spend }] — one entry per desired output (repeat the same // recipient to pay it multiple times; the k counter is handled here). // Returns a taproot scriptPubKey (Uint8Array) for each entry, in order. export function silentPaymentScripts(inputs, outputs) { if (!inputs.length) throw new Error('Silent payment needs at least one input.'); - // a = Σ input private keys (mod n); A = a·G + // a = Σ input private keys (mod n); A = a·G. A taproot input contributes the + // key for its even-Y x-only pubkey, so negate when the key gives odd Y. let a = 0n; - for (const i of inputs) a = modN(a + toInt(i.priv)); + for (const i of inputs) { + let d = toInt(i.priv); + if (i.taproot && Number(G.multiply(d).toRawBytes(true)[0]) === 0x03) d = modN(-d); + a = modN(a + d); + } if (a === 0n) throw new Error('Silent payment input key sum is zero.'); const A = G.multiply(a); diff --git a/src/wallet.js b/src/wallet.js index 5943d86..dbb9b73 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1177,12 +1177,11 @@ export class Wallet { // 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. - const payingSp = recipients.some((r) => isSilentPaymentAddress(r.address)); - const spPool = payingSp - ? [] - : coinIds - ? this.spUtxos.filter((u) => coinIds.includes(utxoId(u))) - : this.spUtxos.slice(); + // 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 = [ @@ -1287,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) => { -- cgit v1.2.3