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.
This commit is contained in:
Local Dev 2026-10-04 02:32:59 +02:00
parent 34e336258b
commit dabe9a9f8e
4 changed files with 587 additions and 2 deletions

99
src/blocklist-admin.mjs Normal file
View file

@ -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 <name> <reason> [evidence]
// node src/blocklist-admin.mjs unblock <name> [evidence]
//
// --list <key> which wallets.json entry is the list authority (default "blocklist")
// --as <listName> 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 <name> <reason> [evidence] | unblock <name> [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));
}

149
src/lib/blocklist-tx.js Normal file
View file

@ -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 },
);
}

View file

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

319
test/blocklist.test.mjs Normal file
View file

@ -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);
});