// The blocklist consumer (Decentralized.DNS/PROTOCOL-ADDENDUM-blocklist.md). // // No network. The rules are a pure reduction of one list address's history, so // every case is a synthetic history. The last block proves the contract the // roadmap asks for: a resolver with NO subscription is the pre-blocklist // resolver, and a subscription to an EMPTY list changes nothing either. import test from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { NETWORKS, KNOWN_OPS, BLOCK_REASONS, sha256, addressToLockHex, addressToScripthash, reduceBlocklist, blocklistAddress, buildBlocklists, withBlockFlag, resolveName, buildIndexFromSnapshot, } from "../src/lib/resolver-web.js"; import * as legacy from "../src/lib/bns.js"; import { reportPayload, blockPayload, unblockPayload, buildListTx, LIST_MIN_CHANGE_SAT } from "../src/lib/blocklist-tx.js"; import { addressToLockingBytecode, addressToScripthash as libauthScripthash } from "../src/lib/chain.js"; import { InsufficientFundsError } from "../src/lib/register-tx.js"; const NET = NETWORKS.chipnet; const LIST = NET.blocklists[0]; const LOCK = addressToLockHex(LIST.beacon); const OTHER_LOCK = addressToLockHex(NET.operatorAddress); // someone who is not the list // ---------- fixture builders ---------- const hex = (s) => Buffer.from(s, "utf8").toString("hex"); const txid = (n) => String(n).padStart(4, "0").repeat(16); const opReturn = (payload) => { const data = Buffer.from("BNS1" + JSON.stringify(payload), "utf8"); const push = data.length <= 75 ? Buffer.from([data.length]) : Buffer.from([0x4c, data.length]); return "6a" + Buffer.concat([push, data]).toString("hex"); }; const out = (n, lock) => ({ n, value: 0.0001, scriptPubKey: { hex: lock } }); const spend = (id, vout = 0) => ({ txid: txid(id), vout }); // A coin arriving at the list address from outside: gives the list something to spend. const FUND = (id) => ({ txid: txid(id), vin: [{ txid: "f".repeat(64), vout: 0 }], vout: [out(0, LOCK)] }); // An entry published by the list: spends a list coin, change back to the list. const ENTRY = (id, payload, spends) => ({ txid: txid(id), vin: spends, vout: [out(0, LOCK), { n: 1, value: 0, scriptPubKey: { hex: opReturn(payload) } }] }); // The same payload from a stranger who merely pays dust to the list address. const STRANGER = (id, payload) => ({ txid: txid(id), vin: [{ txid: "e".repeat(64), vout: 0 }], vout: [out(0, LOCK), { n: 1, value: 0, scriptPubKey: { hex: opReturn(payload) } }, out(2, OTHER_LOCK)] }); const RPT = (names, r = "phishing", t = "block", extra = {}) => ({ o: "RPT", t, n: names, ...(r ? { r } : {}), ...extra }); const BLK = (n, p, r = "phishing", extra = {}) => ({ o: "BLK", n, r, p: txid(p), ...extra }); const UBK = (n, p) => ({ o: "UBK", n, p: txid(p) }); function reduce(entries) { const history = entries.map(([tx, height]) => ({ tx_hash: tx.txid, height })); const txs = new Map(entries.map(([tx]) => [tx.txid, tx])); return reduceBlocklist({ history, txs, address: LIST.beacon, source: LIST.name }); } // ---------- primitives ---------- test("sha256 matches the platform implementation", () => { for (const len of [0, 1, 55, 56, 63, 64, 65, 119, 120, 257, 4096]) { const b = Buffer.alloc(len, len % 251); assert.equal(Buffer.from(sha256(new Uint8Array(b))).toString("hex"), createHash("sha256").update(b).digest("hex"), `length ${len}`); } }); test("address -> scripthash agrees with the pinned beacons and with libauth", () => { assert.equal(addressToScripthash(NET.beacon), NET.beaconScripthash); assert.equal(addressToScripthash(NET.tldBeacon), NET.tldBeaconScripthash); for (const l of NET.blocklists) { assert.equal(addressToScripthash(l.beacon), l.scripthash, "the table's scripthash must match its address"); assert.equal(addressToScripthash(l.beacon), libauthScripthash(l.beacon)); } assert.throws(() => addressToLockHex(LIST.beacon.slice(0, -1) + "x"), /checksum/); assert.throws(() => addressToLockHex("qrrnwvuasq5fy63mdvp3umx5qfavtu7mnq950rdssw"), /prefix/); }); test("the three opcodes exist in both codecs, and an RPT's name list parses", () => { for (const op of ["RPT", "BLK", "UBK"]) { assert.ok(KNOWN_OPS.has(op)); assert.ok(legacy.KNOWN_OPS.has(op)); } assert.deepEqual([...KNOWN_OPS].sort(), [...legacy.KNOWN_OPS].sort()); assert.deepEqual(legacy.parseOpReturn(opReturn(RPT(["a.x", "b.x"]))).n, ["a.x", "b.x"]); assert.equal(legacy.parseOpReturn(opReturn({ o: "RPT", t: "block", n: [1, 2] })), null, "a name list of non-strings is not a payload"); }); // ---------- the rules ---------- test("an empty list flags nothing", () => { assert.equal(reduce([]).size, 0); assert.equal(reduce([[FUND(1), 100]]).size, 0, "a funding payment is not an entry"); }); test("a block with a valid report flags the name", () => { const flags = reduce([[FUND(1), 100], [ENTRY(2, RPT(["scam.x"], "phishing", "block", { e: "sia://evidence" }), [spend(1)]), 101], [ENTRY(3, BLK("scam.x", 2), [spend(2)]), 101]]); const f = flags.get("scam.x"); assert.equal(f.reason, "phishing"); assert.equal(f.reportTxid, txid(2)); assert.equal(f.txid, txid(3)); assert.equal(f.source, LIST.name); assert.equal(f.evidence, "sia://evidence", "the report's evidence is carried when the flag has none"); }); test("a block whose report is missing is ignored", () => { assert.equal(reduce([[FUND(1), 100], [ENTRY(3, BLK("scam.x", 2), [spend(1)]), 101]]).size, 0); }); test("a block whose reason differs from its report is ignored", () => { const flags = reduce([[FUND(1), 100], [ENTRY(2, RPT(["scam.x"], "phishing"), [spend(1)]), 101], [ENTRY(3, BLK("scam.x", 2, "malware"), [spend(2)]), 102]]); assert.equal(flags.size, 0); }); test("a block for a name its report does not cover is ignored", () => { const flags = reduce([[FUND(1), 100], [ENTRY(2, RPT(["scam.x"]), [spend(1)]), 101], [ENTRY(3, BLK("innocent.x", 2), [spend(2)]), 102]]); assert.equal(flags.size, 0); }); test("reasons outside the fixed vocabulary are never applied", () => { assert.equal(BLOCK_REASONS.has("offensive"), false); const flags = reduce([[FUND(1), 100], [ENTRY(2, RPT(["x.x"], "offensive"), [spend(1)]), 101], [ENTRY(3, BLK("x.x", 2, "offensive"), [spend(2)]), 102]]); assert.equal(flags.size, 0); }); test("one report can flag several names", () => { const flags = reduce([ [FUND(1), 100], [ENTRY(2, RPT(["a.x", "b.x", "legacy"], "malware"), [spend(1)]), 101], [ENTRY(3, BLK("a.x", 2, "malware"), [spend(2)]), 102], [ENTRY(4, BLK("b.x", 2, "malware"), [spend(3)]), 102], [ENTRY(5, BLK("legacy", 2, "malware"), [spend(4)]), 102], ]); assert.deepEqual([...flags.keys()].sort(), ["a.x", "b.x", "legacy.bch"], "a bare .bch label is flagged under its canonical name"); }); // ---------- authority ---------- test("entries from someone who only PAYS the list address are ignored", () => { // The stranger's transactions are in the address history (they pay it dust), and well-formed. const flags = reduce([[FUND(1), 100], [STRANGER(2, RPT(["victim.x"])), 101], [STRANGER(3, BLK("victim.x", 2)), 102]]); assert.equal(flags.size, 0); }); test("a stranger's block cannot ride on the list's own report, nor the reverse", () => { const strangerBlock = reduce([[FUND(1), 100], [ENTRY(2, RPT(["victim.x"]), [spend(1)]), 101], [STRANGER(3, BLK("victim.x", 2)), 102]]); assert.equal(strangerBlock.size, 0); const strangerReport = reduce([[FUND(1), 100], [STRANGER(2, RPT(["victim.x"])), 101], [ENTRY(3, BLK("victim.x", 2), [spend(1)]), 102]]); assert.equal(strangerReport.size, 0, "the list's block needs the list's report"); }); test("a stranger cannot unblock", () => { const flags = reduce([ [FUND(1), 100], [ENTRY(2, RPT(["scam.x"]), [spend(1)]), 101], [ENTRY(3, BLK("scam.x", 2), [spend(2)]), 102], [STRANGER(4, RPT(["scam.x"], null, "unblock")), 103], [STRANGER(5, UBK("scam.x", 4)), 104], ]); assert.equal(flags.get("scam.x").reason, "phishing"); }); // ---------- unblock and ordering ---------- test("an unblock with a valid unblock report lifts the flag; latest entry wins", () => { const base = [[FUND(1), 100], [ENTRY(2, RPT(["scam.x"]), [spend(1)]), 101], [ENTRY(3, BLK("scam.x", 2), [spend(2)]), 102]]; const lifted = reduce([...base, [ENTRY(4, RPT(["scam.x"], null, "unblock"), [spend(3)]), 110], [ENTRY(5, UBK("scam.x", 4), [spend(4)]), 111]]); assert.equal(lifted.size, 0); const again = reduce([...base, [ENTRY(4, RPT(["scam.x"], null, "unblock"), [spend(3)]), 110], [ENTRY(5, UBK("scam.x", 4), [spend(4)]), 111], [ENTRY(6, RPT(["scam.x"], "malware"), [spend(5)]), 120], [ENTRY(7, BLK("scam.x", 6, "malware"), [spend(6)]), 121]]); assert.equal(again.get("scam.x").reason, "malware"); }); test("an unblock cannot cite a block report, or no report", () => { const base = [[FUND(1), 100], [ENTRY(2, RPT(["scam.x"]), [spend(1)]), 101], [ENTRY(3, BLK("scam.x", 2), [spend(2)]), 102]]; assert.equal(reduce([...base, [ENTRY(5, UBK("scam.x", 2), [spend(3)]), 111]]).size, 1, "the block report does not authorise an unblock"); assert.equal(reduce([...base, [ENTRY(5, UBK("scam.x", 77), [spend(3)]), 111]]).size, 1); }); test("report and block in one block apply whatever their txid order", () => { // The BLK's txid sorts BEFORE its report's. It spends the report's change, so causal order fixes it — // and the report is looked up by txid, so it would be found even without that. const chained = reduce([[FUND(1), 100], [ENTRY(9, RPT(["scam.x"]), [spend(1)]), 105], [ENTRY(2, BLK("scam.x", 9), [spend(9)]), 105]]); assert.equal(chained.get("scam.x").reportTxid, txid(9)); const unchained = reduce([[FUND(1), 100], [FUND(8), 100], [ENTRY(9, RPT(["scam.x"]), [spend(1)]), 105], [ENTRY(2, BLK("scam.x", 9), [spend(8)]), 105]]); assert.equal(unchained.get("scam.x").reportTxid, txid(9)); }); test("unconfirmed entries apply, like every other BNS message", () => { const flags = reduce([[FUND(1), 100], [ENTRY(2, RPT(["scam.x"]), [spend(1)]), 0], [ENTRY(3, BLK("scam.x", 2), [spend(2)]), -1]]); assert.equal(flags.get("scam.x").reason, "phishing"); }); // ---------- where a list is read from ---------- test("a list's address comes from its name's bl record, else from the table", () => { const other = NET.operatorAddress; assert.equal(blocklistAddress(LIST, null), LIST.beacon); assert.equal(blocklistAddress(LIST, new Map([[LIST.name, { records: { bl: other } }]])), other, "the name can rotate the address"); assert.equal(blocklistAddress(LIST, new Map([[LIST.name, { records: { bl: "not-an-address" } }]])), LIST.beacon, "a malformed record falls back"); assert.equal(blocklistAddress(LIST, new Map([[LIST.name, { records: {} }]])), LIST.beacon); }); // ---------- the write side ---------- test("payload builders enforce the vocabulary, the report link and the size", () => { assert.throws(() => reportPayload({ type: "block", names: ["a.x"], reason: "offensive" }), /reason must be one of/); assert.throws(() => reportPayload({ type: "ban", names: ["a.x"], reason: "phishing" }), /block.*unblock/); assert.throws(() => reportPayload({ type: "block", names: [], reason: "phishing" }), /at least one name/); assert.throws(() => blockPayload({ name: "a.x", reason: "phishing", reportTxid: "nope" }), /64-hex/); assert.throws(() => reportPayload({ type: "block", names: ["a.x"], reason: "phishing", evidence: "x".repeat(200) }), /too large/); const text = (b) => Buffer.from(b).toString("utf8"); assert.equal(text(reportPayload({ type: "unblock", names: ["A.X"] })), 'BNS1{"o":"RPT","t":"unblock","n":["a.x"]}'); assert.equal(text(blockPayload({ name: "scam.x", reason: "phishing", reportTxid: txid(2) })), `BNS1{"o":"BLK","n":"scam.x","r":"phishing","p":"${txid(2)}"}`); assert.equal(text(unblockPayload({ name: "scam.x", reportTxid: txid(4) })), `BNS1{"o":"UBK","n":"scam.x","p":"${txid(4)}"}`); }); test("buildListTx spends from the list and returns its change to the list", () => { const lock = addressToLockingBytecode(LIST.beacon); const coin = { txid: txid(1), vout: 0, satoshis: 20_000n, address: LIST.beacon, lockingBytecode: lock }; const foreign = { txid: txid(5), vout: 0, satoshis: 900_000n, address: NET.operatorAddress, lockingBytecode: addressToLockingBytecode(NET.operatorAddress) }; const built = buildListTx({ payload: reportPayload({ type: "block", names: ["scam.x"], reason: "phishing" }), listAddress: LIST.beacon, utxos: [foreign, coin], addressToLockingBytecode }); assert.equal(built.transaction.inputs.length, 1); assert.equal(Buffer.from(built.transaction.inputs[0].outpointTransactionHash).toString("hex"), txid(1), "a coin from another address is never used: it would not prove authority"); assert.equal(Buffer.from(built.transaction.outputs[0].lockingBytecode).toString("hex"), LOCK); assert.equal(built.changeSats + built.chainFeeSats, 20_000n); assert.ok(built.changeSats >= LIST_MIN_CHANGE_SAT); const next = built.next(txid(2)); assert.deepEqual([next.txid, next.vout, next.satoshis], [txid(2), 0, built.changeSats], "the change is the next entry's input"); assert.throws(() => buildListTx({ payload: new Uint8Array(10), listAddress: LIST.beacon, utxos: [foreign], addressToLockingBytecode }), InsufficientFundsError); assert.throws(() => buildListTx({ payload: new Uint8Array(10), listAddress: LIST.beacon, utxos: [{ ...coin, satoshis: 700n }], addressToLockingBytecode }), InsufficientFundsError); }); test("what the builders write, the reducer accepts", () => { // Round trip through the real payload encoders rather than hand-written JSON. const asEntry = (id, payloadBytes, spends) => { const data = Buffer.from(payloadBytes); const push = data.length <= 75 ? Buffer.from([data.length]) : Buffer.from([0x4c, data.length]); return { txid: txid(id), vin: spends, vout: [out(0, LOCK), { n: 1, value: 0, scriptPubKey: { hex: "6a" + Buffer.concat([push, data]).toString("hex") } }] }; }; const flags = reduce([ [FUND(1), 100], [asEntry(2, reportPayload({ type: "block", names: ["scam.x"], reason: "scam-financial", evidence: "sia://abc" }), [spend(1)]), 101], [asEntry(3, blockPayload({ name: "scam.x", reason: "scam-financial", reportTxid: txid(2) }), [spend(2)]), 101], ]); assert.equal(flags.get("scam.x").reason, "scam-financial"); }); // ---------- the contract: no subscription, or an empty list, changes nothing ---------- const cert = (name, category) => ({ n: 0, value: 0.00001, scriptPubKey: { hex: "76a914" + "11".repeat(20) + "88ac", address: "bchtest:owner" }, tokenData: { category, amount: "0", nft: { capability: "none", commitment: hex(name) } } }); const REG = (id, name, category) => ({ txid: txid(id), vin: [{ txid: category, vout: 0 }], vout: [cert(name, category), { n: 1, value: 0, scriptPubKey: { hex: opReturn({ o: "REG", n: name, r: { u: "https://site.example" } }) } }] }); // A fake electrum client: the name beacon, the TLD beacon (empty), and the list address. function fakeClient({ names, list = [], failList = false }) { const byScripthash = new Map([ [NET.beaconScripthash, names], [NET.tldBeaconScripthash, []], [addressToScripthash(LIST.beacon), list], ]); const txs = new Map([...names, ...list].map(([tx]) => [tx.txid, tx])); const calls = []; return { calls, close() {}, async call(method, [arg]) { calls.push(method + ":" + String(arg).slice(0, 8)); if (method === "blockchain.scripthash.get_history") { if (failList && arg === addressToScripthash(LIST.beacon)) throw new Error("server refused"); return (byScripthash.get(arg) || []).map(([tx, height]) => ({ tx_hash: tx.txid, height })); } if (method === "blockchain.transaction.get") return txs.get(arg); throw new Error("unexpected " + method); }, }; } const NAMES = [[REG(20, "good.x", "a".repeat(64)), 90], [REG(21, "scam.x", "b".repeat(64)), 91]]; const FLAGGED = [[FUND(1), 100], [ENTRY(2, RPT(["scam.x"]), [spend(1)]), 101], [ENTRY(3, BLK("scam.x", 2), [spend(2)]), 102]]; test("no subscription: resolveName is exactly the pre-blocklist resolver", async () => { const history = NAMES.map(([tx, height]) => ({ tx_hash: tx.txid, height })); const before = buildIndexFromSnapshot({ snapshot: { history, txs: Object.fromEntries(NAMES.map(([tx]) => [tx.txid, tx])) } }); for (const blocklists of [[], false]) { const client = fakeClient({ names: NAMES, list: FLAGGED }); for (const n of ["good.x", "scam.x"]) { assert.deepEqual(await resolveName(n, { client, blocklists }), before.get(n)); } assert.equal(await resolveName("absent.x", { client, blocklists }), null); assert.ok(!client.calls.some((c) => c.endsWith(addressToScripthash(LIST.beacon).slice(0, 8))), "the list address is never even queried"); } }); test("default subscription to an EMPTY list: same answers, one extra history call", async () => { const unsubscribed = fakeClient({ names: NAMES }); const subscribed = fakeClient({ names: NAMES, list: [] }); for (const n of ["good.x", "scam.x", "absent.x"]) { assert.deepEqual(await resolveName(n, { client: subscribed }), await resolveName(n, { client: unsubscribed, blocklists: [] })); } const good = await resolveName("good.x", { client: subscribed }); assert.equal("blocked" in good, false, "an unflagged entry carries no blocklist field at all"); const listCalls = subscribed.calls.filter((c) => c === "blockchain.scripthash.get_history:" + addressToScripthash(LIST.beacon).slice(0, 8)); assert.equal(listCalls.length, 3, "one history call per resolution, and no transaction fetch for an empty list"); }); test("a flagged name resolves with the flag attached; others are untouched", async () => { const client = fakeClient({ names: NAMES, list: FLAGGED }); const scam = await resolveName("scam.x", { client }); assert.equal(scam.blocked, true); assert.equal(scam.blockReason, "phishing"); assert.equal(scam.blockReportTxid, txid(2)); assert.equal(scam.blockedBy, LIST.name); assert.deepEqual(scam.records, { u: "https://site.example" }, "the name still resolves: what to do with the flag is the client's policy"); assert.equal("blocked" in (await resolveName("good.x", { client })), false); }); test("a list that cannot be read never stops a name from resolving", async () => { const client = fakeClient({ names: NAMES, list: FLAGGED, failList: true }); const scam = await resolveName("scam.x", { client }); assert.equal(scam.category, "b".repeat(64)); assert.equal("blocked" in scam, false); }); test("buildBlocklists: union over lists, and withBlockFlag never mutates the index", async () => { const client = fakeClient({ names: NAMES, list: FLAGGED }); const flags = await buildBlocklists({ client, lists: [LIST, { name: "second-list.x", beacon: LIST.beacon }] }); assert.equal(flags.get("scam.x").sources.length, 2, "two lists flag it: both are kept, the first is shown"); assert.equal(flags.get("scam.x").source, LIST.name); const entry = { name: "scam.x", records: {} }; const flagged = withBlockFlag(entry, flags); assert.equal(flagged.blocked, true); assert.equal("blocked" in entry, false); assert.equal(withBlockFlag({ name: "good.x" }, flags).blocked, undefined); assert.equal((await buildBlocklists({ client, lists: [] })).size, 0); });