argus/test/tld-index.test.mjs
Local Dev b4fc73f0fa BNS: a transaction with more than one protocol message carries none
Resolvers took the last message in a transaction. Wherever one party fixes
a message and another assembles the transaction - the delivery escrow pins
the registration at output 1 - a later output could replace it and every
resolver would read that one. Counting instead of choosing removes the
question: one message, or the transaction says nothing.

Same rule in all three resolvers. The Android parser now recognises the
same set of operations as the JavaScript one, since what counts as a
message decides which transactions are void. No transaction on chipnet
carried more than one.

Three older suites built registrations that never created their
certificate's category; their fixtures now do, as the sole-certificate
rule requires.
2026-10-04 16:53:58 +02:00

252 lines
12 KiB
JavaScript

// 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);