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