argus/src/blocklist-admin.mjs

100 lines
5.3 KiB
JavaScript
Raw Normal View History

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