buildReleaseTx spends the certificate and writes no token output: the omission is the burn. The certificate's own dust pays the beacon and the fee, so a release needs no other coin. releaseWithBuiltInWallet checks whether the certificate sits in a beacon transaction. One that arrived by a plain transfer cannot be proven burned from the beacon history, so the flow publishes an UPD first and releases its output. Both paths ran on chipnet, and a released name was registered again after its release confirmed. checkAvailability now reports a pending release as not available: a REG broadcast while the release is unconfirmed could be mined beside it and be void, and the buyer would have paid for nothing.
235 lines
13 KiB
JavaScript
235 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 }));
|
|
|
|
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);
|
|
});
|
|
|
|
// ---------- 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/);
|
|
});
|