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 ("