// Self-contained tests for the multi-beacon indexer + /tld-list.json snapshot. // No network, no BCHN — everything runs against synthetic fixtures. Covers the // four things the memory rule cares about after this change lands: // 1. Multi-beacon BeaconIndex dispatches processTx / getHistory per beacon. // 2. Legacy v1 state file migrates into the first configured beacon on load, // preserving history across an upgrade from single- to multi-beacon. // 3. Electrum WS handler routes blockchain.scripthash.get_history by // requested scripthash, and returns [] for unknown scripthashes. // 4. /tld-list.json snapshot has the shape the design doc specifies, its // root is deterministic across insertion order, and TREG payloads // without a matching NFT mint are rejected. // // The existing bns-indexer.test.mjs still covers the live-chipnet integration // for the name beacon; this file is what proves the multi-beacon plumbing // doesn't break it and the new TLD path works. import http from "node:http"; import WebSocket from "ws"; import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createHash } from "node:crypto"; import { BeaconIndex, RpcSource, startElectrumWS, startHttpServer, scripthashOf, } from "../src/indexer/bns-indexer.js"; import { buildTldSnapshot } from "../src/lib/tld-index.js"; import { encodePayload } from "../src/lib/bns.js"; let failures = 0; const ok = (cond, msg) => { console.log((cond ? " ✅ " : " ❌ ") + msg); if (!cond) failures++; }; const eq = (a, b, msg) => ok(JSON.stringify(a) === JSON.stringify(b), `${msg} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`); // ---------- tiny helpers to synth verbose txs ---------- // Pretend the scriptPubKey for a beacon output is a plain OP_DUP HASH160 push // whose scripthash we choose freely by picking the raw hex. We compute // scripthashOf from that hex, so the "beacon scripthash" is whatever we say. function beaconOutput(rawHex) { return { scriptPubKey: { hex: rawHex } }; } function opReturnOutput(op, name, records) { const buf = encodePayload(op, name, records); const hex = "6a" + buf.length.toString(16).padStart(2, "0") + buf.toString("hex"); return { scriptPubKey: { hex } }; } function nftMintOutput(category, commitmentLabel, ownerAddr = "bchtest:qexample") { return { scriptPubKey: { hex: "76a914" + "aabbcc".padEnd(40, "0") + "88ac", address: ownerAddr }, tokenData: { category, amount: "0", nft: { capability: "none", commitment: Buffer.from(commitmentLabel, "utf8").toString("hex") }, }, }; } // ---------- (1) multi-beacon BeaconIndex ---------- console.log("1) BeaconIndex multi-beacon dispatch"); { const rawA = "aa11" + "00".repeat(20); // scriptPubKey for beacon A const rawB = "bb22" + "00".repeat(20); // scriptPubKey for beacon B const shA = scripthashOf(rawA); const shB = scripthashOf(rawB); const idx = new BeaconIndex([shA, shB]); // A tx paying only beacon A const txA = { txid: "txA", vin: [], vout: [beaconOutput(rawA)] }; ok(idx.processTx(txA, 100) === true, "processTx returns true for beacon-A tx"); eq(idx.getHistory(shA).map((h) => h.tx_hash), ["txA"], "beacon A history has txA"); eq(idx.getHistory(shB), [], "beacon B history is empty"); // A tx paying only beacon B const txB = { txid: "txB", vin: [], vout: [beaconOutput(rawB)] }; idx.processTx(txB, 101); eq(idx.getHistory(shA).map((h) => h.tx_hash), ["txA"], "beacon A unaffected by beacon-B tx"); eq(idx.getHistory(shB).map((h) => h.tx_hash), ["txB"], "beacon B history has txB"); // A tx paying BOTH beacons (edge case: same output hex is possible if beacons // are configured overlapping; not our normal shape, but the code must not crash) const rawC = "cc33" + "00".repeat(20); const shC = scripthashOf(rawC); const idx2 = new BeaconIndex([shC]); idx2.processTx({ txid: "txC", vin: [], vout: [beaconOutput(rawC)] }, 200); eq(idx2.getHistory().map((h) => h.tx_hash), ["txC"], "argless getHistory returns single-beacon history"); ok(idx.getTx("txA")?.txid === "txA", "getTx finds cross-beacon"); ok(idx.getTx("nonexistent") === null, "getTx returns null for unknown"); ok(idx.historySize === 2, "historySize sums across beacons"); } // ---------- (2) legacy v1 state migration ---------- console.log("2) BeaconIndex.fromJSON migrates legacy v1 state"); { const sh = "de".repeat(32); const legacy = { history: [["tx-old", 42]], txCache: [["tx-old", { txid: "tx-old", vin: [], vout: [] }]], utxos: [], }; const idx = BeaconIndex.fromJSON(sh, legacy); ok(idx.getHistory().length === 1, "legacy history migrated"); ok(idx.getHistory()[0].tx_hash === "tx-old", "legacy tx present in first beacon"); ok(idx.getTx("tx-old")?.txid === "tx-old", "legacy tx cache migrated"); // Legacy state passed with multi-beacon config lands in FIRST beacon; other // beacons stay empty and need backfill (RpcSource logs a warning for this). const shB = "ef".repeat(32); const idxMulti = BeaconIndex.fromJSON([sh, shB], legacy); ok(idxMulti.getHistory(sh).length === 1, "legacy migrated to first beacon under multi"); ok(idxMulti.getHistory(shB).length === 0, "second beacon empty pending backfill"); // v2 round-trip preserves both beacons const j2 = idxMulti.toJSON(); ok(j2.version === 2, "toJSON writes v2 shape"); const roundtrip = BeaconIndex.fromJSON([sh, shB], j2); eq(roundtrip.getHistory(sh).map((h) => h.tx_hash), ["tx-old"], "v2 round-trip preserves first beacon history"); } // ---------- (3) WS handler dispatch ---------- console.log("3) WS handler dispatches blockchain.scripthash.get_history per scripthash"); await (async () => { const rawA = "aa11" + "00".repeat(20); const rawB = "bb22" + "00".repeat(20); const shA = scripthashOf(rawA); const shB = scripthashOf(rawB); const idx = new BeaconIndex([shA, shB]); idx.processTx({ txid: "aaa", vin: [], vout: [beaconOutput(rawA)] }, 100); idx.processTx({ txid: "bbb", vin: [], vout: [beaconOutput(rawB)] }, 101); const PORT = 51099; const wss = startElectrumWS(idx, { bind: "127.0.0.1", port: PORT }, null); await new Promise((r) => setTimeout(r, 100)); const call = (method, params) => new Promise((res, rej) => { const ws = new WebSocket(`ws://127.0.0.1:${PORT}`); let id = 0, buf = ""; ws.on("open", () => ws.send(JSON.stringify({ id: ++id, method, params }) + "\n")); ws.on("message", (d) => { buf += d.toString(); const nl = buf.indexOf("\n"); const line = (nl >= 0 ? buf.slice(0, nl) : buf).trim(); if (line) { try { const m = JSON.parse(line); res(m); ws.close(); } catch (e) { rej(e); } } }); ws.on("error", rej); setTimeout(() => rej(new Error("timeout")), 5000); }); const rA = await call("blockchain.scripthash.get_history", [shA]); eq(rA.result.map((h) => h.tx_hash), ["aaa"], "beacon A history served"); const rB = await call("blockchain.scripthash.get_history", [shB]); eq(rB.result.map((h) => h.tx_hash), ["bbb"], "beacon B history served"); const rUnknown = await call("blockchain.scripthash.get_history", ["ff".repeat(32)]); eq(rUnknown.result, [], "unknown scripthash returns [] (no error)"); const rTx = await call("blockchain.transaction.get", ["aaa", true]); ok(rTx.result && rTx.result.txid === "aaa", "blockchain.transaction.get finds cross-beacon"); wss.close(); })(); // ---------- (4) /tld-list.json snapshot shape + root recomputability ---------- console.log("4) /tld-list.json snapshot shape and root"); await (async () => { const rawTld = "ee44" + "00".repeat(20); const shTld = scripthashOf(rawTld); const idx = new BeaconIndex([shTld]); const CAT_X = "aa".repeat(32); const CAT_TWO = "bb".repeat(32); // Each tx pays the beacon (proof it belongs there), carries the TREG/TUPD // OP_RETURN, and mints/transfers the TLD NFT (proof of ownership). const t1 = { txid: "t1", vin: [{ txid: CAT_X, vout: 0 }], vout: [beaconOutput(rawTld), opReturnOutput("TREG", "x", { policy: "open", fee_bps: 500 }), nftMintOutput(CAT_X, "x")] }; const t2 = { txid: "t2", vin: [{ txid: CAT_TWO, vout: 0 }], vout: [beaconOutput(rawTld), opReturnOutput("TREG", "two", { policy: "open" }), nftMintOutput(CAT_TWO, "two")] }; const t3 = { txid: "t3", vin: [{ txid: "t1", vout: 2 }], vout: [beaconOutput(rawTld), opReturnOutput("TUPD", "x", { policy: "covenant", fee_bps: 1000 }), nftMintOutput(CAT_X, "x", "bchtest:qnew-owner")] }; idx.processTx(t1, 100); idx.processTx(t2, 100); idx.processTx(t3, 101); // Fake src with lastHeight / lastHash so the HTTP handler has something to // report. Only the fields the handler reads are needed. const src = { index: idx, lastHeight: 200, lastHash: "tip-hash" }; const PORT = 51100; const server = startHttpServer(src, { bind: "127.0.0.1", port: PORT, tldBeacon: shTld }); await new Promise((r) => setTimeout(r, 100)); const fetchJson = (path) => new Promise((res, rej) => { const req = http.request({ hostname: "127.0.0.1", port: PORT, path, method: "GET" }, (r) => { const chunks = []; r.on("data", (c) => chunks.push(c)); r.on("end", () => { try { res({ status: r.statusCode, body: JSON.parse(Buffer.concat(chunks).toString("utf8")) }); } catch (e) { rej(e); } }); }); req.on("error", rej); req.end(); }); const { status, body } = await fetchJson("/tld-list.json"); ok(status === 200, "GET /tld-list.json returns 200"); ok(typeof body.root === "string" && /^[0-9a-f]{64}$/.test(body.root), "root is 64-char hex"); ok(body.asOfHeight === 200 && body.asOfTxid === "tip-hash", "asOfHeight/asOfTxid populated from src"); ok(body.tlds.length === 2, "two TLDs in snapshot"); const x = body.tlds.find((t) => t.tld === "x"); ok(x && x.records.policy === "covenant" && x.records.fee_bps === 1000, "TUPD applied to x"); ok(x && x.updatedTxid === "t3", "updatedTxid tracks TUPD"); ok(x && x.owner === "bchtest:qnew-owner", "owner refreshed by TUPD"); // Root recomputes locally over returned tlds[] — the "verify" path the design promises. const canonical = JSON.stringify([...body.tlds].sort((a, b) => a.tld.localeCompare(b.tld))); const recomputed = createHash("sha256").update(canonical, "utf8").digest("hex"); ok(recomputed === body.root, "client recomputes root from returned tlds[]"); // Health endpoint const h = await fetchJson("/health"); ok(h.status === 200 && h.body.ok === true, "GET /health returns ok"); server.close(); // Direct buildTldSnapshot: TREG without a matching NFT mint commitment is rejected. const bad = new Map(); bad.set("tbad", { txid: "tbad", vin: [], vout: [opReturnOutput("TREG", "no-mint", {}), nftMintOutput("cc".repeat(32), "wrong-commit")] }); const snapBad = buildTldSnapshot({ history: [{ tx_hash: "tbad", height: 100 }], txs: bad }); ok(snapBad.tlds.length === 0, "TREG without matching mint commitment is dropped"); // 503 when TLD beacon not configured const src2 = { index: new BeaconIndex(["ff".repeat(32)]), lastHeight: 0, lastHash: null }; const PORT2 = 51101; const server2 = startHttpServer(src2, { bind: "127.0.0.1", port: PORT2, tldBeacon: "" }); await new Promise((r) => setTimeout(r, 100)); const { status: s503 } = await fetchJson.call(null, "/tld-list.json").catch(() => ({ status: 0 })); // fetchJson uses PORT (the first server) — build a scoped fetch for PORT2: const fetch2 = (path) => new Promise((res, rej) => { const req = http.request({ hostname: "127.0.0.1", port: PORT2, path, method: "GET" }, (r) => { const chunks = []; r.on("data", (c) => chunks.push(c)); r.on("end", () => res({ status: r.statusCode, body: Buffer.concat(chunks).toString("utf8") })); }); req.on("error", rej); req.end(); }); const { status: sr503 } = await fetch2("/tld-list.json"); ok(sr503 === 503, "endpoint returns 503 when no TLD beacon configured"); server2.close(); })(); console.log(failures === 0 ? "\n🎉 ALL PASSED — multi-beacon + /tld-list.json wired correctly" : `\n💥 ${failures} check(s) failed`); process.exit(failures === 0 ? 0 : 1);