argus/test/registry-covenant.test.mjs
Local Dev 9f6dd69bf3 registry covenant v1: the contract that holds a name and enforces its lifecycle (draft)
Expiry was always going to be the chain refusing, not resolvers agreeing. This
is that contract: a certificate sits in a covenant whose address encodes its
owner and expiry, and five paths move it. The owner renews, transfers,
releases or migrates; anyone takes over an expired name at a price that
glides from a premium to a floor, paying the previous owner a share while the
grace period lasts and committing to the name a block ahead.

The two changing values are the last constructor arguments, so they sit first
in the redeem script and the contract re-creates itself by swapping 26 bytes.
A resolver that knows the code can check any claimed owner and expiry against
the address.

Every number comes from registry-lifecycle.js, which was written as the
fixture for this. The tests run each path in libauth's VM under the 2025 and
2026 rules, compare price and split to that model along the glide, and then
break one rule at a time with valid signatures; the suite counts that the VM
itself refused them.

Draft, not reviewed, and not wired into any resolver. REGISTRY-COVENANT-v1.md
says where it departs from the design: script cannot bound a height from
above, so reclaim has no deadline and a commit cannot expire, which leaves
commit hoarding open. That is the first thing a reviewer should read.
2026-10-04 15:42:36 +02:00

266 lines
19 KiB
JavaScript

