argus/test/registry-covenant.test.mjs

331 lines
24 KiB
JavaScript
Raw Normal View History

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