argus/test/release.test.mjs
Local Dev 4fb37e0683 bns: a registration is void unless its certificate is the only token of its category
Ownership of a name is proven by holding a token of its category: an update
is accepted when some output carries it. Nothing required that the
certificate be the only such token. Whoever registered a name could mint a
second one in the same transaction, a minting token for instance, sell the
certificate on the market, and later publish an update the resolvers would
accept. The buyer would hold the certificate and lose the name.

A registration now counts only if its category is created by that very
transaction, exactly one output carries it, and that output is an NFT with
no capability and no fungible amount. Nothing meeting this can ever have a
sibling. The same holds for TLD certificates, whose category proves the
right to set a TLD's policy and to co-sign registrations under it.

One definition, in resolver-web.js; bns.js and the TLD snapshot builder call
it. Every valid registration on chipnet already satisfied the rule: the live
registry is the same 71 names and 21 TLDs before and after.

Found while writing the delivery escrow, which had to forbid exactly this.
2026-10-04 16:13:04 +02:00

236 lines
13 KiB
JavaScript

// 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/);
});