// The name registry covenant, run in libauth's BCH virtual machine.
// (contracts/name-registry.cash, contracts/commit-box.cash)
//
// No network, no coins. Every path is built with the library and executed by
// the VM, then broken one rule at a time: each negative case re-signs the
// tampered transaction, so it fails because the CONTRACT refuses it and not
// because a signature went stale.
//
// The prices are checked against registry-lifecycle.js to the satoshi. That
// file is the reference; if the two ever disagree the contract is wrong.
//
// This is a draft contract and these tests are not a review. They show the
// paths behave as designed, not that no other path exists.
import test from "node:test";
import assert from "node:assert/strict";
import * as L from "@bitauth/libauth";
import {
registryTerms, registryRedeem, registryLock, registryCode, registryState, parseRegistryState, STATE_BYTES,
commitBoxLock, commitHashFor, takeoverQuote, signMigrationApproval, p2sh32Lock, registryPhase,
buildRegistryRenewTx, buildRegistryTransferTx, buildRegistryTakeoverTx, buildRegistryReleaseTx, buildRegistryMigrateTx,
} from "../src/lib/registry-covenant.js";
import {
BLOCKS_PER_YEAR, GRACE_BLOCKS, PHASE, takeoverPriceSats, splitTakeover, renewalPriceSats, phaseAt,
} from "../src/lib/registry-lifecycle.js";
const { hexToBin, binToHex, hash160, secp256k1, encodeLockingBytecodeP2pkh, generatePrivateKey, flattenBinArray } = L;
// Run under the rules live today and under the next upgrade's.
const VMS = [["BCH 2025", L.createVirtualMachineBch2025()], ["BCH 2026", L.createVirtualMachineBch2026()]];
const verdict = (built) => VMS.map(([label, vm]) => [label, vm.verify({ transaction: built.transaction, sourceOutputs: built.sourceOutputs })]);
const accepts = (built, what) => { for (const [label, r] of verdict(built)) assert.equal(r, true, `${what} — ${label}: ${r}`); };
// A negative case must be refused by the VIRTUAL MACHINE. If the library
// throws before a transaction exists, the contract was never asked, and the
// case proves nothing about it — so those are counted, and the last test
// pins exactly which cases are allowed to end that way.
const refusedByLibrary = [];
let refusedByVm = 0;
const rejects = (make, what) => {
let built;
try { built = make(); } catch (e) { refusedByLibrary.push(what); return; }
for (const [label, r] of verdict(built)) assert.notEqual(r, true, `${what} — ${label} accepted it`);
refusedByVm++;
};
// ---------- cast ----------
const person = () => {
const priv = generatePrivateKey(() => crypto.getRandomValues(new Uint8Array(32)));
const pub = secp256k1.derivePublicKeyCompressed(priv);
const pkh = hash160(pub);
const lock = encodeLockingBytecodeP2pkh(pkh);
return { priv, pub, pkh: binToHex(pkh), lock, lockHex: binToHex(lock) };
};
const owner = person(), taker = person(), buyer = person(), steward = person(), fund = person(), stranger = person();
const keyring = (...people) => (lockHex) => { const p = people.find((x) => x.lockHex === lockHex); return p ? { privateKey: p.priv, publicKey: p.pub } : null; };
const fakeTxid = (n) => n.toString(16).padStart(64, "0");
const coin = (id, who, sats = 500_000_000n) => ({ txid: fakeTxid(id), vout: 0, satoshis: sats, lockingBytecode: who.lock });
const TIER = 3;
const terms = registryTerms({ tier: TIER, fundLock: fund.lock, stewardPk: steward.pub });
const NAME = "covenant.x";
const CATEGORY = "ab".repeat(32);
const EXPIRY = 900_000 + BLOCKS_PER_YEAR;
const IDLE_END = EXPIRY + GRACE_BLOCKS;
const state0 = { ownerPkh: owner.pkh, expiry: EXPIRY };
const certToken = (name = NAME) => ({ category: CATEGORY, amount: 0n, nft: { capability: "none", commitment: binToHex(new TextEncoder().encode(name)) } });
const covUtxo = (state = state0, name = NAME) => ({ txid: fakeTxid(1), vout: 0, satoshis: 1000n, lockingBytecode: registryLock(terms, state), token: certToken(name) });
const SALT = hexToBin("00112233445566778899aabbccddeeff");
const boxUtxo = (who = taker, name = NAME, salt = SALT) => ({ txid: fakeTxid(7), vout: 0, satoshis: 1000n, lockingBytecode: commitBoxLock({ ownerPkh: who.pkh, commitHash: commitHashFor(name, salt) }) });
const outLockHex = (built, i) => binToHex(built.transaction.outputs[i].lockingBytecode);
const setOut = (i, patch) => (tx) => ({ ...tx, outputs: tx.outputs.map((o, k) => (k === i ? { ...o, ...patch(o) } : o)) });
// ---------- the address is the state ----------
test("state prefix: 26 bytes the contract can swap, and nothing else moves", () => {
const redeem = registryRedeem(terms, state0);
assert.equal(binToHex(redeem.slice(0, 1)), "14");
assert.equal(binToHex(redeem.slice(21, 22)), "04");
assert.deepEqual(parseRegistryState(redeem), state0);
assert.equal(binToHex(redeem.slice(STATE_BYTES)), binToHex(registryCode(terms)), "CODE is identical for every name in the tier");
const other = registryRedeem(terms, { ownerPkh: taker.pkh, expiry: EXPIRY + 5 });
assert.equal(binToHex(other.slice(STATE_BYTES)), binToHex(redeem.slice(STATE_BYTES)));
assert.notEqual(binToHex(registryLock(terms, state0)), binToHex(registryLock(terms, { ...state0, expiry: EXPIRY + 1 })), "a different expiry is a different address");
assert.throws(() => registryState({ ownerPkh: "00", expiry: 1 }));
});
// ---------- renew ----------
const renew = (o = {}) => buildRegistryRenewTx({ cov: covUtxo(), terms, state: state0, years: 1, coins: [coin(2, owner)], changeLock: owner.lock, keyFor: keyring(owner), ...o });
test("renew: the owner extends the name and pays the fund the tier's price", () => {
for (const years of [1, 3]) {
const built = renew({ years });
accepts(built, `renew ${years}y`);
assert.equal(outLockHex(built, 0), binToHex(registryLock(terms, { ownerPkh: owner.pkh, expiry: EXPIRY + years * BLOCKS_PER_YEAR })));
assert.equal(built.transaction.outputs[1].valueSatoshis, renewalPriceSats(TIER, years), "the price is the reference model's");
assert.equal(binToHex(built.transaction.outputs[1].lockingBytecode), fund.lockHex);
assert.deepEqual(built.state, { ownerPkh: owner.pkh, expiry: EXPIRY + years * BLOCKS_PER_YEAR });
}
});
test("renew: an expired name renews the same way (reclaim), from its old expiry", () => {
// No height is involved at all: the owner can reclaim for as long as nobody has taken the name.
accepts(renew({ years: 2 }), "reclaim");
});
test("renew is refused when a rule is broken", () => {
rejects(() => buildRegistryRenewTx({ cov: covUtxo(), terms, state: state0, coins: [coin(2, stranger)], changeLock: stranger.lock, keyFor: (h) => keyring(stranger)(h) ?? { privateKey: stranger.priv, publicKey: stranger.pub } }), "a stranger's signature");
rejects(() => renew({ tamper: setOut(1, (o) => ({ valueSatoshis: o.valueSatoshis - 1n })) }), "one satoshi short");
rejects(() => renew({ tamper: setOut(1, () => ({ lockingBytecode: owner.lock })) }), "the payment goes to the owner instead of the fund");
rejects(() => renew({ years: 1, tamper: setOut(0, () => ({ lockingBytecode: registryLock(terms, { ownerPkh: owner.pkh, expiry: EXPIRY + 2 * BLOCKS_PER_YEAR }) })) }), "two years claimed, one paid");
rejects(() => renew({ tamper: setOut(0, () => ({ lockingBytecode: owner.lock })) }), "the certificate leaves the covenant");
rejects(() => renew({ tamper: setOut(0, (o) => ({ token: { ...o.token, nft: { ...o.token.nft, commitment: new TextEncoder().encode("other.x") } } })) }), "the certificate's name is changed");
rejects(() => renew({ tamper: setOut(0, () => ({ token: undefined })) }), "the certificate is dropped");
rejects(() => renew({ tamper: setOut(0, () => ({ valueSatoshis: 999n })) }), "the certificate's dust is skimmed");
rejects(() => renew({ years: 0 }), "zero years");
});
// ---------- transfer ----------
const transfer = (o = {}) => buildRegistryTransferTx({ cov: covUtxo(), terms, state: state0, newOwnerPkh: buyer.pkh, coins: [coin(2, owner)], changeLock: owner.lock, keyFor: keyring(owner), ...o });
test("transfer: the owner hands the name over; expiry is untouched", () => {
const built = transfer();
accepts(built, "transfer");
assert.equal(outLockHex(built, 0), binToHex(registryLock(terms, { ownerPkh: buyer.pkh, expiry: EXPIRY })));
accepts(transfer({ newOwnerPkh: owner.pkh }), "transfer to self — the carrier for a records update");
});
test("transfer cannot be used to walk the certificate out, or by anyone else", () => {
rejects(() => transfer({ tamper: setOut(0, () => ({ lockingBytecode: buyer.lock })) }), "certificate sent to a plain address");
rejects(() => transfer({ tamper: setOut(0, () => ({ lockingBytecode: registryLock(terms, { ownerPkh: buyer.pkh, expiry: EXPIRY + BLOCKS_PER_YEAR }) })) }), "a free year smuggled into a transfer");
rejects(() => buildRegistryTransferTx({ cov: covUtxo(), terms, state: state0, newOwnerPkh: stranger.pkh, coins: [coin(2, stranger)], changeLock: stranger.lock, keyFor: () => ({ privateKey: stranger.priv, publicKey: stranger.pub }) }), "a stranger signs");
});
// ---------- takeover ----------
const takeover = (height, o = {}) => buildRegistryTakeoverTx({ cov: covUtxo(), terms, state: state0, name: NAME, height, newOwnerPkh: taker.pkh, salt: SALT, box: boxUtxo(), coins: [coin(3, taker, 50_000_000_000n)], changeLock: taker.lock, keyFor: keyring(taker), ...o });
test("takeover price and split equal the reference model at every point of the glide", () => {
const heights = [EXPIRY, EXPIRY + 1, EXPIRY + Math.floor(GRACE_BLOCKS / 3), EXPIRY + Math.floor(GRACE_BLOCKS / 2), IDLE_END - 1, IDLE_END, IDLE_END + 1, IDLE_END + 5 * BLOCKS_PER_YEAR];
for (const height of heights) {
const model = takeoverPriceSats({ height, expiryHeight: EXPIRY, idleEndHeight: IDLE_END, tier: TIER });
const split = splitTakeover({ amountSats: model, phase: phaseAt({ height, expiryHeight: EXPIRY, idleEndHeight: IDLE_END }) });
const quote = takeoverQuote(terms, state0, height);
assert.equal(quote.priceSats, model, `price at ${height}`);
assert.equal(quote.ownerSats, split.ownerSats, `owner share at ${height}`);
assert.equal(quote.fundSats, split.fundSats, `fund share at ${height}`);
const built = takeover(height);
accepts(built, `takeover at ${height}`);
assert.equal(built.transaction.outputs[1].valueSatoshis, split.fundSats);
assert.equal(outLockHex(built, 0), binToHex(registryLock(terms, { ownerPkh: taker.pkh, expiry: height + BLOCKS_PER_YEAR })), "one fresh year from the takeover height");
rejects(() => takeover(height, { tamper: setOut(1, (o) => ({ valueSatoshis: o.valueSatoshis - 1n })) }), `fund underpaid by one satoshi at ${height}`);
if (split.ownerSats > 0n) {
assert.equal(built.transaction.outputs[2].valueSatoshis, split.ownerSats);
assert.equal(outLockHex(built, 2), owner.lockHex, "the previous owner is paid at their key");
rejects(() => takeover(height, { tamper: setOut(2, (o) => ({ valueSatoshis: o.valueSatoshis - 1n })) }), `previous owner underpaid at ${height}`);
rejects(() => takeover(height, { tamper: setOut(2, () => ({ lockingBytecode: taker.lock })) }), `previous owner's share kept by the taker at ${height}`);
} else {
assert.equal(registryPhase(terms, state0, height), PHASE.open, "no owner share only once the grace period is over");
}
}
});
test("an ACTIVE name cannot be taken at any price", () => {
assert.equal(takeoverQuote(terms, state0, EXPIRY - 1), null);
assert.throws(() => takeover(EXPIRY - 1), /ACTIVE/);
// Build a valid takeover, then claim an earlier height: the locktime check must refuse it.
rejects(() => takeover(EXPIRY, { tamper: (tx) => ({ ...tx, locktime: EXPIRY - 1 }) }), "locktime before expiry");
rejects(() => takeover(EXPIRY, { tamper: (tx) => ({ ...tx, locktime: 0 }) }), "no locktime");
rejects(() => takeover(EXPIRY, { tamper: (tx) => ({ ...tx, locktime: 1_800_000_000 }) }), "a timestamp in place of a height");
});
test("a taker cannot claim a later height than the locktime to pay less", () => {
// Price for IDLE_END (the floor) with a transaction that says it is only at EXPIRY.
const floorQuote = takeoverQuote(terms, state0, IDLE_END);
rejects(() => takeover(EXPIRY, { tamper: (tx) => ({ ...tx, outputs: tx.outputs.map((o, i) => (i === 1 ? { ...o, valueSatoshis: floorQuote.fundSats } : o)) }) }), "floor price paid during grace");
rejects(() => takeover(EXPIRY, { tamper: setOut(0, () => ({ lockingBytecode: registryLock(terms, { ownerPkh: taker.pkh, expiry: EXPIRY + 3 * BLOCKS_PER_YEAR }) })) }), "three years claimed for the price of one");
});
test("takeover needs the taker's own matured commit for this name", () => {
accepts(takeover(EXPIRY + 10), "with the commit");
rejects(() => takeover(EXPIRY + 10, { box: { ...boxUtxo(), lockingBytecode: taker.lock } }), "input 1 is a plain coin, not a commit box");
rejects(() => takeover(EXPIRY + 10, { box: boxUtxo(taker, "other.x") }), "a commit made for another name");
rejects(() => takeover(EXPIRY + 10, { box: boxUtxo(taker, NAME, hexToBin("ff".repeat(16))) }), "a commit made with another salt");
rejects(() => takeover(EXPIRY + 10, { box: boxUtxo(stranger) }), "someone else's commit");
rejects(() => takeover(EXPIRY + 10, { tamper: (tx) => ({ ...tx, inputs: tx.inputs.map((inp, i) => (i === 1 ? { ...inp, sequenceNumber: 0 } : inp)) }) }), "a commit spent in the block it was made in");
rejects(() => takeover(EXPIRY + 10, { tamper: setOut(0, () => ({ lockingBytecode: registryLock(terms, { ownerPkh: stranger.pkh, expiry: EXPIRY + 10 + BLOCKS_PER_YEAR }) })) }), "the name goes to a key the commit was not made for");
});
test("takeover keeps the certificate in the covenant", () => {
rejects(() => takeover(IDLE_END, { tamper: setOut(0, () => ({ lockingBytecode: taker.lock })) }), "certificate sent to the taker's wallet");
rejects(() => takeover(IDLE_END, { tamper: setOut(0, () => ({ token: undefined })) }), "certificate dropped");
});
// ---------- release ----------
const release = (o = {}) => buildRegistryReleaseTx({ cov: covUtxo(), terms, state: state0, coins: [coin(2, owner)], changeLock: owner.lock, keyFor: keyring(owner), ...o });
test("release destroys the certificate; it cannot be used to take it out", () => {
const built = release();
accepts(built, "release");
assert.ok(built.transaction.outputs.every((o) => !o.token));
accepts(release({ extra: [{ lockingBytecode: Uint8Array.of(0x6a, 0x01, 0x00), valueSatoshis: 0n }, { lockingBytecode: fund.lock, valueSatoshis: 600n }] }), "release with a REL payload and beacon dust");
for (const i of [0, 1, 2]) {
rejects(() => release({ extra: [{ lockingBytecode: Uint8Array.of(0x6a, 0x01, 0x00), valueSatoshis: 0n }, { lockingBytecode: fund.lock, valueSatoshis: 600n }], tamper: setOut(i, () => ({ lockingBytecode: owner.lock, valueSatoshis: 1000n, token: { category: hexToBin(CATEGORY), amount: 0n, nft: { capability: "none", commitment: new TextEncoder().encode(NAME) } } })) }), `certificate recreated in output ${i}`);
}
rejects(() => release({ tamper: (tx) => ({ ...tx, outputs: [...tx.outputs.slice(0, 1), ...Array(4).fill({ lockingBytecode: owner.lock, valueSatoshis: 546n })] }) }), "five outputs: one would be unchecked");
rejects(() => buildRegistryReleaseTx({ cov: covUtxo(), terms, state: state0, coins: [coin(2, stranger)], changeLock: stranger.lock, keyFor: () => ({ privateKey: stranger.priv, publicKey: stranger.pub }) }), "a stranger releases someone's name");
});
// ---------- migrate ----------
const NEW_CODE = flattenBinArray([Uint8Array.of(0x51), new TextEncoder().encode("registry v2 code")]);
const NEW_STATE = flattenBinArray([Uint8Array.of(0x14), hexToBin(owner.pkh)]);
const approval = signMigrationApproval(steward.priv, NEW_CODE);
const migrate = (o = {}) => buildRegistryMigrateTx({ cov: covUtxo(), terms, state: state0, newCode: NEW_CODE, newState: NEW_STATE, approval, coins: [coin(2, owner)], changeLock: owner.lock, keyFor: keyring(owner), ...o });
test("migrate: owner AND steward — neither can move a name alone", () => {
const built = migrate();
accepts(built, "migrate");
assert.equal(outLockHex(built, 0), binToHex(p2sh32Lock(flattenBinArray([NEW_STATE, NEW_CODE]))));
// the same approval serves any state: it covers the code, signed once for everyone
accepts(migrate({ newState: flattenBinArray([Uint8Array.of(0x14), hexToBin(buyer.pkh)]) }), "another state under the same approved code");
rejects(() => migrate({ approval: signMigrationApproval(stranger.priv, NEW_CODE) }), "code approved by someone who is not the steward");
rejects(() => migrate({ newCode: flattenBinArray([NEW_CODE, Uint8Array.of(0x00)]) }), "code the steward did not sign");
rejects(() => migrate({ approval: signMigrationApproval(steward.priv, Uint8Array.of(0x51)) }), "an approval for different code");
rejects(() => buildRegistryMigrateTx({ cov: covUtxo(), terms, state: state0, newCode: NEW_CODE, newState: NEW_STATE, approval, coins: [coin(2, stranger)], changeLock: stranger.lock, keyFor: () => ({ privateKey: stranger.priv, publicKey: stranger.pub }) }), "the steward's approval without the owner");
rejects(() => migrate({ tamper: setOut(0, () => ({ lockingBytecode: owner.lock })) }), "certificate sent to a plain address under cover of a migration");
rejects(() => migrate({ tamper: setOut(0, () => ({ token: undefined })) }), "certificate dropped");
});
// ---------- a life ----------
test("a name's life: renew, sell, lapse, be taken over, be renewed by its new owner", () => {
let state = state0;
let cov = covUtxo(state);
const step = (built, next) => { accepts(built, "life"); cov = { txid: built.txid, vout: 0, satoshis: built.transaction.outputs[0].valueSatoshis, lockingBytecode: built.transaction.outputs[0].lockingBytecode, token: certToken() }; state = next; };
let b = buildRegistryRenewTx({ cov, terms, state, years: 2, coins: [coin(20, owner)], changeLock: owner.lock, keyFor: keyring(owner) });
step(b, b.state);
b = buildRegistryTransferTx({ cov, terms, state, newOwnerPkh: buyer.pkh, coins: [coin(21, owner)], changeLock: owner.lock, keyFor: keyring(owner) });
step(b, b.state);
assert.equal(state.expiry, EXPIRY + 2 * BLOCKS_PER_YEAR);
rejects(() => buildRegistryRenewTx({ cov, terms, state, coins: [coin(22, owner)], changeLock: owner.lock, keyFor: keyring(owner, buyer) === null ? null : (h) => (h === owner.lockHex ? { privateKey: owner.priv, publicKey: owner.pub } : h === buyer.lockHex ? { privateKey: owner.priv, publicKey: owner.pub } : null) }), "the old owner cannot renew a name they sold");
const height = state.expiry + 100; // the buyer lets it lapse
b = buildRegistryTakeoverTx({ cov, terms, state, name: NAME, height, newOwnerPkh: taker.pkh, salt: SALT, box: boxUtxo(), coins: [coin(23, taker, 50_000_000_000n)], changeLock: taker.lock, keyFor: keyring(taker) });
assert.equal(binToHex(b.transaction.outputs[2].lockingBytecode), buyer.lockHex, "the buy-out goes to whoever owned it when it lapsed");
step(b, b.state);
assert.equal(state.ownerPkh, taker.pkh);
b = buildRegistryRenewTx({ cov, terms, state, coins: [coin(24, taker)], changeLock: taker.lock, keyFor: keyring(taker) });
step(b, b.state);
assert.equal(state.expiry, height + 2 * BLOCKS_PER_YEAR);
});
// ---------- the negatives are real ----------
test("every negative case was refused by the contract itself, not by the library", () => {
assert.deepEqual(refusedByLibrary.sort(), ["zero years"], "only these may be stopped before the VM runs");
assert.ok(refusedByVm >= 45, `expected the VM to refuse at least 45 tampered transactions, it refused ${refusedByVm}`);
});