267 lines
19 KiB
JavaScript
267 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}`);
|
||
|
|
});
|