diff --git a/PROTOCOL.md b/PROTOCOL.md index b41e99f..3482adf 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -28,7 +28,16 @@ server is the only infrastructure a resolver needs. No fork, no sidechain. 2. Carries `OP_RETURN "BNS1{...}"` with `{"o":"UPD","n":,"r":}`. 3. Pays 600 sat to the beacon. +**REL** — one tx that: +1. Spends the name's certificate and does **not** recreate it (no output carries + the category): the certificate is burned. +2. Carries `OP_RETURN "BNS1{...}"` with `{"o":"REL","n":}`. +3. Pays 600 sat to the beacon. + +The name returns to the pool. Specified in `PROTOCOL-ADDENDUM-release.md`. + Transferring a name = sending the certificate to a new owner (no beacon tx needed). +A certificate moved that way must be anchored by an `UPD` before it can be released. ## Records (JSON, keep total payload ≤ 200 bytes) @@ -55,9 +64,14 @@ Transferring a name = sending the certificate to a new owner (no beacon tx neede 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.** + and the name is not yet taken. **First valid REG wins.** 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. -5. Names: `[a-z0-9-]{1,32}`, rendered as `.bch`. +5. `REL`: valid if the name is registered, no output of the tx carries the + name's category, and one input spends an output of an earlier beacon + transaction that carries the certificate. The name leaves the index. +6. Names: `[a-z0-9-]{1,32}`, rendered as `.bch`. ### Ordering (why rule 2 exists) @@ -89,7 +103,8 @@ between them is a consensus split between clients. causally — see "Ordering" above. - The beacon is a keyed address (prototype convenience); production would use a keyless constant script so no one can be pressured over it. -- No expiry/renewal: names live forever once minted (Namecoin used ~8-month - renewals to garbage-collect dead names). +- No expiry/renewal: names live until their owner releases them (`REL`); + an abandoned name still lives forever (Namecoin used ~8-month renewals to + garbage-collect dead names — the covenant registry is that step). - Front-running: a miner/mempool observer could see a REG and race it (Namecoin's two-step `name_new` commit-reveal solved this; omitted here). diff --git a/src/lib/resolver-web.d.ts b/src/lib/resolver-web.d.ts index 1e21e8e..cea5fd1 100644 --- a/src/lib/resolver-web.d.ts +++ b/src/lib/resolver-web.d.ts @@ -57,6 +57,10 @@ export interface BnsNetwork { accountPath: string; bchnRpcPort: number; } +/** 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. */ +export function mayReRegister(releasedAt: number | undefined, height: number): boolean; export const NETWORKS: { chipnet: BnsNetwork; mainnet: BnsNetwork }; export const DEFAULT_NETWORK_ID: "chipnet"; export function networkIdFromEnv(): string; diff --git a/src/lib/resolver-web.js b/src/lib/resolver-web.js index 5bca35c..8a1b022 100644 --- a/src/lib/resolver-web.js +++ b/src/lib/resolver-web.js @@ -297,6 +297,8 @@ function parseOpReturnHex(hex) { const p = JSON.parse(data.slice(PREFIX.length)); // Recognised opcodes: // REG / UPD — name registrations & updates on the name beacon + // REL — voluntary release of a name (burns its certificate) + // (Decentralized.DNS/PROTOCOL-ADDENDUM-release.md) // TREG / TUPD — TLD registrations & updates on the TLD beacon // (Decentralized.DNS/DESIGN-tld-registry.md §3) // KEEP IN LOCKSTEP with bns.js parseOpReturn — memory rule. @@ -307,7 +309,7 @@ function parseOpReturnHex(hex) { // The set of payload opcodes this codec recognises. New opcodes go here AND in // bns.js — divergence is a consensus split. -export const KNOWN_OPS = new Set(["REG", "UPD", "TREG", "TUPD"]); +export const KNOWN_OPS = new Set(["REG", "UPD", "REL", "TREG", "TUPD"]); // ---- ordering ---- @@ -480,6 +482,38 @@ function ownerFromVout(vout) { return null; } +// ---- REL: voluntary release (Decentralized.DNS/PROTOCOL-ADDENDUM-release.md) ---- +// The output a vin spends, looked up among the beacon transactions the index is +// built from. null when the parent is not one of them — the resolver holds the +// beacon history and nothing else, so the rule is stated over that set. +function spentOutput(vin, txs) { + const prev = vin && vin.txid ? txs.get(vin.txid) : null; + if (!prev || !Array.isArray(prev.vout)) return null; + return prev.vout.find((o) => o && o.n === vin.vout) ?? prev.vout[vin.vout] ?? null; +} +// A release is valid when the transaction burns the name's certificate: no +// output carries the category, and one input spends an output of an earlier +// beacon transaction that carried it as an NFT. `txs` is the Map of beacon +// transactions (txid -> verbose tx). KEEP IN LOCKSTEP with bns.js. +export function burnsCertificate(tx, entry, txs) { + if (!tx || !entry || !entry.category) return false; + if ((tx.vout || []).some((v) => v.tokenData && v.tokenData.category === entry.category)) return false; + return (tx.vin || []).some((vin) => { + const out = spentOutput(vin, txs); + return !!(out && out.tokenData && out.tokenData.nft && out.tokenData.category === entry.category); + }); +} +// The one-block gap. `releasedAt` is the height of the name's release +// (undefined = never released; <= 0 = release still unconfirmed). A released +// name can be registered again only by a REG in a later block than a confirmed +// release; an unconfirmed REG qualifies because it cannot be mined into a block +// that already exists. KEEP IN LOCKSTEP with bns.js. +export function mayReRegister(releasedAt, height) { + if (releasedAt === undefined) return true; + if (!(releasedAt > 0)) return false; + return !(height > 0) || height > releasedAt; +} + export async function buildIndex({ network = NETWORK, electrum = getNetwork(network).electrum, WebSocket: WS, client, directIP = false, enforceTld = false, cosignGate = true } = {}) { const net = requireBeacons(network); const el = client ?? (await connectAny(electrum, WS, { directIP })); @@ -523,6 +557,7 @@ export async function buildIndex({ network = NETWORK, electrum = getNetwork(netw } const names = new Map(); + const released = new Map(); // name -> height of its release (see mayReRegister) for (const { tx_hash, height } of orderByDependency(history.filter((h) => txs.has(h.tx_hash)), txs)) { const tx = txs.get(tx_hash); const vouts = tx.vout || []; @@ -536,6 +571,7 @@ export async function buildIndex({ network = NETWORK, electrum = getNetwork(netw if (payload.o === "REG") { if (names.has(key)) continue; // first valid REG wins + if (!mayReRegister(released.get(key), height)) continue; // one-block gap after a release // TLD gate — see §4 of the design. Only applies to REG; UPDs modify // names already admitted, so no gate needed there. if (enforceTld && !tldAdmits(tldOfKey(key), height, tldMap)) continue; @@ -558,6 +594,13 @@ export async function buildIndex({ network = NETWORK, electrum = getNetwork(netw // Updated in the UPD branch below on every ownership-touching tx. owner: ownerFromVout(mint), }); + released.delete(key); + } else if (payload.o === "REL") { + const entry = names.get(key); + if (!entry) continue; + if (!burnsCertificate(tx, entry, txs)) continue; + names.delete(key); + released.set(key, height); } else if (payload.o === "UPD") { const entry = names.get(key); if (!entry) continue; @@ -577,6 +620,9 @@ export async function buildIndex({ network = NETWORK, electrum = getNetwork(netw // working. A caller that cares (the registrar, before taking money) can ask // whether the servers agreed on what exists. if (integrity) Object.defineProperty(names, "integrity", { value: integrity, enumerable: false }); + // name -> release height, for names currently free because they were + // released (the registrar reads it to explain a pending release). + Object.defineProperty(names, "released", { value: released, enumerable: false }); return names; } finally { if (!client) el.close(); @@ -605,6 +651,7 @@ export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap = const txMap = snapshot.txs instanceof Map ? snapshot.txs : new Map(Object.entries(snapshot.txs || {})); const history = [...snapshot.history].sort(byHeightThenTxid); const names = new Map(); + const released = new Map(); for (const { tx_hash, height } of orderByDependency(history.filter((h) => txMap.has(h.tx_hash)), txMap)) { const tx = txMap.get(tx_hash); const vouts = tx.vout || []; @@ -616,6 +663,7 @@ export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap = const key = canonicalKey(rawName); if (payload.o === "REG") { if (names.has(key)) continue; + if (!mayReRegister(released.get(key), height)) continue; if (enforceTld && !tldAdmits(tldOfKey(key), height, tldMap)) continue; const commitHex = strToHex(rawName); const mint = vouts.find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex); @@ -625,6 +673,13 @@ export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap = txid: tx_hash, height, updatedTxid: null, owner: ownerFromVout(mint), }); + released.delete(key); + } else if (payload.o === "REL") { + const entry = names.get(key); + if (!entry) continue; + if (!burnsCertificate(tx, entry, txMap)) continue; + names.delete(key); + released.set(key, height); } else if (payload.o === "UPD") { const entry = names.get(key); if (!entry) continue; @@ -635,6 +690,7 @@ export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap = entry.owner = ownerFromVout(proving) ?? entry.owner; } } + Object.defineProperty(names, "released", { value: released, enumerable: false }); return names; }