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.
99 lines
5.3 KiB
JavaScript
99 lines
5.3 KiB
JavaScript
// 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));
|
|
}
|