bns: REL — an owner can release a name, and resolvers notice a burned certificate

A burned certificate used to leave a name in the index forever: listed,
resolvable with its last records, and impossible to update or re-register.
REL is the declared burn. A transaction that spends the certificate from a
beacon transaction, recreates no output of its category and carries the REL
payload removes the name from the index.

The proof is read from an input because a burn leaves no output to read it
from. Resolvers hold only the beacon history, so the spent output must belong
to a beacon transaction; a certificate moved by a plain transfer is anchored
with an UPD first. That keeps the rule a pure function of the snapshot every
client already has.

A released name can be registered again only in a later block than the
confirmed release. Same-block re-registration would be decided by txid order
between a release and a snipe sitting in the mempool together.

This is the first step of ROADMAP-MAINNET.md §9: it puts burn detection in
the resolvers on its own, before any covenant depends on it. Specified in
PROTOCOL-ADDENDUM-release.md; shared resolver and its Ariadne copy here.
This commit is contained in:
Local Dev 2026-10-04 01:39:14 +02:00
parent c7c66e1557
commit e185ca43a0
3 changed files with 80 additions and 5 deletions

View file

@ -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":<name>,"r":<newRecords>}`.
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":<name>}`.
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 `<name>.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 `<name>.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).

View file

@ -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<string, any>): 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;

View file

@ -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;
}