bns: a registration is void unless its certificate is the only token of its category
Ownership of a name is proven by holding a token of its category: an update is accepted when some output carries it. Nothing required that the certificate be the only such token. Whoever registered a name could mint a second one in the same transaction, a minting token for instance, sell the certificate on the market, and later publish an update the resolvers would accept. The buyer would hold the certificate and lose the name. A registration now counts only if its category is created by that very transaction, exactly one output carries it, and that output is an NFT with no capability and no fungible amount. Nothing meeting this can ever have a sibling. The same holds for TLD certificates, whose category proves the right to set a TLD's policy and to co-sign registrations under it. One definition, in resolver-web.js; bns.js and the TLD snapshot builder call it. Every valid registration on chipnet already satisfied the rule: the live registry is the same 71 names and 21 TLDs before and after. Found while writing the delivery escrow, which had to forbid exactly this.
This commit is contained in:
parent
9aa8b6b33f
commit
4fb37e0683
8 changed files with 166 additions and 4 deletions
10
PROTOCOL.md
10
PROTOCOL.md
|
|
@ -64,7 +64,15 @@ A certificate moved that way must be anchored by an `UPD` before it can be relea
|
|||
from. Independent transactions keep the `txid` tiebreak. *(See "Ordering"
|
||||
below — this rule is load-bearing, added 2026-07-26.)*
|
||||
3. `REG`: valid if the same tx mints a certificate whose commitment matches the name
|
||||
and the name is not yet taken. **First valid REG wins.** If the name was
|
||||
and the name is not yet taken. **First valid REG wins.** The certificate
|
||||
must be the **only token of its category**: the category is created by
|
||||
this transaction (it spends, at output index 0, the outpoint whose txid is
|
||||
the category), exactly one output carries it, and that output is an NFT
|
||||
with no capability and no fungible amount. Rule 4 accepts an update from
|
||||
any token of a name's category, so a registration that left a second one
|
||||
in existence would let its holder rewrite the name after selling the
|
||||
certificate; such a registration is void. *(Added 2026-10-04; every valid
|
||||
registration on chipnet already satisfied it.)* If the name was
|
||||
released (rule 5), the `REG` must be in a later block than the confirmed
|
||||
release — the one-block gap.
|
||||
4. `UPD`: valid if some output of the tx carries the name's category.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// BNS1 core: payload codec, chain indexer/resolver, and tx builders.
|
||||
import { OpReturnData, TokenSendRequest, NFTCapability } from "mainnet-js";
|
||||
import { BEACON, TLD_BEACON, PREFIX, BEACON_DUST_SAT, NAME_RE } from "../config.js";
|
||||
import { reduceBlocklist } from "./resolver-web.js";
|
||||
import { reduceBlocklist, soleCertificate } from "./resolver-web.js";
|
||||
|
||||
// ---------- payload codec ----------
|
||||
|
||||
|
|
@ -136,6 +136,7 @@ async function buildTldMap(provider) {
|
|||
const commitment = Buffer.from(tld, "utf8").toString("hex");
|
||||
const mint = tx.vout.find((v) => v.tokenData?.nft?.commitment === commitment);
|
||||
if (!mint) continue;
|
||||
if (!soleCertificate(tx, mint)) continue; // one definition, in resolver-web.js
|
||||
tlds.set(tld, {
|
||||
category: mint.tokenData.category, regHeight: height, regTxid: tx_hash,
|
||||
records: payload.r ?? {},
|
||||
|
|
@ -270,6 +271,8 @@ export async function buildIndex(provider, { enforceTld = false, cosignGate = tr
|
|||
const commitment = Buffer.from(name, "utf8").toString("hex");
|
||||
const mint = tx.vout.find((v) => v.tokenData?.nft?.commitment === commitment);
|
||||
if (!mint) continue;
|
||||
// the certificate must be the only token of its category (resolver-web.js)
|
||||
if (!soleCertificate(tx, mint)) continue;
|
||||
const entry = {
|
||||
name, category: mint.tokenData.category, records: payload.r ?? {},
|
||||
txid: tx_hash, height, updatedTxid: null,
|
||||
|
|
|
|||
2
src/lib/resolver-web.d.ts
vendored
2
src/lib/resolver-web.d.ts
vendored
|
|
@ -58,6 +58,8 @@ export interface BnsNetwork {
|
|||
bchnRpcPort: number;
|
||||
blocklists: { name: string; beacon: string; scripthash?: string }[];
|
||||
}
|
||||
/** REG/TREG: is `mint` the only token of its category there is or can ever be? */
|
||||
export function soleCertificate(tx: any, mint: any): boolean;
|
||||
/** 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<string, any>): boolean;
|
||||
/** One-block gap after a release. `releasedAt` undefined = never released. */
|
||||
|
|
|
|||
|
|
@ -378,6 +378,36 @@ function orderByDependency(history, txs) {
|
|||
return ordered;
|
||||
}
|
||||
|
||||
// ---- the certificate must be the only token of its category ----
|
||||
// Ownership of a name is proven by holding a token of its category: an UPD
|
||||
// (or TUPD) is accepted when some output carries that category. So a
|
||||
// registration is only as good as the guarantee that its certificate is the
|
||||
// ONLY such token there is or ever can be. Otherwise whoever registered a name
|
||||
// could keep a second token of the category — a minting one, say — hand the
|
||||
// certificate to a buyer, and still rewrite the name afterwards.
|
||||
//
|
||||
// The rule, checked at registration (REG and TREG alike):
|
||||
// · the category is created by this very transaction (it spends, at output
|
||||
// index 0, the outpoint whose txid IS the category), so no token of it can
|
||||
// predate the registration;
|
||||
// · exactly one output carries it — the certificate;
|
||||
// · that output is an NFT with no capability (so it cannot mint or be
|
||||
// rewritten) and no fungible amount.
|
||||
// Nothing that satisfies this can ever have a sibling. KEEP IN LOCKSTEP with
|
||||
// bns.js and the mobile Bns.java.
|
||||
export function soleCertificate(tx, mint) {
|
||||
const td = mint && mint.tokenData;
|
||||
if (!td || !td.nft || typeof td.category !== "string") return false;
|
||||
const category = td.category;
|
||||
if (!(tx.vin || []).some((i) => i && i.txid === category && i.vout === 0)) return false;
|
||||
if ((tx.vout || []).filter((v) => v.tokenData && v.tokenData.category === category).length !== 1) return false;
|
||||
const cap = td.nft.capability;
|
||||
if (cap !== undefined && cap !== null && cap !== "" && cap !== "none") return false;
|
||||
let amount;
|
||||
try { amount = BigInt(td.amount ?? 0); } catch { return false; }
|
||||
return amount === 0n;
|
||||
}
|
||||
|
||||
// TLD of a canonical index key ("<label>.<tld>") — always the rightmost label,
|
||||
// since canonicalKey always emits "label.tld" form.
|
||||
function tldOfKey(key) {
|
||||
|
|
@ -426,6 +456,7 @@ export async function fetchTldMap(el, network = NETWORK) {
|
|||
const commitHex = strToHex(tld);
|
||||
const mint = vouts.find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex);
|
||||
if (!mint) continue;
|
||||
if (!soleCertificate(tx, mint)) continue; // see soleCertificate above
|
||||
// Record the mint-recipient script + vout so callers can compute the
|
||||
// TLD owner's address (needed for the revenue-split fee output).
|
||||
// MVP limitation: this is the ORIGINAL owner. If the NFT gets moved
|
||||
|
|
@ -605,6 +636,7 @@ export async function buildIndex({ network = NETWORK, electrum = getNetwork(netw
|
|||
const commitHex = strToHex(rawName); // commitment matches the raw on-chain name
|
||||
const mint = vouts.find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex);
|
||||
if (!mint) continue;
|
||||
if (!soleCertificate(tx, mint)) continue; // the certificate must be the only token of its category
|
||||
names.set(key, {
|
||||
name: key, category: mint.tokenData.category, records: payload.r ?? {},
|
||||
txid: tx_hash, height, updatedTxid: null,
|
||||
|
|
@ -689,6 +721,7 @@ export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap =
|
|||
const commitHex = strToHex(rawName);
|
||||
const mint = vouts.find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex);
|
||||
if (!mint) continue;
|
||||
if (!soleCertificate(tx, mint)) continue;
|
||||
names.set(key, {
|
||||
name: key, category: mint.tokenData.category, records: payload.r ?? {},
|
||||
txid: tx_hash, height, updatedTxid: null,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseOpReturn } from "./bns.js";
|
||||
import { soleCertificate } from "./resolver-web.js";
|
||||
|
||||
const TLD_RE = /^[a-z0-9-]{1,16}$/;
|
||||
|
||||
|
|
@ -111,6 +112,7 @@ export function buildTldSnapshot({
|
|||
const commitHex = asHexBuf(tld);
|
||||
const mint = vouts.find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex);
|
||||
if (!mint) continue; // TREG payload without a matching mint output is not authoritative
|
||||
if (!soleCertificate(tx, mint)) continue; // the certificate must be the only token of its category
|
||||
tlds.set(tld, {
|
||||
tld,
|
||||
category: mint.tokenData.category,
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ test("what the builders write, the reducer accepts", () => {
|
|||
// ---------- the contract: no subscription, or an empty list, changes nothing ----------
|
||||
|
||||
const cert = (name, category) => ({ n: 0, value: 0.00001, scriptPubKey: { hex: "76a914" + "11".repeat(20) + "88ac", address: "bchtest:owner" }, tokenData: { category, amount: "0", nft: { capability: "none", commitment: hex(name) } } });
|
||||
const REG = (id, name, category) => ({ txid: txid(id), vin: [], vout: [cert(name, category), { n: 1, value: 0, scriptPubKey: { hex: opReturn({ o: "REG", n: name, r: { u: "https://site.example" } }) } }] });
|
||||
const REG = (id, name, category) => ({ txid: txid(id), vin: [{ txid: category, vout: 0 }], vout: [cert(name, category), { n: 1, value: 0, scriptPubKey: { hex: opReturn({ o: "REG", n: name, r: { u: "https://site.example" } }) } }] });
|
||||
|
||||
// A fake electrum client: the name beacon, the TLD beacon (empty), and the list address.
|
||||
function fakeClient({ names, list = [], failList = false }) {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ const beacon = () => ({ value: 0.000006, scriptPubKey: { hex: "76a914" + "22".re
|
|||
const plain = () => ({ value: 0.001, scriptPubKey: { hex: "76a914" + "33".repeat(20) + "88ac", address: "bchtest:change" } });
|
||||
const number = (vout) => vout.map((o, n) => ({ n, ...o }));
|
||||
|
||||
const REG = (id, name, category, spends = []) => ({ txid: txid(id), vin: spends, vout: number([cert(name, category), data({ o: "REG", n: name, r: { u: "https://one.example" } }), beacon()]) });
|
||||
// A registration creates its category: it spends, at index 0, the outpoint whose txid is that category.
|
||||
const REG = (id, name, category, spends = []) => ({ txid: txid(id), vin: [{ txid: category, vout: 0 }, ...spends], vout: number([cert(name, category), data({ o: "REG", n: name, r: { u: "https://one.example" } }), beacon()]) });
|
||||
const UPD = (id, name, category, spends, r = { u: "https://two.example" }) => ({ txid: txid(id), vin: spends, vout: number([cert(name, category, "bchtest:owner2"), data({ o: "UPD", n: name, r }), beacon()]) });
|
||||
const REL = (id, name, spends, extra = []) => ({ txid: txid(id), vin: spends, vout: number([plain(), data({ o: "REL", n: name }), beacon(), ...extra]) });
|
||||
const spend = (id, vout = 0) => ({ txid: txid(id), vout });
|
||||
|
|
|
|||
113
test/sole-certificate.test.mjs
Normal file
113
test/sole-certificate.test.mjs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// "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"]);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue