aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-27 14:42:11 -0700
committerAdam Soltys <[email protected]>2026-06-27 14:42:11 -0700
commit1beae75ebd34142cc63342fbf7c8a798af45bb5a (patch)
tree96f2962305dd1169b7c475a6ef2e9fabb97b92aa
parent6c817756c5cca849864119405be9fa3f9e968057 (diff)
downloadcoinosv3-1beae75ebd34142cc63342fbf7c8a798af45bb5a.tar.gz
coinosv3-1beae75ebd34142cc63342fbf7c8a798af45bb5a.zip
Add regtest SP tweak indexer; accept Core's array-witness in inputPubKey
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) <[email protected]>
-rw-r--r--src/silentpay.js28
-rw-r--r--tools/sp-indexer.js118
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);