// 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 })); // A registration creates its category: it spends, at index 0, the outpoint whose txid is that category. const REG = (id, name, category, spends = []) => ({ txid: txid(id), vin: [{ txid: category, vout: 0 }, ...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); }); // ---------- the transaction ---------- import { buildReleaseTx, InsufficientFundsError } from "../src/lib/register-tx.js"; import { addressToLockingBytecode } from "../src/lib/chain.js"; import { NETWORK } from "../src/lib/resolver-web.js"; import { encodeTransaction, binToHex } from "@bitauth/libauth"; const OWNER = NETWORK.operatorAddress; const certUtxo = (name, sats = 1000n) => ({ txid: txid(7), vout: 1, satoshis: sats, address: OWNER, lockingBytecode: addressToLockingBytecode(OWNER), token: { category: A, amount: 0n, nft: { capability: "none", commitment: hex(name) } }, }); const coin = (id, sats) => ({ txid: txid(id), vout: 0, satoshis: sats, address: OWNER, lockingBytecode: addressToLockingBytecode(OWNER) }); test("buildReleaseTx burns the certificate: no output carries a token", () => { const built = buildReleaseTx({ name: "gone.x", ownerAddress: OWNER, certificateUtxo: certUtxo("gone.x"), addressToLockingBytecode }); const t = built.transaction; assert.equal(binToHex(t.inputs[0].outpointTransactionHash), txid(7), "the certificate is input 0"); assert.equal(t.inputs.length, 1, "the certificate's own dust pays for the release"); assert.ok(t.outputs.every((o) => !o.token), "an output carrying the category would cancel the burn"); assert.equal(built.sourceOutputs[0].token.nft.commitment.length, "gone.x".length, "signing still sees the token on the input"); const beaconOut = t.outputs[built.outputMap.beacon]; assert.equal(binToHex(beaconOut.lockingBytecode), binToHex(addressToLockingBytecode(NETWORK.beacon))); assert.equal(beaconOut.valueSatoshis, 600n); const script = Buffer.from(t.outputs[built.outputMap.opReturn].lockingBytecode); const text = script.subarray(script[1] === 0x4c ? 3 : 2).toString("utf8"); assert.ok(text.startsWith("BNS1")); assert.deepEqual(JSON.parse(text.slice(4)), { o: "REL", n: "gone.x", r: {} }); const out = t.outputs.reduce((a, o) => a + o.valueSatoshis, 0n); assert.equal(1000n - out, built.costs.chainFeeSats, "everything not sent is fee"); // an unsigned P2PKH input grows by ~107 bytes when signed; 1 sat/byte must still hold assert.ok(built.costs.chainFeeSats >= BigInt(encodeTransaction(t).length), "fee covers at least 1 sat/byte"); }); test("buildReleaseTx tops up from plain coins only when the certificate cannot pay", () => { assert.throws( () => buildReleaseTx({ name: "poor.x", ownerAddress: OWNER, certificateUtxo: certUtxo("poor.x", 700n), utxos: [], addressToLockingBytecode }), InsufficientFundsError, ); const built = buildReleaseTx({ name: "poor.x", ownerAddress: OWNER, certificateUtxo: certUtxo("poor.x", 700n), utxos: [coin(8, 50_000n)], addressToLockingBytecode }); assert.equal(built.transaction.inputs.length, 2); assert.ok(built.outputMap.change !== undefined, "the top-up's remainder comes back as change"); assert.ok(built.transaction.outputs.every((o) => !o.token)); const out = built.transaction.outputs.reduce((a, o) => a + o.valueSatoshis, 0n); assert.equal(50_700n - out, built.costs.chainFeeSats); }); test("buildReleaseTx refuses a certificate for a different name", () => { assert.throws(() => buildReleaseTx({ name: "other.x", ownerAddress: OWNER, certificateUtxo: certUtxo("gone.x"), addressToLockingBytecode }), /commitment mismatch/); });