argus/test/registry-covenant.test.mjs
Local Dev e0eca1190e covenants: close the holes an adversarial review found
migrate let the owner supply the new state, so an owner could put a script
of their own in front of the approved code and take the certificate out of
the covenant, or rewrite owner and expiry on the way. The contract now
carries its own state over and the owner supplies only the code. The
steward's approval covers source and destination code, so it cannot be
replayed in another tier or network.

The delivery escrow could be collected with a second protocol message after
the agreed one; it now allows at most four outputs, the fourth plain change.
A subscription draw could pay its fee allowance to a third output; it now
has one or two outputs and pays the host exactly the price.

Smaller: the commit salt is exactly 32 bytes (one box could serve several
names), expiry stays a block height, and relative-lock terms are range
checked in script. Escrow quotes are refused for co-sign TLDs, where a
delivery would satisfy the contract and register nothing.
2026-10-04 16:53:56 +02:00

330 lines
24 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, commitBoxRedeem, commitHashFor, takeoverQuote, signMigrationApproval, migratedLock, registryPhase, COMMIT_SALT_BYTES,
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";
import { unlockScript, assemble, p2sh32Lock, selectorsOf, PUBKEY, SIG } from "../src/lib/covenant.js";
import { NAME_REGISTRY } from "../src/lib/name-registry-artifact.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".repeat(2)); // 32 bytes: the contract requires exactly that
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(32))) }), "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 ----------
// A stand-in for a newer version's CODE. It runs behind the 26 state bytes
// (two pushes), which it drops; then it is spendable by the owner's key.
const NEW_CODE = flattenBinArray([Uint8Array.of(0x6d), Uint8Array.of(0x21), owner.pub, Uint8Array.of(0xac)]); // OP_2DROP <pub> OP_CHECKSIG
const approval = signMigrationApproval(steward.priv, terms, NEW_CODE);
const migrate = (o = {}) => buildRegistryMigrateTx({ cov: covUtxo(), terms, state: state0, newCode: NEW_CODE, approval, coins: [coin(2, owner)], changeLock: owner.lock, keyFor: keyring(owner), ...o });
test("migrate: owner AND steward — neither can move a name alone, and the state carries over", () => {
const built = migrate();
accepts(built, "migrate");
assert.equal(outLockHex(built, 0), binToHex(migratedLock(state0, NEW_CODE)), "same owner, same expiry, in front of the approved code");
rejects(() => migrate({ approval: signMigrationApproval(stranger.priv, terms, 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, terms, Uint8Array.of(0x51)) }), "an approval for different code");
rejects(() => buildRegistryMigrateTx({ cov: covUtxo(), terms, state: state0, newCode: NEW_CODE, 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");
});
test("migrate: the owner chooses nothing — no script in front of the code, no new expiry, no new owner", () => {
// The first draft let the owner supply the bytes placed in front of the
// approved code. An owner could put their own signature check there and a
// push opcode that swallowed the approved code as data: the certificate left
// the covenant under any approval the steward had ever made. The contract
// now rebuilds the state itself, so every one of these targets is refused.
const push = (b) => (b.length <= 75 ? flattenBinArray([Uint8Array.of(b.length), b]) : flattenBinArray([Uint8Array.of(0x4c, b.length), b]));
const swallowed = flattenBinArray([Uint8Array.of(0x21), owner.pub, Uint8Array.of(0xad), push(NEW_CODE)]); // <pub> OP_CHECKSIGVERIFY PUSH(approved code)
rejects(() => migrate({ tamper: setOut(0, () => ({ lockingBytecode: p2sh32Lock(swallowed) })) }), "the approved code wrapped as data behind the owner's own signature check");
rejects(() => migrate({ tamper: setOut(0, () => ({ lockingBytecode: migratedLock({ ownerPkh: owner.pkh, expiry: 0x7ffffffe }, NEW_CODE) })) }), "a migration that also grants itself forty thousand years");
rejects(() => migrate({ tamper: setOut(0, () => ({ lockingBytecode: migratedLock({ ownerPkh: buyer.pkh, expiry: EXPIRY }, NEW_CODE) })) }), "a migration that also changes the owner");
});
test("migrate: an approval is for one covenant and is worthless in another", () => {
// Same steward, same new code — but the approval was made for tier 4's covenant.
const otherTerms = registryTerms({ tier: 4, fundLock: fund.lock, stewardPk: steward.pub });
rejects(() => migrate({ approval: signMigrationApproval(steward.priv, otherTerms, NEW_CODE) }), "an approval made for another tier's covenant");
const otherFund = registryTerms({ tier: TIER, fundLock: stranger.lock, stewardPk: steward.pub });
rejects(() => migrate({ approval: signMigrationApproval(steward.priv, otherFund, NEW_CODE) }), "an approval made for a covenant with another fund (another network's, say)");
});
// ---------- bounds the review asked for ----------
test("renew cannot push the expiry past the range of block heights", () => {
const nearEnd = { ownerPkh: owner.pkh, expiry: 500_000_000 - BLOCKS_PER_YEAR };
const cov = covUtxo(nearEnd);
// one more year would be exactly 500,000,000: read as a timestamp, never takeable again
rejects(() => buildRegistryRenewTx({ cov, terms, state: nearEnd, years: 1, coins: [coin(2, owner)], changeLock: owner.lock, keyFor: keyring(owner) }), "expiry of exactly 500,000,000");
const justUnder = { ownerPkh: owner.pkh, expiry: 500_000_000 - BLOCKS_PER_YEAR - 1 };
accepts(buildRegistryRenewTx({ cov: covUtxo(justUnder), terms, state: justUnder, years: 1, coins: [coin(2, owner)], changeLock: owner.lock, keyFor: keyring(owner) }), "expiry of 499,999,999");
});
test("a commit is for one name: the salt has a fixed size, so nothing can slide between name and salt", () => {
assert.equal(COMMIT_SALT_BYTES, 32);
assert.throws(() => commitHashFor(NAME, hexToBin("00".repeat(16))), /exactly 32 bytes/);
// The ambiguity, with real bytes: hash256("covenant" ‖ ".x" ‖ S) is one hash,
// whether you call the name "covenant" with salt ".x"‖S or "covenant.x" with salt S.
// Build a takeover of "covenant.x" from a box made as if for a 34-byte salt on a shorter name,
// by hand (the library refuses to), and confirm the CONTRACT refuses the odd-sized salt.
const longSalt = flattenBinArray([new TextEncoder().encode("xx"), SALT]); // 34 bytes
const { hash256 } = L;
const commitHash = hash256(flattenBinArray([new TextEncoder().encode(NAME), longSalt]));
const boxRedeem = commitBoxRedeem({ ownerPkh: taker.pkh, commitHash });
const box = { txid: fakeTxid(7), vout: 0, satoshis: 1000n, lockingBytecode: p2sh32Lock(boxRedeem) };
const height = IDLE_END, quote = takeoverQuote(terms, state0, height);
const redeem = registryRedeem(terms, state0);
const FN = selectorsOf(NAME_REGISTRY);
const takerKey = { privateKey: taker.priv, publicKey: taker.pub };
const cov = covUtxo();
rejects(() => assemble({
special: [
{ utxo: cov, unlock: () => unlockScript({ selector: FN.takeover, redeem, args: [hexToBin(taker.pkh), longSalt] }) },
{ utxo: box, sequence: 1, unlock: (transaction, sourceOutputs, inputIndex, final) => unlockScript({ redeem: boxRedeem, transaction, sourceOutputs, inputIndex, key: final ? takerKey : null, args: [PUBKEY, SIG] }) },
],
outputs: [
{ lockingBytecode: registryLock(terms, { ownerPkh: taker.pkh, expiry: quote.newExpiry }), valueSatoshis: 1000n, token: { amount: 0n, category: hexToBin(CATEGORY), nft: { capability: "none", commitment: new TextEncoder().encode(NAME) } } },
{ lockingBytecode: fund.lock, valueSatoshis: quote.fundSats },
],
coins: [coin(3, taker, 50_000_000_000n)], changeLock: taker.lock, keyFor: keyring(taker), locktime: height,
}), "a takeover with a 34-byte salt, everything else in order");
});
// ---------- 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 >= 54, `expected the VM to refuse at least 54 tampered transactions, it refused ${refusedByVm}`);
});