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.
149 lines
6.6 KiB
JavaScript
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 },
|
|
);
|
|
}
|