From dabe9a9f8ef0a1b74d354f5a09cb22fa134e3067 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 02:32:59 +0200 Subject: [PATCH] blocklist: write side, operator tool, and the tests the roadmap asks for blocklist-tx.js builds the three payloads and a transaction that takes its inputs from the list address and returns its change there, which is both the authority proof and the funding of the next entry. blocklist-admin.mjs publishes a report and chains the flag on its change, so the pair needs no waiting; it refuses free-text reasons and declines 'stolen' in this phase. bns.js recognises the opcodes and reduces a list through the shared reducer rather than a second copy of the rules. The tests cover the validity rules, the authority rule against a stranger who only pays the address, ordering inside one block, and the contract that matters most: no subscription, or a subscription to an empty list, changes no answer. --- src/blocklist-admin.mjs | 99 +++++++++++++ src/lib/blocklist-tx.js | 149 +++++++++++++++++++ src/lib/bns.js | 22 ++- test/blocklist.test.mjs | 319 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 src/blocklist-admin.mjs create mode 100644 src/lib/blocklist-tx.js create mode 100644 test/blocklist.test.mjs diff --git a/src/blocklist-admin.mjs b/src/blocklist-admin.mjs new file mode 100644 index 0000000..522985a --- /dev/null +++ b/src/blocklist-admin.mjs @@ -0,0 +1,99 @@ +// Operate a blocklist (Decentralized.DNS/PROTOCOL-ADDENDUM-blocklist.md). +// +// node src/blocklist-admin.mjs show +// node src/blocklist-admin.mjs block [evidence] +// node src/blocklist-admin.mjs unblock [evidence] +// +// --list which wallets.json entry is the list authority (default "blocklist") +// --as the list's BNS name, for display (default: the network's first list) +// --dry build and print, do not broadcast +// --force allow reason "stolen" (declined in Phase 1, addendum §9) +// +// The list address must hold a coin: an entry is valid only when it is SPENT +// from the list address. Each command publishes a report (RPT) and then the +// flag (BLK / UBK) chained on the report's change, so the pair is atomic in +// practice and needs no waiting between the two. +// +// Reasons: phishing, impersonation, malware, scam-financial, csam, stolen. +// Nothing else — a list that takes free-text reasons cannot be audited. + +import { readFileSync } from "node:fs"; +import WebSocket from "ws"; +import { BuiltInWallet } from "./lib/wallet-web.js"; +import { addressToLockingBytecode, getUtxosForAddresses, broadcast } from "./lib/chain.js"; +import { connect } from "./lib/registrar.js"; +import { signTransaction } from "./lib/sign-tx.js"; +import { buildIndex, buildBlocklists, normalizeName, NETWORK, BLOCK_REASONS } from "./lib/resolver-web.js"; +import { reportPayload, blockPayload, unblockPayload, buildListTx } from "./lib/blocklist-tx.js"; +import { WALLET_FILE } from "./config.js"; + +const argv = process.argv.slice(2); +const flag = (name) => { const i = argv.indexOf(name); if (i < 0) return null; const v = argv[i + 1]; argv.splice(i, 2); return v; }; +const has = (name) => { const i = argv.indexOf(name); if (i < 0) return false; argv.splice(i, 1); return true; }; +const walletKey = flag("--list") || "blocklist"; +const listName = flag("--as") || (walletKey === "blocklist" ? NETWORK.blocklists[0]?.name : walletKey); +const dry = has("--dry"); +const force = has("--force"); +const [cmd, nameArg, a2, a3] = argv; + +const store = JSON.parse(readFileSync(WALLET_FILE, "utf8")); +if (!store[walletKey]?.seed) { console.error(`wallets.json has no "${walletKey}" entry`); process.exit(1); } +const wallet = await BuiltInWallet.fromMnemonic(store[walletKey].seed, NETWORK.prefix); +const listAddress = wallet.address; +const el = await connect({ WebSocket }); +const done = (code = 0) => { try { el.close(); } catch {} process.exit(code); }; +const fail = (msg) => { console.error(msg); done(1); }; + +async function currentFlags() { + return buildBlocklists({ client: el, lists: [{ name: listName, beacon: listAddress }] }); +} +async function publish(payload, utxos) { + const built = buildListTx({ payload, listAddress, utxos, addressToLockingBytecode }); + const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h)); + if (dry) return { txid: signed.txid, next: built.next(signed.txid), built }; + const txid = await broadcast(el, signed.hex); + return { txid, next: built.next(txid), built }; +} + +try { + if (cmd === "show") { + const utxos = await getUtxosForAddresses(el, [listAddress]); + const flags = await currentFlags(); + console.log(`list ${listName}`); + console.log(`address ${listAddress}`); + console.log(`coins ${utxos.length} (${utxos.reduce((a, u) => a + u.satoshis, 0n)} sat)`); + console.log(`flags ${flags.size}`); + for (const f of flags.values()) console.log(` ${f.name} ${f.reason} report ${f.reportTxid} block ${f.height > 0 ? f.height : "unconfirmed"}${f.evidence ? " evidence " + f.evidence : ""}`); + done(); + } + + if (cmd !== "block" && cmd !== "unblock") { + console.log("usage: blocklist-admin.mjs [--list key] [--as name] [--dry] show | block [evidence] | unblock [evidence]"); + done(1); + } + if (!nameArg) fail("name required"); + const name = normalizeName(nameArg); + const reason = cmd === "block" ? a2 : null; + const evidence = cmd === "block" ? a3 : a2; + + const flags = await currentFlags(); + if (cmd === "block") { + if (!BLOCK_REASONS.has(reason)) fail(`reason must be one of: ${[...BLOCK_REASONS].join(", ")}`); + if (reason === "stolen" && !force) fail(`"stolen" needs an evidence standard this list cannot enforce (addendum §9) — pass --force only with a documented case`); + const index = await buildIndex({ client: el }); + if (!index.has(name)) fail(`"${name}" is not registered — there is nothing to flag`); + if (flags.get(name)?.reason === reason) fail(`"${name}" is already flagged ${reason}`); + } else if (!flags.has(name)) fail(`"${name}" is not flagged on this list`); + + const utxos = (await getUtxosForAddresses(el, [listAddress])).filter((u) => !u.token); + const report = await publish(reportPayload({ type: cmd, names: [name], reason: reason || undefined, evidence }), utxos); + console.log(`${dry ? "(dry) " : ""}report RPT ${report.txid} fee ${report.built.chainFeeSats} sat`); + const entry = await publish( + cmd === "block" ? blockPayload({ name, reason, reportTxid: report.txid }) : unblockPayload({ name, reportTxid: report.txid }), + [report.next], + ); + console.log(`${dry ? "(dry) " : ""}${cmd === "block" ? "flag BLK" : "lift UBK"} ${entry.txid} fee ${entry.built.chainFeeSats} sat list balance ${entry.built.changeSats} sat`); + done(); +} catch (e) { + fail(e?.message || String(e)); +} diff --git a/src/lib/blocklist-tx.js b/src/lib/blocklist-tx.js new file mode 100644 index 0000000..cfbb0a2 --- /dev/null +++ b/src/lib/blocklist-tx.js @@ -0,0 +1,149 @@ +// Blocklist entries — the WRITE side (Decentralized.DNS/PROTOCOL-ADDENDUM-blocklist.md). +// +// A list is an address. An entry counts only when its transaction spends an +// output locked to that address, so every transaction built here takes its +// inputs from the list address and sends its change back to it: the change +// output both funds the next entry and keeps the transaction in the address's +// history, where resolvers read it. +// +// Three payloads, each "BNS1" + JSON in an OP_RETURN: +// RPT the report: which names, why, and where the evidence is +// BLK flags one name, naming the report that justifies it +// UBK lifts a flag, naming the report that justifies that +// A flag without a valid report is ignored by every resolver, so the order to +// publish is always report first. +// +// The read side and all the validity rules live in resolver-web.js +// (reduceBlocklist). Nothing here is needed to resolve names. + +import { encodeTransaction, hexToBin, utf8ToBin } from "@bitauth/libauth"; +import { + opReturnScript, + placeholderUnlocker, + normalizeName, + InsufficientFundsError, + MAX_PAYLOAD_BYTES, + P2PKH_DUST_SAT, +} from "./register-tx.js"; +import { BLOCK_REASONS } from "./resolver-web.js"; + +const PREFIX = "BNS1"; +// What every entry leaves at the list address at minimum (the beacon dust of +// the other BNS1 messages): below this the list could not fund a next entry. +export const LIST_MIN_CHANGE_SAT = 600n; + +const isTxid = (v) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v); + +function encodeListPayload(obj) { + const bytes = utf8ToBin(PREFIX + JSON.stringify(obj)); + if (bytes.length > MAX_PAYLOAD_BYTES) { + throw new Error(`payload too large (${bytes.length} > ${MAX_PAYLOAD_BYTES} bytes) — shorten the evidence reference or split the names over two reports`); + } + return bytes; +} + +/** + * RPT — one decision, covering one or more names. + * @param {"block"|"unblock"} o.type + * @param {string[]} o.names + * @param {string} [o.reason] required for "block"; one of BLOCK_REASONS + * @param {string} [o.evidence] reference to off-chain evidence (URL or hash) + * @param {string} [o.vote] Phase 2: the poll that authorised this + */ +export function reportPayload({ type, names, reason, evidence, vote }) { + if (type !== "block" && type !== "unblock") throw new Error(`report type must be "block" or "unblock"`); + const list = [...new Set((names || []).map((n) => normalizeName(n)))]; + if (!list.length) throw new Error("a report covers at least one name"); + if (type === "block" && !BLOCK_REASONS.has(reason)) { + throw new Error(`reason must be one of: ${[...BLOCK_REASONS].join(", ")}`); + } + if (reason !== undefined && reason !== null && !BLOCK_REASONS.has(reason)) { + throw new Error(`reason must be one of: ${[...BLOCK_REASONS].join(", ")}`); + } + const p = { o: "RPT", t: type, n: list }; + if (reason) p.r = reason; + if (evidence) p.e = String(evidence); + if (vote) p.v = String(vote); + return encodeListPayload(p); +} + +/** BLK — flag `name` for `reason`, justified by the report `reportTxid`. */ +export function blockPayload({ name, reason, reportTxid, evidence }) { + if (!BLOCK_REASONS.has(reason)) throw new Error(`reason must be one of: ${[...BLOCK_REASONS].join(", ")}`); + if (!isTxid(reportTxid)) throw new Error("reportTxid must be the 64-hex txid of the report"); + const p = { o: "BLK", n: normalizeName(name), r: reason, p: reportTxid }; + if (evidence) p.e = String(evidence); + return encodeListPayload(p); +} + +/** UBK — lift the flag on `name`, justified by the "unblock" report `reportTxid`. */ +export function unblockPayload({ name, reportTxid }) { + if (!isTxid(reportTxid)) throw new Error("reportTxid must be the 64-hex txid of the report"); + return encodeListPayload({ o: "UBK", n: normalizeName(name), p: reportTxid }); +} + +const toInput = (u) => ({ + outpointTransactionHash: hexToBin(u.txid), + outpointIndex: u.vout, + sequenceNumber: 0xfffffffe, + unlockingBytecode: placeholderUnlocker(), +}); +const toSourceOutput = (u) => ({ ...toInput(u), lockingBytecode: u.lockingBytecode, valueSatoshis: u.satoshis }); + +/** + * Build one list transaction carrying `payload`. + * + * Inputs are plain coins AT the list address (that is the authority proof); + * output 0 is the change, back to the list address; output 1 is the OP_RETURN. + * + * @param {Uint8Array} o.payload from reportPayload / blockPayload / unblockPayload + * @param {string} o.listAddress + * @param {Array} o.utxos coins at the list address ({txid, vout, satoshis, lockingBytecode}) + * @returns {{ transaction, sourceOutputs, changeSats, chainFeeSats, next(txid) }} + * `next(txid)` is the change output as a spendable coin, so a BLK can be + * chained on its RPT without waiting for a server to show it. + */ +export function buildListTx({ payload, listAddress, utxos, feeRate = 1, addressToLockingBytecode }) { + if (typeof addressToLockingBytecode !== "function") { + throw new Error("addressToLockingBytecode must be injected (see chain.js)"); + } + const listLock = addressToLockingBytecode(listAddress); + const lockHex = Buffer.from(listLock).toString("hex"); + const coins = (utxos || []) + .filter((u) => !u.token && Buffer.from(u.lockingBytecode).toString("hex") === lockHex) + .sort((a, b) => (b.satoshis > a.satoshis ? 1 : -1)); + if (!coins.length) { + throw new InsufficientFundsError( + `the list address ${listAddress} holds no coin — an entry must be spent from it, so fund it first`, + { needSats: LIST_MIN_CHANGE_SAT, haveSats: 0n }, + ); + } + + const chosen = []; + let total = 0n; + for (const coin of coins) { + chosen.push(coin); + total += coin.satoshis; + const draft = { + version: 2, locktime: 0, inputs: chosen.map(toInput), + outputs: [{ lockingBytecode: listLock, valueSatoshis: total }, { lockingBytecode: opReturnScript(payload), valueSatoshis: 0n }], + }; + const fee = BigInt(Math.ceil(encodeTransaction(draft).length * feeRate)); + const floor = LIST_MIN_CHANGE_SAT > P2PKH_DUST_SAT ? LIST_MIN_CHANGE_SAT : P2PKH_DUST_SAT; + if (total >= fee + floor) { + const change = total - fee; + const transaction = { ...draft, outputs: [{ lockingBytecode: listLock, valueSatoshis: change }, draft.outputs[1]] }; + return { + transaction, + sourceOutputs: chosen.map(toSourceOutput), + changeSats: change, + chainFeeSats: fee, + next: (txid) => ({ txid, vout: 0, satoshis: change, address: listAddress, lockingBytecode: listLock }), + }; + } + } + throw new InsufficientFundsError( + `the list address ${listAddress} cannot pay for an entry: it holds ${total} sat`, + { needSats: LIST_MIN_CHANGE_SAT + 400n, haveSats: total }, + ); +} diff --git a/src/lib/bns.js b/src/lib/bns.js index 7ac39ba..1c5ba55 100644 --- a/src/lib/bns.js +++ b/src/lib/bns.js @@ -1,6 +1,7 @@ // BNS1 core: payload codec, chain indexer/resolver, and tx builders. import { OpReturnData, TokenSendRequest, NFTCapability } from "mainnet-js"; import { BEACON, TLD_BEACON, PREFIX, BEACON_DUST_SAT, NAME_RE } from "../config.js"; +import { reduceBlocklist } from "./resolver-web.js"; // ---------- payload codec ---------- @@ -51,15 +52,19 @@ export function parseOpReturn(scriptHex) { // REG / UPD — name registrations & updates on the name beacon // TREG / TUPD — TLD registrations & updates on the TLD beacon // (Decentralized.DNS/DESIGN-tld-registry.md §3) + // RPT / BLK / UBK — blocklist report, block and unblock + // (PROTOCOL-ADDENDUM-blocklist.md); an RPT's `n` is an array. // KEEP IN LOCKSTEP with resolver-web.js parseOpReturnHex — memory rule. - if (KNOWN_OPS.has(p.o) && typeof p.n === "string") return p; + if (!KNOWN_OPS.has(p.o)) return null; + if (typeof p.n === "string") return p; + if (p.o === "RPT" && Array.isArray(p.n) && p.n.every((x) => typeof x === "string")) return p; } catch { /* not ours */ } return null; } // The set of payload opcodes this codec recognises. New opcodes go here AND in // resolver-web.js — divergence is a consensus split. -export const KNOWN_OPS = new Set(["REG", "UPD", "REL", "TREG", "TUPD"]); +export const KNOWN_OPS = new Set(["REG", "UPD", "REL", "TREG", "TUPD", "RPT", "BLK", "UBK"]); // ---------- indexer / resolver ---------- @@ -294,6 +299,19 @@ export async function buildIndex(provider, { enforceTld = false, cosignGate = tr return names; } +// ---- blocklists (PROTOCOL-ADDENDUM-blocklist.md) ---- +// The rules live in ONE place, resolver-web.js reduceBlocklist; this is only +// the mainnet-js-provider way of feeding it a list's history. +export async function buildBlocklist(provider, { address, source = null }) { + const history = await provider.getHistory(address); + const txs = new Map(); + for (const { tx_hash } of history) { + try { txs.set(tx_hash, await provider.getRawTransactionObject(tx_hash)); } + catch { /* unreadable tx — skip it entirely */ } + } + return reduceBlocklist({ history, txs, address, source }); +} + export async function resolveName(provider, input) { const name = normalizeName(input); const index = await buildIndex(provider); diff --git a/test/blocklist.test.mjs b/test/blocklist.test.mjs new file mode 100644 index 0000000..539d9e3 --- /dev/null +++ b/test/blocklist.test.mjs @@ -0,0 +1,319 @@ +// 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: [], 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); +});