diff --git a/PROTOCOL.md b/PROTOCOL.md index 3482adf..52dd21c 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -93,6 +93,14 @@ once per index build. Implemented identically in `src/lib/resolver-web.js` and `src/lib/bns.js` — **the two must stay in lockstep**, since a disagreement between them is a consensus split between clients. +## Blocklist (resolver-side) + +Not a chain rule. A resolver may subscribe to lists of flagged names and warn +about, or refuse, a name a list flags; the name stays registered and any +resolver that does not subscribe resolves it as before. The messages (`RPT`, +`BLK`, `UBK`), the authority rule and the client behaviour are in +`PROTOCOL-ADDENDUM-blocklist.md`. + ## Honest limitations (prototype vs production) - Uniqueness is **indexer policy**, not consensus. Production: a covenant-run diff --git a/src/lib/resolver-web.d.ts b/src/lib/resolver-web.d.ts index cea5fd1..b411ef7 100644 --- a/src/lib/resolver-web.d.ts +++ b/src/lib/resolver-web.d.ts @@ -56,11 +56,25 @@ export interface BnsNetwork { operatorAddress: string | null; accountPath: string; bchnRpcPort: number; + blocklists: { name: string; beacon: string; scripthash?: string }[]; } /** REL: does `tx` burn `entry`'s certificate, spending it from a beacon transaction in `txs`? */ export function burnsCertificate(tx: any, entry: { category: string }, txs: Map): boolean; /** One-block gap after a release. `releasedAt` undefined = never released. */ export function mayReRegister(releasedAt: number | undefined, height: number): boolean; +export interface BlockFlag { + name: string; reason: string; reportTxid: string; txid: string; height: number; + evidence: string | null; vote: string | null; source: string | null; address: string; +} +export function sha256(bytes: Uint8Array): Uint8Array; +export function addressToLockHex(address: string): string; +export function addressToScripthash(address: string): string; +export const BLOCK_REASONS: Set; +/** Reduce one list's history to its current flags (pure). */ +export function reduceBlocklist(o: { history: { tx_hash: string; height: number }[]; txs: Map | Record; address: string; source?: string | null }): Map; +export function blocklistAddress(list: { name?: string; beacon?: string }, index?: Map | null): string | null; +export function buildBlocklists(o?: { network?: any; lists?: { name?: string; beacon?: string }[]; index?: Map | null; electrum?: any; WebSocket?: any; client?: any; directIP?: boolean }): Promise>; +export function withBlockFlag(entry: T, flags: Map | null): T; export const NETWORKS: { chipnet: BnsNetwork; mainnet: BnsNetwork }; export const DEFAULT_NETWORK_ID: "chipnet"; export function networkIdFromEnv(): string; diff --git a/src/lib/resolver-web.js b/src/lib/resolver-web.js index 8a1b022..9a4651d 100644 --- a/src/lib/resolver-web.js +++ b/src/lib/resolver-web.js @@ -84,6 +84,19 @@ export const NETWORKS = { operatorAddress: "bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu", accountPath: "m/44'/145'/0'", bchnRpcPort: 48332, + // Blocklists this network's clients subscribe to by default + // (Decentralized.DNS/PROTOCOL-ADDENDUM-blocklist.md). A list is a NAME: + // its `bl` record holds the list address, so the authority can rotate + // on-chain; `beacon` is the address baked in for a client that cannot + // read the name yet. An entry counts only when it is spent FROM that + // address. The list is empty by design until a first real report. + blocklists: [ + { + name: "sirius-blocklist.x", + beacon: "bchtest:qrrnwvuasq5fy63mdvp3umx5qfavtu7mnq950rdssw", + scripthash: "fa14c5fd7a22bd1856b44168823a1ab3ff50961c917dafbcba749aeaa7b26646", + }, + ], }, mainnet: { id: "mainnet", @@ -121,6 +134,9 @@ export const NETWORKS = { operatorAddress: null, accountPath: "m/44'/145'/0'", bchnRpcPort: 8332, + // NOT PINNED: the blocklist beacon is one of the addresses reserved before + // the mainnet beacon goes live (ROADMAP-MAINNET.md §6). + blocklists: [], }, }; export const DEFAULT_NETWORK_ID = "chipnet"; @@ -301,15 +317,20 @@ function parseOpReturnHex(hex) { // (Decentralized.DNS/PROTOCOL-ADDENDUM-release.md) // TREG / TUPD — TLD registrations & updates on the TLD beacon // (Decentralized.DNS/DESIGN-tld-registry.md §3) + // RPT / BLK / UBK — blocklist report, block and unblock, on a list's + // own address (PROTOCOL-ADDENDUM-blocklist.md). An RPT + // covers several names, so its `n` is an array. // KEEP IN LOCKSTEP with bns.js parseOpReturn — memory rule. - if (KNOWN_OPS.has(p.o) && typeof p.n === "string") return p; + if (!KNOWN_OPS.has(p.o)) return null; + if (typeof p.n === "string") return p; + if (p.o === "RPT" && Array.isArray(p.n) && p.n.every((x) => typeof x === "string")) return p; } catch {} return null; } // The set of payload opcodes this codec recognises. New opcodes go here AND in // bns.js — divergence is a consensus split. -export const KNOWN_OPS = new Set(["REG", "UPD", "REL", "TREG", "TUPD"]); +export const KNOWN_OPS = new Set(["REG", "UPD", "REL", "TREG", "TUPD", "RPT", "BLK", "UBK"]); // ---- ordering ---- @@ -694,10 +715,275 @@ export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap = return names; } +// ---- blocklists (Decentralized.DNS/PROTOCOL-ADDENDUM-blocklist.md) ---------- +// A resolver-side advisory, not a chain rule: a flagged name is still +// registered, the resolver just says so. The consumer ships with an empty +// list so the first real report needs a transaction, not a client release. + +// SHA-256 and cashaddr decoding, dependency-free: this file is a single-file +// drop-in, and a list is published as an ADDRESS (in a name's `bl` record) +// while electrum indexes by scripthash. +const SHA256_K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); +const rotr = (x, n) => (x >>> n) | (x << (32 - n)); +export function sha256(bytes) { + const H = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]); + const len = bytes.length; + const padded = new Uint8Array(((len + 9 + 63) >> 6) << 6); + padded.set(bytes); + padded[len] = 0x80; + const dv = new DataView(padded.buffer); + dv.setUint32(padded.length - 8, Math.floor((len * 8) / 0x100000000)); + dv.setUint32(padded.length - 4, (len * 8) >>> 0); + const w = new Uint32Array(64); + for (let off = 0; off < padded.length; off += 64) { + for (let t = 0; t < 16; t++) w[t] = dv.getUint32(off + t * 4); + for (let t = 16; t < 64; t++) { + const s0 = rotr(w[t - 15], 7) ^ rotr(w[t - 15], 18) ^ (w[t - 15] >>> 3); + const s1 = rotr(w[t - 2], 17) ^ rotr(w[t - 2], 19) ^ (w[t - 2] >>> 10); + w[t] = (w[t - 16] + s0 + w[t - 7] + s1) | 0; + } + let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7]; + for (let t = 0; t < 64; t++) { + const t1 = (h + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + SHA256_K[t] + w[t]) | 0; + const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0; + h = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0; + } + H[0] += a; H[1] += b; H[2] += c; H[3] += d; H[4] += e; H[5] += f; H[6] += g; H[7] += h; + } + const out = new Uint8Array(32); + const odv = new DataView(out.buffer); + for (let i = 0; i < 8; i++) odv.setUint32(i * 4, H[i]); + return out; +} +const bytesToHex = (b) => [...b].map((x) => x.toString(16).padStart(2, "0")).join(""); + +const CASHADDR_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +function cashaddrPolymod(values) { + const GEN = [0x98f2bc8e61n, 0x79b76d99e2n, 0xf33e5fb3c4n, 0xae2eabe2a8n, 0x1e4f43e470n]; + let c = 1n; + for (const v of values) { + const c0 = c >> 35n; + c = ((c & 0x07ffffffffn) << 5n) ^ BigInt(v); + for (let i = 0; i < 5; i++) if ((c0 >> BigInt(i)) & 1n) c ^= GEN[i]; + } + return c ^ 1n; +} +// cashaddr -> locking bytecode hex. Throws on anything that is not a valid +// P2PKH / P2SH address (token-aware forms decode to the same script). +export function addressToLockHex(address) { + const text = String(address).trim().toLowerCase(); + const colon = text.indexOf(":"); + if (colon < 1) throw new Error(`address needs its network prefix: "${address}"`); + const prefix = text.slice(0, colon); + const data = [...text.slice(colon + 1)].map((ch) => CASHADDR_CHARSET.indexOf(ch)); + if (data.length < 9 || data.some((v) => v < 0)) throw new Error(`not a cashaddr: "${address}"`); + if (cashaddrPolymod([...[...prefix].map((ch) => ch.charCodeAt(0) & 31), 0, ...data]) !== 0n) { + throw new Error(`bad cashaddr checksum: "${address}"`); + } + const payload = []; + let acc = 0, bits = 0; + for (const v of data.slice(0, -8)) { + acc = (acc << 5) | v; bits += 5; + while (bits >= 8) { bits -= 8; payload.push((acc >> bits) & 0xff); } + } + const version = payload[0]; + const type = (version >> 3) & 0x0f; + const hash = bytesToHex(payload.slice(1)); + const want = [20, 24, 28, 32, 40, 48, 56, 64][version & 7]; + if (hash.length !== want * 2) throw new Error(`cashaddr hash length mismatch: "${address}"`); + if ((type === 0 || type === 2) && want === 20) return "76a914" + hash + "88ac"; + if ((type === 1 || type === 3) && want === 20) return "a914" + hash + "87"; + if ((type === 1 || type === 3) && want === 32) return "aa20" + hash + "87"; + throw new Error(`unsupported address type: "${address}"`); +} +// The key electrum indexes an address under: sha256(lockingBytecode), reversed. +export function addressToScripthash(address) { + return bytesToHex(sha256(hexToBytes(addressToLockHex(address))).reverse()); +} + +// The fixed reason vocabulary (addendum §4). Nothing else is a valid reason. +export const BLOCK_REASONS = new Set(["phishing", "impersonation", "malware", "scam-financial", "csam", "stolen"]); + +// AUTHORITY. Anyone can pay dust to an address, so "it is in the list's +// history" proves nothing. An entry counts only when its transaction spends +// an output locked to the list address — only the holder of the list's key +// (or, later, the covenant standing at that address) can produce one. +function spendsFromLock(tx, lockHex, txs) { + return (tx.vin || []).some((vin) => { + const out = spentOutput(vin, txs); + return !!(out && out.scriptPubKey && out.scriptPubKey.hex === lockHex); + }); +} +const isTxid = (v) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v); +function canonicalOrNull(raw) { + return typeof raw === "string" && RAWNAME_RE.test(raw) ? canonicalKey(raw) : null; +} + +// Reduce ONE list's history to its current flags. Pure: no I/O. +// history [{ tx_hash, height }] the list address's electrum history +// txs Map(txid -> verbose tx) (or a plain object) +// address the list address source: the list's name, for display +// Returns Map(name -> { name, reason, reportTxid, txid, height, evidence, vote, source, address }). +// +// Rules (addendum §3, §5, §6): +// - RPT, BLK and UBK all need the authority proof above. +// - A report is valid when its type is "block" or "unblock", it lists valid +// names, and a "block" report carries a reason from the vocabulary. +// - BLK is applied only if `p` names a valid "block" report in this list +// that covers the same name with the same reason; UBK only if `p` names a +// valid "unblock" report covering the name. The resolver never flags a +// name on the word of the address alone. +// - Entries apply in causal order; the latest valid one wins. +export function reduceBlocklist({ history, txs, address, source = null }) { + const lockHex = addressToLockHex(address); + const txMap = txs instanceof Map ? txs : new Map(Object.entries(txs || {})); + const ordered = orderByDependency([...(history || [])].filter((h) => txMap.has(h.tx_hash)).sort(byHeightThenTxid), txMap); + + const payloadOf = (tx) => { + let payload = null; + for (const v of tx.vout || []) payload = parseOpReturnHex(v.scriptPubKey?.hex) ?? payload; + return payload; + }; + + // Pass 1: the reports. Looked up by txid, so their position does not matter. + const reports = new Map(); + for (const { tx_hash, height } of ordered) { + const tx = txMap.get(tx_hash); + const p = payloadOf(tx); + if (!p || p.o !== "RPT") continue; + if (!spendsFromLock(tx, lockHex, txMap)) continue; + if (p.t !== "block" && p.t !== "unblock") continue; + const names = (p.n || []).map(canonicalOrNull); + if (!names.length || names.some((n) => !n)) continue; + if (p.t === "block" && !BLOCK_REASONS.has(p.r)) continue; + reports.set(tx_hash, { type: p.t, names: new Set(names), reason: p.r ?? null, evidence: p.e ?? null, vote: p.v ?? null, height }); + } + + // Pass 2: the flags, latest valid entry per name wins. + const flags = new Map(); + for (const { tx_hash, height } of ordered) { + const tx = txMap.get(tx_hash); + const p = payloadOf(tx); + if (!p || (p.o !== "BLK" && p.o !== "UBK")) continue; + if (!spendsFromLock(tx, lockHex, txMap)) continue; + const name = canonicalOrNull(p.n); + if (!name || !isTxid(p.p)) continue; + const report = reports.get(p.p); + if (!report || !report.names.has(name)) continue; + if (p.o === "BLK") { + if (report.type !== "block" || !BLOCK_REASONS.has(p.r) || report.reason !== p.r) continue; + flags.set(name, { + name, reason: p.r, reportTxid: p.p, txid: tx_hash, height, + evidence: p.e ?? report.evidence, vote: report.vote, source, address, + }); + } else { + if (report.type !== "unblock") continue; + flags.delete(name); + } + } + return flags; +} + +// The address a list is read from: the `bl` record of its name when that is a +// valid address, else the address baked into the network table. +export function blocklistAddress(list, index) { + const fromName = list && list.name && index && typeof index.get === "function" ? index.get(list.name)?.records?.bl : null; + if (typeof fromName === "string") { + try { addressToLockHex(fromName); return fromName; } catch { /* malformed record: fall back */ } + } + return list?.beacon ?? null; +} + +// Read every subscribed list and return the union of their flags: +// Map(name -> flag), where `flag.sources` holds one flag per list that blocks +// the name (the first is the one shown). `lists` are { name, beacon } records; +// pass `index` so a list's address can be taken from its name's `bl` record. +// An empty list costs one history call and nothing else. +// +// A list is an ordinary address, so it must be read from a FULL electrum +// server: the project's own indexers serve the name beacons only and would +// answer "no history" for it, which reads as an empty list. Without an explicit +// `client` or `electrum`, the beacon-only servers are left out of the pool. +export async function buildBlocklists({ network = NETWORK, lists, index = null, electrum, WebSocket: WS, client, directIP = false } = {}) { + const net = getNetwork(network); + const subscribed = lists ?? net.blocklists ?? []; + const out = new Map(); + if (!subscribed.length) return out; + const pool = electrum ?? net.electrum.filter((s) => !net.beaconOnly.test(typeof s === "string" ? s : s.url)); + const el = client ?? (await connectAny(pool, WS, { directIP })); + try { + for (const list of subscribed) { + const address = blocklistAddress(list, index); + if (!address) continue; + const scripthash = addressToScripthash(address); + const history = typeof el.unionHistory === "function" + ? (await el.unionHistory(scripthash)).history + : await el.call("blockchain.scripthash.get_history", [scripthash]); + if (!history || !history.length) continue; + const txs = new Map(); + for (const { tx_hash } of history) { + try { txs.set(tx_hash, await el.call("blockchain.transaction.get", [tx_hash, true])); } + catch { /* unreadable tx — skip it entirely */ } + } + for (const [name, flag] of reduceBlocklist({ history, txs, address, source: list.name ?? address })) { + const existing = out.get(name); + if (existing) existing.sources.push(flag); + else out.set(name, { ...flag, sources: [flag] }); + } + } + return out; + } finally { + if (!client) el.close(); + } +} + +// An entry as a client should see it: unchanged when no list flags the name, +// a COPY carrying the flag when one does. Never mutates the index. +export function withBlockFlag(entry, flags) { + const flag = entry && flags && typeof flags.get === "function" ? flags.get(entry.name) : null; + if (!flag) return entry; + return { ...entry, blocked: true, blockReason: flag.reason, blockReportTxid: flag.reportTxid, blockedBy: flag.source }; +} + +// Resolve a name. With no subscription (`blocklists: []` or `false`) this is +// exactly the pre-blocklist resolver. With one, a flagged name comes back with +// `blocked`, `blockReason`, `blockReportTxid` and `blockedBy`; what to do about +// it — warn, refuse, ignore — is the caller's policy. A list that cannot be +// read never stops a name from resolving. export async function resolveName(input, opts = {}) { const name = normalizeName(input); - const index = await buildIndex(opts); - return index.get(name) ?? null; + const net = getNetwork(opts.network); + const lists = opts.blocklists === undefined ? (net.blocklists ?? []) : (opts.blocklists || []); + if (!lists.length) { + const index = await buildIndex(opts); + return index.get(name) ?? null; + } + const el = opts.client ?? (await connectAny(opts.electrum ?? net.electrum, opts.WebSocket, { directIP: !!opts.directIP })); + try { + const index = await buildIndex({ ...opts, client: el }); + const entry = index.get(name) ?? null; + if (!entry) return null; + let flags = null; + // The caller's own client is reused; otherwise the list gets its own + // connection to a full server (see buildBlocklists). + try { + flags = await buildBlocklists(opts.client + ? { network: net, lists, index, client: opts.client } + : { network: net, lists, index, WebSocket: opts.WebSocket, directIP: !!opts.directIP }); + } catch { /* advisory only: an unreadable list must not block resolution */ } + return withBlockFlag(entry, flags); + } finally { + if (!opts.client) el.close(); + } } // Open one electrum connection against the pool and return the raw client, so