diff --git a/src/lib/bns.js b/src/lib/bns.js index 0ed6e06..7ac39ba 100644 --- a/src/lib/bns.js +++ b/src/lib/bns.js @@ -59,7 +59,7 @@ export function parseOpReturn(scriptHex) { // The set of payload opcodes this codec recognises. New opcodes go here AND in // resolver-web.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"]); // ---------- indexer / resolver ---------- @@ -191,6 +191,32 @@ function tldAdmits(tld, regHeight, tldMap) { // the design doc. Off by default while the mainnet migration and grandfathering // story lands; a chipnet deployment can flip it on immediately to prove the // path end-to-end. +// ---- REL: voluntary release (Decentralized.DNS/PROTOCOL-ADDENDUM-release.md) ---- +// KEEP IN LOCKSTEP with resolver-web.js burnsCertificate / mayReRegister — +// Argus/test/release.test.mjs runs both over the same fixtures. +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; +} +// Valid release: no output carries the name's category, and one input spends +// an output of an earlier beacon transaction that carried it as an NFT. +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); + }); +} +// One-block gap: a released name is registrable again only by a REG in a later +// block than a CONFIRMED release (an unconfirmed REG qualifies). +export function mayReRegister(releasedAt, height) { + if (releasedAt === undefined) return true; + if (!(releasedAt > 0)) return false; + return !(height > 0) || height > releasedAt; +} + export async function buildIndex(provider, { enforceTld = false, cosignGate = true } = {}) { // Fetch the TLD map first — needed for the existence gate when enforcing // and always for the co-sign rule. Both fetches share `provider`. @@ -211,6 +237,7 @@ export async function buildIndex(provider, { enforceTld = false, cosignGate = tr const names = new Map(); const byCategory = new Map(); + const released = new Map(); // name -> height of its release for (const { tx_hash, height } of orderByDependency(history.filter((h) => txs.has(h.tx_hash)), txs)) { const tx = txs.get(tx_hash); @@ -226,6 +253,7 @@ export async function buildIndex(provider, { enforceTld = false, cosignGate = tr if (payload.o === "REG") { if (names.has(name)) continue; // first valid REG wins + if (!mayReRegister(released.get(name), height)) continue; // one-block gap after a release // TLD gate — see design §4 / tldAdmits above. Only applies to REG; UPDs // are transitions on names already admitted, so no gate needed there. if (enforceTld && !tldAdmits(tldOf(name), height, tldMap)) continue; @@ -243,6 +271,14 @@ export async function buildIndex(provider, { enforceTld = false, cosignGate = tr }; names.set(name, entry); byCategory.set(entry.category, entry); + released.delete(name); + } else if (payload.o === "REL") { + const entry = names.get(name); + if (!entry) continue; + if (!burnsCertificate(tx, entry, txs)) continue; + names.delete(name); + byCategory.delete(entry.category); + released.set(name, height); } else if (payload.o === "UPD") { const entry = names.get(name); if (!entry) continue; @@ -254,6 +290,7 @@ export async function buildIndex(provider, { enforceTld = false, cosignGate = tr entry.updatedTxid = tx_hash; } } + Object.defineProperty(names, "released", { value: released, enumerable: false }); return names; } diff --git a/test/release.test.mjs b/test/release.test.mjs new file mode 100644 index 0000000..e169770 --- /dev/null +++ b/test/release.test.mjs @@ -0,0 +1,181 @@ +// REL — voluntary release (Decentralized.DNS/PROTOCOL-ADDENDUM-release.md). +// +// No network. Synthetic beacon histories are reduced by BOTH JavaScript +// resolvers — resolver-web.js (the snapshot reducer every client ships) and +// bns.js (the CLI resolver) — and the two results must be identical. A rule +// that only one of them implements is a consensus split, so every case below +// is asserted on each and then compared. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildIndexFromSnapshot, burnsCertificate, mayReRegister, KNOWN_OPS } from "../src/lib/resolver-web.js"; +import * as legacy from "../src/lib/bns.js"; + +// ---------- fixture builders ---------- +const hex = (s) => Buffer.from(s, "utf8").toString("hex"); +const txid = (n) => String(n).padStart(4, "0").repeat(16); // sortable, 64 hex chars +const cat = (c) => c.repeat(64); // a token category +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 cert = (name, category, owner = "bchtest:owner") => ({ + value: 0.00001, scriptPubKey: { hex: "76a914" + "11".repeat(20) + "88ac", address: owner }, + tokenData: { category, amount: "0", nft: { capability: "none", commitment: hex(name) } }, +}); +const data = (payload) => ({ value: 0, scriptPubKey: { hex: opReturn(payload) } }); +const beacon = () => ({ value: 0.000006, scriptPubKey: { hex: "76a914" + "22".repeat(20) + "88ac", address: "bchtest:beacon" } }); +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()]) }); +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 }); + +// A history is a list of [tx, height]; height 0 = unconfirmed. +function reduceBoth(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 } }); + return { web, history, txs }; +} +async function reduceLegacy(history, txs) { + const provider = { + getHistory: async () => history.map((h) => ({ ...h })), + getRawTransactionObject: async (id) => txs[id], + }; + return legacy.buildIndex(provider, { cosignGate: false }); +} +const shape = (idx) => ({ + names: [...idx].map(([k, e]) => `${k}=${e.category.slice(0, 2)}:${JSON.stringify(e.records)}`).sort(), + released: [...idx.released].map(([k, h]) => `${k}@${h}`).sort(), +}); +async function both(entries) { + const { web, history, txs } = reduceBoth(entries); + const old = await reduceLegacy(history, txs); + assert.deepEqual(shape(old), shape(web), "bns.js and resolver-web.js disagree"); + return web; +} + +const A = cat("a"), B = cat("b"), C = cat("c"); + +// ---------- the rule ---------- + +test("REL is a recognised opcode in both codecs", () => { + assert.ok(KNOWN_OPS.has("REL")); + assert.ok(legacy.KNOWN_OPS.has("REL")); + assert.deepEqual([...KNOWN_OPS].sort(), [...legacy.KNOWN_OPS].sort()); +}); + +test("a release that burns the certificate frees the name", async () => { + const idx = await both([[REG(1, "gone.x", A), 100], [REL(2, "gone.x", [spend(1)]), 105]]); + assert.equal(idx.get("gone.x"), undefined); + assert.equal(idx.released.get("gone.x"), 105); +}); + +test("a REL that does not spend the certificate is ignored", async () => { + const idx = await both([[REG(1, "kept.x", A), 100], [REL(2, "kept.x", [spend(1, 2)]), 105]]); // spends the beacon dust, not the cert + assert.equal(idx.get("kept.x").category, A); + assert.equal(idx.released.size, 0); +}); + +test("a REL that recreates the certificate is not a release", async () => { + const idx = await both([[REG(1, "kept.x", A), 100], [REL(2, "kept.x", [spend(1)], [cert("kept.x", A)]), 105]]); + assert.equal(idx.get("kept.x").category, A); +}); + +test("burning another name's certificate releases nothing", async () => { + const idx = await both([[REG(1, "mine.x", A), 100], [REG(2, "theirs.x", B), 100], [REL(3, "theirs.x", [spend(1)]), 105]]); + assert.equal(idx.get("theirs.x").category, B); + assert.equal(idx.get("mine.x").category, A); // its certificate is burned on chain, but no valid REL names it +}); + +test("a certificate moved outside the beacon must be anchored by an UPD first", async () => { + // txid(50) is a plain wallet transfer of the certificate: not a beacon transaction, so not in the history. + const unanchored = await both([[REG(1, "moved.x", A), 100], [REL(3, "moved.x", [spend(50)]), 110]]); + assert.equal(unanchored.get("moved.x").category, A, "parent unknown to the resolver: not a valid release"); + + const anchored = await both([[REG(1, "moved.x", A), 100], [UPD(2, "moved.x", A, [spend(50)]), 108], [REL(3, "moved.x", [spend(2)]), 110]]); + assert.equal(anchored.get("moved.x"), undefined); + assert.equal(anchored.released.get("moved.x"), 110); +}); + +test("register and release in the same block work whatever the txid order", async () => { + // The REL's txid sorts BEFORE its REG's; causal ordering must still apply it after. + const idx = await both([[REG(9, "quick.x", A), 100], [REL(2, "quick.x", [spend(9)]), 100]]); + assert.equal(idx.get("quick.x"), undefined); + assert.equal(idx.released.get("quick.x"), 100); +}); + +// ---------- the one-block gap ---------- + +test("mayReRegister: the gap, case by case", () => { + for (const f of [mayReRegister, legacy.mayReRegister]) { + assert.equal(f(undefined, 100), true, "never released"); + assert.equal(f(100, 100), false, "same block as the release"); + assert.equal(f(100, 99), false, "earlier block (cannot happen, must still be false)"); + assert.equal(f(100, 101), true, "next block"); + assert.equal(f(100, 0), true, "unconfirmed REG after a confirmed release"); + assert.equal(f(0, 0), false, "release itself unconfirmed"); + assert.equal(f(-1, 0), false, "release unconfirmed with unconfirmed parents"); + assert.equal(f(0, 200), false, "release unconfirmed (a confirmed REG would sort first anyway)"); + } +}); + +test("a REG in the same block as the release is void; the next block's REG wins", async () => { + const sameBlock = await both([[REG(1, "again.x", A), 100], [REL(2, "again.x", [spend(1)]), 105], [REG(3, "again.x", B), 105]]); + assert.equal(sameBlock.get("again.x"), undefined); + assert.equal(sameBlock.released.get("again.x"), 105); + + const nextBlock = await both([[REG(1, "again.x", A), 100], [REL(2, "again.x", [spend(1)]), 105], [REG(3, "again.x", B), 105], [REG(4, "again.x", C), 106]]); + assert.equal(nextBlock.get("again.x").category, C, "the void same-block REG must not block the valid one"); + assert.equal(nextBlock.released.size, 0, "a re-registered name is no longer 'released'"); +}); + +test("mempool: an unconfirmed release hides the name but does not open it", async () => { + const idx = await both([[REG(1, "soon.x", A), 100], [REL(2, "soon.x", [spend(1)]), 0], [REG(3, "soon.x", B), 0]]); + assert.equal(idx.get("soon.x"), undefined, "released, and the racing REG is void"); + assert.equal(idx.released.get("soon.x"), 0); + + const later = await both([[REG(1, "soon.x", A), 100], [REL(2, "soon.x", [spend(1)]), 120], [REG(3, "soon.x", B), 0]]); + assert.equal(later.get("soon.x").category, B, "release confirmed, REG still unconfirmed: it can only land later"); +}); + +// ---------- nothing carries over ---------- + +test("after a release the old certificate proves nothing", async () => { + // The old owner publishes an UPD carrying a (forged-looking) output of the old category after release. + const idx = await both([[REG(1, "fresh.x", A), 100], [REL(2, "fresh.x", [spend(1)]), 105], [UPD(3, "fresh.x", A, [spend(60)]), 106]]); + assert.equal(idx.get("fresh.x"), undefined, "an UPD cannot resurrect a released name"); + + const re = await both([[REG(1, "fresh.x", A), 100], [REL(2, "fresh.x", [spend(1)]), 105], [REG(4, "fresh.x", B), 107], [UPD(5, "fresh.x", A, [spend(60)], { u: "https://old-owner.example" }), 108]]); + assert.equal(re.get("fresh.x").category, B); + assert.deepEqual(re.get("fresh.x").records, { u: "https://one.example" }, "the old category cannot update the new registration"); +}); + +test("a name can be released twice across two registrations", async () => { + const idx = await both([ + [REG(1, "cycle.x", A), 100], [REL(2, "cycle.x", [spend(1)]), 101], + [REG(3, "cycle.x", B), 102], [REL(4, "cycle.x", [spend(3)]), 103], + ]); + assert.equal(idx.get("cycle.x"), undefined); + assert.equal(idx.released.get("cycle.x"), 103); +}); + +test("burnsCertificate reads the spent output by its n, not only by position", () => { + const reg = REG(1, "n.x", A); + const shuffled = { ...reg, vout: [...reg.vout].reverse() }; // array index no longer equals n + const rel = REL(2, "n.x", [spend(1, 0)]); + const txs = new Map([[reg.txid, shuffled]]); + assert.equal(burnsCertificate(rel, { category: A }, txs), true); + assert.equal(legacy.burnsCertificate(rel, { category: A }, txs), true); +}); + +test("names that were never released are untouched by the new rule", async () => { + const idx = await both([[REG(1, "plain.x", A), 100], [UPD(2, "plain.x", A, [spend(1)]), 101], [REG(3, "plain.x", B), 102]]); + assert.equal(idx.get("plain.x").category, A, "first valid REG still wins"); + assert.deepEqual(idx.get("plain.x").records, { u: "https://two.example" }); + assert.equal(idx.released.size, 0); +});