From 4fb37e06838a74a9af8ae8324c02d37b47d14331 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 16:13:04 +0200 Subject: [PATCH] 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. --- PROTOCOL.md | 10 ++- src/lib/bns.js | 5 +- src/lib/resolver-web.d.ts | 2 + src/lib/resolver-web.js | 33 ++++++++++ src/lib/tld-index.js | 2 + test/blocklist.test.mjs | 2 +- test/release.test.mjs | 3 +- test/sole-certificate.test.mjs | 113 +++++++++++++++++++++++++++++++++ 8 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 test/sole-certificate.test.mjs diff --git a/PROTOCOL.md b/PROTOCOL.md index 52dd21c..7ecaa9c 100644 --- a/PROTOCOL.md +++ b/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. diff --git a/src/lib/bns.js b/src/lib/bns.js index 1c5ba55..21b7a8f 100644 --- a/src/lib/bns.js +++ b/src/lib/bns.js @@ -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, diff --git a/src/lib/resolver-web.d.ts b/src/lib/resolver-web.d.ts index b411ef7..4296a08 100644 --- a/src/lib/resolver-web.d.ts +++ b/src/lib/resolver-web.d.ts @@ -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): boolean; /** One-block gap after a release. `releasedAt` undefined = never released. */ diff --git a/src/lib/resolver-web.js b/src/lib/resolver-web.js index ae385dd..e1e201f 100644 --- a/src/lib/resolver-web.js +++ b/src/lib/resolver-web.js @@ -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 ("