diff --git a/src/indexer/electrum-source.js b/src/indexer/electrum-source.js new file mode 100644 index 0000000..cba046a --- /dev/null +++ b/src/indexer/electrum-source.js @@ -0,0 +1,98 @@ +// Electrum-sourced BNS indexer — NO node, NO Fulcrum, NO disk, NO sync. +// +// Instead of scanning blocks from a BCHN node (see RpcSource in bns-indexer.js), +// this polls PUBLIC electrum/Fulcrum servers for just the two calls BNS needs — +// blockchain.scripthash.get_history (the beacon) + blockchain.transaction.get — +// caches the beacon set, and serves the same BNS electrum-over-WebSocket endpoint. +// +// Result: a featherweight, pinned-IP, DNS-independent caching relay you can run on +// a tiny VPS. Tradeoff vs. RpcSource: it trusts the upstream public servers (no own +// chain), but it needs no node, no Fulcrum, no storage. +// +// BNS_ELECTRUM_SERVERS=wss://a:50004,wss://b:50004 (comma-separated, tried in order) +// BNS_WS_BIND=127.0.0.1 BNS_WS_PORT=50010 BNS_POLL_MS=30000 +// node electrum-source.js +import WebSocket from "ws"; +import { pathToFileURL } from "node:url"; +import { BeaconIndex, startElectrumWS } from "./bns-indexer.js"; + +const CONFIG = { + servers: (process.env.BNS_ELECTRUM_SERVERS || + "wss://chipnet.bch.ninja:50004,wss://chipnet.imaginary.cash:50004,wss://chipnet.c3-soft.com:64004") + .split(",").map((s) => s.trim()).filter(Boolean), + wsBind: process.env.BNS_WS_BIND || "127.0.0.1", + wsPort: Number(process.env.BNS_WS_PORT || 50010), + beacon: process.env.BNS_BEACON_SCRIPTHASH || + "e1cdbba719436f49acb4bf245b44df875c6d6b405eda46a9d3e736d138102f3b", + pollMs: Number(process.env.BNS_POLL_MS || 30000), +}; +const log = (...a) => console.log(new Date().toISOString(), ...a); + +// minimal newline-delimited electrum WS client (tolerates a missing trailing newline). +function elClient(url) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + let id = 0, buf = ""; const pending = new Map(); + const t = setTimeout(() => { try { ws.close(); } catch {} reject(new Error("connect timeout " + url)); }, 15000); + ws.on("open", () => { clearTimeout(t); resolve({ + call(method, params = []) { + return new Promise((res, rej) => { + const i = ++id; pending.set(i, { res, rej }); + ws.send(JSON.stringify({ id: i, method, params }) + "\n"); + setTimeout(() => { if (pending.has(i)) { pending.delete(i); rej(new Error("timeout " + method)); } }, 20000); + }); + }, + close() { try { ws.close(); } catch {} }, + }); }); + ws.on("error", (e) => { clearTimeout(t); reject(e); }); + const settle = (line) => { try { const m = JSON.parse(line); const p = pending.get(m.id); if (p) { pending.delete(m.id); m.error ? p.rej(new Error(JSON.stringify(m.error))) : p.res(m.result); } } catch {} }; + ws.on("message", (d) => { + buf += d.toString(); let nl; + while ((nl = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (line) settle(line); } + const rest = buf.trim(); + if (rest) { try { JSON.parse(rest); settle(rest); buf = ""; } catch {} } + }); + }); +} + +async function connectAny(servers) { + let lastErr; + for (const url of servers) { + try { const cl = await elClient(url); await cl.call("server.version", ["BNS-Relay/0.1", "1.4.3"]); return { cl, url }; } + catch (e) { lastErr = e; log("upstream unavailable:", url, "-", e.message); } + } + throw lastErr || new Error("no upstream servers configured"); +} + +// Pull the beacon history + any not-yet-cached txs from an upstream, into the index. +async function sync(index, servers) { + const { cl, url } = await connectAny(servers); + try { + const history = await cl.call("blockchain.scripthash.get_history", [CONFIG.beacon]); + if (!Array.isArray(history)) throw new Error("bad get_history response"); + let fetched = 0; + for (const { tx_hash, height } of history) { + index.history.set(tx_hash, height); + if (!index.txCache.has(tx_hash)) { + index.txCache.set(tx_hash, await cl.call("blockchain.transaction.get", [tx_hash, true])); + fetched++; + } + } + log(`synced via ${url}: ${index.history.size} beacon txs (${fetched} new)`); + } finally { cl.close(); } +} + +async function main() { + log("electrum-sourced BNS indexer starting — upstreams:", CONFIG.servers.join(", ")); + const index = new BeaconIndex(CONFIG.beacon); + startElectrumWS(index, { bind: CONFIG.wsBind, port: CONFIG.wsPort }, null); // cache-only: beacon txs are all cached + for (;;) { + try { await sync(index, CONFIG.servers); } + catch (e) { log("sync error (will retry):", e.message); } + await new Promise((r) => setTimeout(r, CONFIG.pollMs)); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + main().catch((e) => { console.error("fatal:", e.message); process.exit(1); }); +}