Resolvers took the last message in a transaction. Wherever one party fixes a message and another assembles the transaction - the delivery escrow pins the registration at output 1 - a later output could replace it and every resolver would read that one. Counting instead of choosing removes the question: one message, or the transaction says nothing. Same rule in all three resolvers. The Android parser now recognises the same set of operations as the JavaScript one, since what counts as a message decides which transactions are void. No transaction on chipnet carried more than one. Three older suites built registrations that never created their certificate's category; their fixtures now do, as the sole-certificate rule requires.
163 lines
9.3 KiB
JavaScript
163 lines
9.3 KiB
JavaScript
// "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");
|
|
}
|
|
});
|