// "The certificate must be the only token of its category." // // Ownership of a name is proven by holding a token of its category: resolvers // accept an UPD when some output carries it. That is only sound if a // registration's certificate is the ONLY such token there is or can ever be. // These tests pin the rule that guarantees it, and the attack it stops: a // seller who registers a name while keeping a hidden second token, sells the // certificate, and rewrites the name afterwards. // // Both JavaScript reducers run every case and must agree. import test from "node:test"; import assert from "node:assert/strict"; import { soleCertificate, buildIndexFromSnapshot, fetchTldMap } from "../src/lib/resolver-web.js"; import { buildTldSnapshot } from "../src/lib/tld-index.js"; import * as legacy from "../src/lib/bns.js"; const hex = (s) => Buffer.from(s, "utf8").toString("hex"); const txid = (n) => String(n).padStart(4, "0").repeat(16); const opReturn = (payload) => { const data = Buffer.from("BNS1" + JSON.stringify(payload), "utf8"); const push = data.length <= 75 ? Buffer.from([data.length]) : Buffer.from([0x4c, data.length]); return "6a" + Buffer.concat([push, data]).toString("hex"); }; const CAT = "c".repeat(64); const token = (category, commitment, capability = "none", amount = "0") => ({ category, amount, nft: { capability, commitment: hex(commitment) } }); const out = (n, tokenData, address = "bchtest:owner") => ({ n, value: 0.00001, scriptPubKey: { hex: "76a914" + String(n + 1).repeat(40).slice(0, 40) + "88ac", address }, ...(tokenData ? { tokenData } : {}) }); const data = (n, payload) => ({ n, value: 0, scriptPubKey: { hex: opReturn(payload) } }); const GENESIS = { txid: CAT, vout: 0 }; // A registration transaction, with knobs for everything the rule looks at. function reg(id, name, { op = "REG", vin = [GENESIS], cert = token(CAT, name), extra = [] } = {}) { return { txid: txid(id), vin, vout: [out(0, cert), data(1, { o: op, n: name, r: { u: "https://honest.example" } }), ...extra.map((t, i) => out(2 + i, t, "bchtest:hidden"))] }; } const upd = (id, name, r, tokenData, spends) => ({ txid: txid(id), vin: spends, vout: [out(0, tokenData, "bchtest:attacker"), data(1, { o: "UPD", n: name, r })] }); async function names(entries) { const history = entries.map(([tx, height]) => ({ tx_hash: tx.txid, height })); const txs = Object.fromEntries(entries.map(([tx]) => [tx.txid, tx])); const web = buildIndexFromSnapshot({ snapshot: { history, txs } }); const old = await legacy.buildIndex({ getHistory: async () => history.map((h) => ({ ...h })), getRawTransactionObject: async (id) => txs[id] }, { cosignGate: false }); const shape = (idx) => [...idx].map(([k, e]) => `${k}:${JSON.stringify(e.records)}`).sort(); assert.deepEqual(shape(old), shape(web), "bns.js and resolver-web.js disagree"); return web; } test("an ordinary registration passes", async () => { const tx = reg(1, "plain.x"); assert.equal(soleCertificate(tx, tx.vout[0]), true); assert.ok((await names([[tx, 100]])).has("plain.x")); // verbose transactions from different servers spell "no capability" and "no amount" differently for (const cert of [{ category: CAT, nft: { commitment: hex("plain.x") } }, { category: CAT, amount: 0, nft: { capability: "", commitment: hex("plain.x") } }]) { const t = reg(1, "plain.x", { cert }); assert.equal(soleCertificate(t, t.vout[0]), true); } }); const CASES = [ ["a second, minting token of the category is kept", { extra: [token(CAT, "", "minting")] }], ["a second plain token of the category is kept", { extra: [token(CAT, "plain.x")] }], ["fungible tokens of the category are issued alongside", { extra: [{ category: CAT, amount: "1000" }] }], ["the certificate itself can mint", { cert: token(CAT, "plain.x", "minting") }], ["the certificate itself can be rewritten", { cert: token(CAT, "plain.x", "mutable") }], ["the certificate carries a fungible amount", { cert: token(CAT, "plain.x", "none", "5") }], ["the category was not created by this transaction", { vin: [{ txid: "d".repeat(64), vout: 0 }] }], ["the category's outpoint is spent, but not at index 0", { vin: [{ txid: CAT, vout: 1 }] }], ["no inputs listed at all", { vin: [] }], ]; for (const [what, knobs] of CASES) { test(`a registration is void when ${what}`, async () => { const tx = reg(1, "plain.x", knobs); assert.equal(soleCertificate(tx, tx.vout[0]), false); assert.equal((await names([[tx, 100]])).has("plain.x"), false); }); } test("the attack: sell the certificate, keep a hidden token, rewrite the name", async () => { // The seller registers with a minting token tucked into another output. const tainted = reg(1, "resold.x", { extra: [token(CAT, "", "minting")] }); // Later — after selling the certificate — they publish an update from the hidden token. const hijack = upd(2, "resold.x", { u: "https://phishing.example" }, token(CAT, "", "minting"), [{ txid: txid(1), vout: 2 }]); const idx = await names([[tainted, 100], [hijack, 200]]); assert.equal(idx.has("resold.x"), false, "the tainted registration never existed, so there is nothing to hijack — or to sell"); // And an honest registration cannot be attacked this way at all: its category has one token, the buyer's. const honest = reg(3, "honest.x"); const forged = upd(4, "honest.x", { u: "https://phishing.example" }, token("e".repeat(64), "honest.x"), [{ txid: "f".repeat(64), vout: 0 }]); const idx2 = await names([[honest, 100], [forged, 200]]); assert.deepEqual(idx2.get("honest.x").records, { u: "https://honest.example" }); }); test("a void registration does not block an honest one for the same name", async () => { const tainted = reg(1, "wanted.x", { extra: [token(CAT, "", "minting")] }); const CAT2 = "a".repeat(64); const honest = reg(2, "wanted.x", { vin: [{ txid: CAT2, vout: 0 }], cert: token(CAT2, "wanted.x") }); const idx = await names([[tainted, 100], [honest, 101]]); assert.equal(idx.get("wanted.x").category, CAT2); }); test("TLD certificates obey the same rule, in every reducer that reads them", async () => { const good = reg(1, "zz", { op: "TREG" }); const CAT2 = "a".repeat(64); const bad = reg(2, "yy", { op: "TREG", vin: [{ txid: CAT2, vout: 0 }], cert: token(CAT2, "yy"), extra: [token(CAT2, "", "minting")] }); const history = [{ tx_hash: good.txid, height: 5 }, { tx_hash: bad.txid, height: 6 }]; const txs = new Map([[good.txid, good], [bad.txid, bad]]); const el = { call: async (m, [a]) => (m === "blockchain.scripthash.get_history" ? history.map((h) => ({ ...h })) : txs.get(a)) }; assert.deepEqual([...(await fetchTldMap(el)).keys()], ["zz"]); const snap = buildTldSnapshot({ history, txs, beacon: "x" }); assert.deepEqual((snap.tlds ?? []).map((t) => t.tld), ["zz"]); }); // ---------- one message per transaction ---------- import { onlyPayload } from "../src/lib/resolver-web.js"; test("a transaction with two protocol messages carries none, in every reducer", async () => { const honest = reg(1, "single.x"); assert.deepEqual(onlyPayload(honest.vout), { o: "REG", n: "single.x", r: { u: "https://honest.example" } }); assert.deepEqual(legacy.onlyPayload(honest.vout), onlyPayload(honest.vout)); // The escrow attack at the resolver: the agreed registration at output 1, // and a second message after it that used to win. const second = [ { o: "REG", n: "single.x", r: { u: "https://deliverer.example" } }, { o: "UPD", n: "single.x", r: {} }, { o: "REG", n: "elsewhere.x", r: {} }, ]; for (const payload of second) { const tx = { ...honest, vout: [...honest.vout, data(2, payload)] }; assert.equal(onlyPayload(tx.vout), null); assert.equal(legacy.onlyPayload(tx.vout), null); const idx = await names([[tx, 100]]); assert.equal(idx.size, 0, `nothing registers from a transaction that says two things (${payload.o} ${payload.n})`); } // Other data outputs are nobody's business: only BNS messages are counted. const withMemo = { ...honest, vout: [...honest.vout, { n: 2, value: 0, scriptPubKey: { hex: "6a0568656c6c6f" } }] }; assert.ok((await names([[withMemo, 100]])).has("single.x")); // TLD certificates and the blocklist read through the same function. const t = reg(2, "zz", { op: "TREG" }); const doubled = { ...t, vout: [...t.vout, data(2, { o: "TREG", n: "yy", r: {} })] }; const history = [{ tx_hash: doubled.txid, height: 5 }]; const txs = new Map([[doubled.txid, doubled]]); const el = { call: async (m, [a]) => (m === "blockchain.scripthash.get_history" ? history.map((h) => ({ ...h })) : txs.get(a)) }; assert.equal((await fetchTldMap(el)).size, 0); assert.deepEqual((buildTldSnapshot({ history, txs, beacon: "x" }).tlds ?? []).length, 0); }); test("what counts as a message is the same everywhere", () => { const honest = reg(1, "single.x"); const plus = (...payloads) => [...honest.vout, ...payloads.map((p, i) => data(2 + i, p))]; for (const read of [onlyPayload, legacy.onlyPayload]) { // list entries are messages too, though no name reducer acts on them assert.equal(read(plus({ o: "BLK", n: "single.x", c: "scam" })), null); assert.equal(read(plus({ o: "RPT", n: ["a.x", "b.x"] })), null); // malformed or unknown ones are not messages, and so do not void the transaction assert.equal(read(plus({ o: "UPD", n: 5 }, { o: "XYZ", n: "single.x" }, { o: "BLK", n: ["a.x"] }))?.o, "REG"); } });