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