diff options
| -rw-r--r-- | src/silentpay.js | 28 | ||||
| -rw-r--r-- | tools/sp-indexer.js | 118 |
2 files changed, 135 insertions, 11 deletions
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 <x-only> 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 <x-only> = 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); |
