diff options
| author | Adam Soltys <[email protected]> | 2026-06-27 17:11:15 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-27 17:11:15 -0700 |
| commit | b0b9a71ef4912350ecf188bf6a75d21e97adc06b (patch) | |
| tree | 26408ba59a115b839efb9d9d551abf5a07b84403 /tools | |
| parent | a52cf9e500847bc1d545eee9e2816a13cccf20cf (diff) | |
| download | coinosv3-b0b9a71ef4912350ecf188bf6a75d21e97adc06b.tar.gz coinosv3-b0b9a71ef4912350ecf188bf6a75d21e97adc06b.zip | |
Mempool (unconfirmed) silent-payment discovery
Indexer: scan the mempool (getrawmempool + getrawtransaction; prevouts fetched
explicitly since verbosity-2 omits them for mempool txs), track SP-eligible
unconfirmed txs, serve /mempool, and push {type:'mempool'} on change. Switched
Core RPC to node:http (no keep-alive) + serialized calls — Bun's fetch was
dropping sockets under the added mempool load.
Wallet: handle the mempool push and /mempool catch-up (_scanSpMempool) — pending
SP receipts rebuild on each update (also drops RBF'd/vanished ones); block pushes
promote a pending entry to confirmed. So a silent payment shows the instant it's
broadcast, like a normal receive, with no mining.
Verified on regtest: an unconfirmed SP is detected and served; clean logs.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/sp-indexer.js | 98 |
1 files changed, 89 insertions, 9 deletions
diff --git a/tools/sp-indexer.js b/tools/sp-indexer.js index 9bbedd6..7e34405 100644 --- a/tools/sp-indexer.js +++ b/tools/sp-indexer.js @@ -15,6 +15,7 @@ import { Database } from 'bun:sqlite'; import net from 'node:net'; +import http from 'node:http'; import { inputPubKey, silentPaymentTweak } from '../src/silentpay.js'; const RPC = process.env.CORE_RPC || 'http://127.0.0.1:18443'; @@ -27,16 +28,41 @@ const DB_PATH = process.env.SP_DB || ':memory:'; 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 }), +// Serialize all RPC so the block sync and mempool poll don't hammer Core +// concurrently (which was dropping sockets). +let rpcLock = Promise.resolve(); +function rpc(method, params = [], retry = 2) { + const p = rpcLock.then(() => rpcCall(method, params, retry)); + rpcLock = p.then(() => {}, () => {}); + return p; +} +const RPC_URL = new URL(RPC); +function rpcCall(method, params = [], retry = 2) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ jsonrpc: '1.0', id: ++rpcId, method, params }); + const req = http.request({ + hostname: RPC_URL.hostname, port: RPC_URL.port || 80, path: RPC_URL.pathname || '/', method: 'POST', + agent: false, // fresh connection each call — no keep-alive sockets to go stale + headers: { 'content-type': 'application/json', authorization: auth, 'content-length': Buffer.byteLength(body) }, + }, (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + try { const j = JSON.parse(data); if (j.error) reject(new Error(`rpc ${method}: ${j.error.message}`)); else resolve(j.result); } + catch (e) { reject(new Error(`rpc ${method}: ${e.message}`)); } + }); + }); + req.on('error', (e) => (retry > 0 ? resolve(rpcCall(method, params, retry - 1)) : reject(e))); + req.write(body); + req.end(); }); - 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; +} + +// The scriptPubKey of a previous output (for mempool txs, whose getrawtransaction +// doesn't carry prevouts). Cached within a mempool pass via the parent tx. +async function prevoutScriptPubKey(txid, vout) { + const ptx = await rpc('getrawtransaction', [txid, true]); + return (ptx.vout[vout] && ptx.vout[vout].scriptPubKey.hex) || ''; } // ---- persistent store (SQLite) -------------------------------------------- @@ -106,11 +132,61 @@ async function sync() { if (h % 100 === 0 || h === tip) console.log(`indexed height ${h}/${tip} (${items.length} sp-tx)`); } if (!live) { live = true; console.log('caught up — pushing new blocks over websocket'); } + await mempoolSync(); // blocks just changed the mempool — reconcile pending } catch (e) { console.error('sync error:', e.message); } } +// ---- mempool (unconfirmed) SP discovery ----------------------------------- +// So a payment shows the moment it's broadcast, not only once mined. We track +// which mempool txs are SP-eligible (getrawtransaction verbosity 2 carries the +// prevouts needed for the tweak) and push the set whenever it changes. +const mempoolSeen = new Map(); // txid -> item | null (null = seen, not an SP tx) +let mempoolItems = []; + +// Like scanTx but for a mempool txid: getrawtransaction has no prevouts, so we +// fetch each input's prevout scriptPubKey (only for txs with a taproot output). +async function scanMempoolTx(txid) { + const tx = await rpc('getrawtransaction', [txid, true]); + if (tx.vin.some((i) => i.coinbase)) return null; + 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; // not SP-relevant — skip the prevout lookups + const inputs = []; + for (const i of tx.vin) { + let spk = ''; + try { spk = await prevoutScriptPubKey(i.txid, i.vout); } catch {} + inputs.push({ txid: i.txid, vout: i.vout, pubkey: inputPubKey({ scriptSig: (i.scriptSig && i.scriptSig.hex) || '', witness: i.txinwitness || [], scriptPubKey: spk }) || undefined }); + } + let tweak; + try { tweak = silentPaymentTweak(inputs); } catch { return null; } + if (!tweak) return null; + return { txid: tx.txid, tweak: Buffer.from(tweak).toString('hex'), outputs }; +} + +async function mempoolSync() { + try { + const txids = await rpc('getrawmempool', []); + const inPool = new Set(txids); + let changed = false; + for (const txid of txids) { + if (mempoolSeen.has(txid)) continue; + mempoolSeen.set(txid, null); // mark seen so we don't refetch + let it = null; + try { it = await scanMempoolTx(txid); } catch {} + if (it) { mempoolSeen.set(txid, it); changed = true; } + } + for (const txid of mempoolSeen.keys()) if (!inPool.has(txid)) { if (mempoolSeen.get(txid)) changed = true; mempoolSeen.delete(txid); } + if (changed) { + mempoolItems = [...mempoolSeen.values()].filter(Boolean); + if (live) server.publish('blocks', JSON.stringify({ type: 'mempool', items: mempoolItems })); + } + } catch (e) { + console.error('mempool 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 } }); @@ -144,6 +220,9 @@ const server = Bun.serve({ 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 }); } + // Current unconfirmed (mempool) SP-eligible txs — for the pending catch-up on + // connect; live updates arrive as {type:'mempool'} WS pushes. + if (p === '/mempool') return json({ items: mempoolItems }); return json({ error: 'not found' }, 404); }, websocket: { @@ -211,3 +290,4 @@ if (process.env.SP_ZMQ) { console.log(`zmq: subscribed to hashblock at ${process.env.SP_ZMQ}`); } setInterval(sync, POLL_MS); // backstop (and the only trigger when SP_ZMQ is unset) +setInterval(mempoolSync, Number(process.env.MEMPOOL_POLL_MS || 2500)); // unconfirmed SP discovery |
