argus/src/lib/blocklist-tx.js
Local Dev dabe9a9f8e 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.
2026-10-04 02:32:59 +02:00

149 lines
6.6 KiB
JavaScript

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