argus/test/covenant-family.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

298 lines
21 KiB
JavaScript

// The subscription and the delivery escrow, run in libauth's BCH virtual
// machine (contracts/subscription.cash, contracts/delivery-escrow.cash).
//
// Same method as registry-covenant.test.mjs: build each path with the library,
// execute it, then break one rule at a time with every signature still valid,
// and require the VM itself to refuse. The last test pins which negative cases
// were stopped by the library before a transaction existed.
//
// Draft contracts; these tests are not a review.
import test from "node:test";
import assert from "node:assert/strict";
import * as L from "@bitauth/libauth";
import {
subscriptionTerms, subscriptionRedeem, subscriptionLock, subscriptionAddress, periodsCovered,
buildSubscriptionPullTx, buildSubscriptionCancelTx,
} from "../src/lib/subscription-covenant.js";
import { escrowTerms, escrowLock, escrowAddress, buildEscrowDeliverTx, buildEscrowRefundTx, assertTldAllowsEscrow } from "../src/lib/escrow-covenant.js";
import { parseOpReturn } from "../src/lib/bns.js";
import { buildIndexFromSnapshot } from "../src/lib/resolver-web.js";
const { hexToBin, binToHex, hash160, secp256k1, encodeLockingBytecodeP2pkh, generatePrivateKey, encodeTransaction } = L;
const VMS = [["BCH 2025", L.createVirtualMachineBch2025()], ["BCH 2026", L.createVirtualMachineBch2026()]];
const verdict = (b) => VMS.map(([label, vm]) => [label, vm.verify({ transaction: b.transaction, sourceOutputs: b.sourceOutputs })]);
const accepts = (b, what) => { for (const [label, r] of verdict(b)) assert.equal(r, true, `${what} — ${label}: ${r}`); };
const refusedByLibrary = [];
let refusedByVm = 0;
const rejects = (make, what) => {
let b;
try { b = make(); } catch (e) { refusedByLibrary.push(what); return; }
for (const [label, r] of verdict(b)) assert.notEqual(r, true, `${what} — ${label} accepted it`);
refusedByVm++;
};
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 buyer = person(), host = person(), registrar = person(), stranger = person(), beacon = person();
const keyring = (...people) => (lockHex) => { const p = people.find((x) => x.lockHex === lockHex); return p ? { privateKey: p.priv, publicKey: p.pub } : null; };
const asAnyone = (p) => () => ({ privateKey: p.priv, publicKey: p.pub });
const fakeTxid = (n) => n.toString(16).padStart(64, "0");
const coin = (id, who, sats = 1_000_000n, vout = 0) => ({ txid: fakeTxid(id), vout, satoshis: sats, lockingBytecode: who.lock });
const setOut = (i, patch) => (tx) => ({ ...tx, outputs: tx.outputs.map((o, k) => (k === i ? { ...o, ...patch(o) } : o)) });
const setIn = (i, patch) => (tx) => ({ ...tx, inputs: tx.inputs.map((o, k) => (k === i ? { ...o, ...patch(o) } : o)) });
// =====================================================================
// Subscription
// =====================================================================
const SUB = subscriptionTerms({ buyerPkh: buyer.pkh, hostLock: host.lock, priceSats: 50_000n, periodBlocks: 4320, maxFeeSats: 1000n });
const subUtxo = (sats = 200_000n, id = 1) => ({ txid: fakeTxid(id), vout: 0, satoshis: sats, lockingBytecode: subscriptionLock(SUB) });
const pull = (o = {}) => buildSubscriptionPullTx({ utxo: subUtxo(), terms: SUB, ...o });
test("subscription: the terms are the address", () => {
assert.notEqual(binToHex(subscriptionLock(SUB)), binToHex(subscriptionLock({ ...SUB, priceSats: 50_001n })));
assert.notEqual(binToHex(subscriptionLock(SUB)), binToHex(subscriptionLock({ ...SUB, periodBlocks: 4321 })));
assert.match(subscriptionAddress(SUB), /^bchtest:p/);
assert.equal(periodsCovered(SUB, 200_000n), 3n);
assert.throws(() => subscriptionTerms({ buyerPkh: buyer.pkh, hostLock: host.lock, priceSats: 10n, periodBlocks: 10 }), /at least/);
assert.throws(() => subscriptionTerms({ buyerPkh: buyer.pkh, hostLock: host.lock, priceSats: 50_000n, periodBlocks: 0 }), /1\.\.65535/);
});
test("subscription: one period's draw pays the host and returns the rest, with no key at all", () => {
const b = pull();
accepts(b, "pull");
assert.equal(b.transaction.inputs.length, 1);
assert.equal(b.transaction.inputs[0].sequenceNumber, 4320, "the input carries the period as its relative lock");
assert.equal(binToHex(b.transaction.outputs[0].lockingBytecode), host.lockHex);
assert.equal(b.transaction.outputs[0].valueSatoshis, 50_000n);
assert.equal(binToHex(b.transaction.outputs[1].lockingBytecode), binToHex(subscriptionLock(SUB)), "the rest stays in the same subscription");
assert.equal(b.transaction.outputs[1].valueSatoshis, 200_000n - 50_000n - b.feeSats);
assert.ok(b.feeSats <= SUB.maxFeeSats && b.feeSats >= BigInt(encodeTransaction(b.transaction).length));
});
test("subscription: the balance is drawn down period by period, and the dust at the end is the fee", () => {
let utxo = subUtxo(160_000n), drawn = 0n, draws = 0;
for (;;) {
let b;
try { b = buildSubscriptionPullTx({ utxo, terms: SUB }); } catch { break; }
accepts(b, `draw ${draws + 1}`);
drawn += b.hostSats; draws++;
if (!b.next) { assert.equal(b.transaction.outputs.length, 1, "nothing worth returning: one output"); break; }
utxo = b.next;
}
assert.equal(draws, 3);
assert.equal(drawn, 150_000n);
// A balance of one price and a little: nothing is worth returning, so the
// draw has a single output and the remainder is its fee.
const last = buildSubscriptionPullTx({ utxo: subUtxo(50_900n), terms: SUB });
accepts(last, "final draw");
assert.equal(last.transaction.outputs.length, 1);
assert.equal(last.feeSats, 900n);
assert.equal(last.next, null);
assert.throws(() => buildSubscriptionPullTx({ utxo: subUtxo(49_999n), terms: SUB }), /less than one period/);
});
test("subscription: the host cannot draw early, twice, or more than the price", () => {
rejects(() => pull({ tamper: setIn(0, () => ({ sequenceNumber: 4319 })) }), "one block before the period is up");
rejects(() => pull({ tamper: setIn(0, () => ({ sequenceNumber: 0xfffffffe })) }), "no relative lock at all");
rejects(() => pull({ tamper: setOut(1, (o) => ({ valueSatoshis: 200_000n - 50_000n - 1001n })) }), "one satoshi more than the allowed fee kept back");
rejects(() => pull({ tamper: setOut(1, () => ({ lockingBytecode: host.lock })) }), "the rest sent to the host instead of back to the subscription");
rejects(() => pull({ tamper: setOut(1, () => ({ lockingBytecode: subscriptionLock({ ...SUB, priceSats: 60_000n }) })) }), "the rest moved into a subscription with a higher price");
rejects(() => pull({ tamper: (tx) => ({ ...tx, outputs: [tx.outputs[0]] }) }), "the rest not returned at all");
rejects(() => pull({ tamper: setOut(0, () => ({ lockingBytecode: stranger.lock })) }), "the payment sent to someone who is not the host");
rejects(() => pull({ tamper: setOut(0, () => ({ valueSatoshis: 49_999n })) }), "less than the price to the host");
});
test("subscription: the fee allowance is nobody's to take", () => {
// The first draft required "at least" the price and did not limit outputs:
// the unused part of the fee allowance could be added to the host's output
// or sent to whoever built the transaction. Now it can only be the fee.
rejects(() => pull({ tamper: setOut(0, (o) => ({ valueSatoshis: o.valueSatoshis + 700n })) }), "the host's output topped up from the fee allowance");
rejects(() => pull({ tamper: (tx) => ({ ...tx, outputs: [...tx.outputs, { lockingBytecode: stranger.lock, valueSatoshis: 700n }] }) }), "a third output paying whoever built the draw");
// At the end of a balance, where nothing is returned, the leftover is still not theirs.
const last = (o = {}) => buildSubscriptionPullTx({ utxo: subUtxo(51_545n), terms: SUB, ...o });
accepts(last(), "last draw");
rejects(() => last({ tamper: (tx) => ({ ...tx, outputs: [tx.outputs[0], { lockingBytecode: stranger.lock, valueSatoshis: 1245n }] }) }), "the last 1,245 satoshis taken by a stranger");
});
test("subscription: a period outside the range of a relative lock is refused, not misread", () => {
// The library refuses these terms; the contract must too, because an address
// built by someone else's code is only as safe as the contract.
const odd = (periodBlocks) => ({ ...SUB, periodBlocks });
const pullWith = (terms, sequenceNumber) => () => {
const utxo = { txid: fakeTxid(1), vout: 0, satoshis: 200_000n, lockingBytecode: subscriptionLock(terms) };
return buildSubscriptionPullTx({ utxo, terms, tamper: setIn(0, () => ({ sequenceNumber })) });
};
rejects(pullWith(odd(0x10001), 1), "0x10001 read as one block");
rejects(pullWith(odd(0x10000), 0), "0x10000 read as no wait");
rejects(pullWith(odd(0), 0), "a period of zero");
rejects(pullWith(odd(0x400001), 0x400001), "a period read as seconds");
rejects(pullWith(odd(0x80000001), 0xffffffff), "a period with the disable bit: no lock at all");
});
test("subscription: two balances cannot be drawn against one payment", () => {
// The bug this guards against: with two inputs, ONE host output and ONE
// return output would satisfy both scripts, and the host keeps a balance.
const a = pull();
const second = subUtxo(200_000n, 2);
const tx = {
...a.transaction,
inputs: [a.transaction.inputs[0], { ...a.transaction.inputs[0], outpointTransactionHash: hexToBin(second.txid) }],
outputs: [a.transaction.outputs[0], a.transaction.outputs[1], { lockingBytecode: host.lock, valueSatoshis: 150_000n }],
};
const sources = [a.sourceOutputs[0], { ...a.sourceOutputs[0], outpointTransactionHash: hexToBin(second.txid) }];
rejects(() => ({ transaction: tx, sourceOutputs: sources }), "two subscription inputs in one transaction");
});
test("subscription: the buyer cancels whenever they like; nobody else can", () => {
const b = buildSubscriptionCancelTx({ utxo: subUtxo(), terms: SUB, toLock: buyer.lock, keyFor: keyring(buyer) });
accepts(b, "cancel");
assert.equal(b.transaction.outputs.length, 1);
assert.equal(binToHex(b.transaction.outputs[0].lockingBytecode), buyer.lockHex);
assert.equal(b.transaction.outputs[0].valueSatoshis, 200_000n - b.feeSats);
assert.equal(b.transaction.inputs[0].sequenceNumber, 0xfffffffe, "no waiting period for the buyer");
rejects(() => buildSubscriptionCancelTx({ utxo: subUtxo(), terms: SUB, toLock: host.lock, keyFor: asAnyone(host) }), "the host cancels and takes the balance");
rejects(() => buildSubscriptionCancelTx({ utxo: subUtxo(), terms: SUB, toLock: stranger.lock, keyFor: asAnyone(stranger) }), "a stranger cancels");
});
// =====================================================================
// Delivery escrow
// =====================================================================
const NAME = "escrowed.x";
const RECORDS = { u: "https://buyer.example" };
const ESC = escrowTerms({ name: NAME, records: RECORDS, buyerPkh: buyer.pkh, certLock: buyer.lock, beaconLock: beacon.lock, refundAfter: 144 });
const escUtxo = (terms = ESC, sats = 300_000n, id = 5) => ({ txid: fakeTxid(id), vout: 0, satoshis: sats, lockingBytecode: escrowLock(terms) });
const deliver = (o = {}) => buildEscrowDeliverTx({ escrow: escUtxo(), terms: ESC, genesis: coin(9, registrar, 20_000n), changeLock: registrar.lock, keyFor: keyring(registrar), ...o });
const tokenOf = (category, commitment, capability = "none", amount = 0n) => ({ amount, category: hexToBin(category), nft: { capability, commitment: new TextEncoder().encode(commitment) } });
test("escrow: every term is in the address", () => {
const variants = [
{ name: "other.x" }, { records: { u: "https://someone-else.example" } }, { certLock: registrar.lock },
{ beaconLock: stranger.lock }, { refundAfter: 145 }, { buyerPkh: stranger.pkh },
];
for (const v of variants) {
const t = escrowTerms({ name: NAME, records: RECORDS, buyerPkh: buyer.pkh, certLock: buyer.lock, beaconLock: beacon.lock, refundAfter: 144, ...v });
assert.notEqual(binToHex(escrowLock(t)), binToHex(escrowLock(ESC)), `changing ${Object.keys(v)[0]} must change the address`);
}
assert.match(escrowAddress(ESC), /^bchtest:p/);
});
test("escrow: delivery registers the name to the buyer, and only then pays", () => {
const b = deliver();
accepts(b, "deliver");
const [cert, payload, dust, change] = b.transaction.outputs;
assert.equal(binToHex(cert.lockingBytecode), buyer.lockHex, "the certificate goes to the buyer");
assert.equal(binToHex(cert.token.category), fakeTxid(9), "a new category: the genesis coin's txid");
assert.equal(cert.token.nft.capability, "none");
assert.equal(new TextDecoder().decode(cert.token.nft.commitment), NAME);
assert.deepEqual(parseOpReturn(binToHex(payload.lockingBytecode)), { o: "REG", n: NAME, r: RECORDS }, "the agreed registration, exactly");
assert.equal(binToHex(dust.lockingBytecode), beacon.lockHex);
assert.equal(binToHex(change.lockingBytecode), registrar.lockHex);
assert.ok(change.valueSatoshis > 300_000n, "the deliverer collects the escrow");
// and what it builds is a registration a resolver accepts
const verbose = {
txid: b.txid, vin: [{ txid: fakeTxid(9), vout: 0 }],
vout: b.transaction.outputs.map((o, n) => ({ n, scriptPubKey: { hex: binToHex(o.lockingBytecode), address: n === 0 ? "bchtest:buyer" : undefined }, ...(o.token ? { tokenData: { category: binToHex(o.token.category), nft: { capability: o.token.nft.capability, commitment: binToHex(o.token.nft.commitment) } } } : {}) })),
};
const index = buildIndexFromSnapshot({ snapshot: { history: [{ tx_hash: b.txid, height: 100 }], txs: { [b.txid]: verbose } } });
assert.equal(index.get(NAME).category, fakeTxid(9));
assert.deepEqual(index.get(NAME).records, RECORDS);
});
test("escrow: the money cannot be collected without the agreed delivery", () => {
rejects(() => deliver({ tamper: setOut(0, () => ({ lockingBytecode: registrar.lock })) }), "the certificate goes to the registrar");
rejects(() => deliver({ tamper: setOut(0, (o) => ({ token: tokenOf(fakeTxid(9), "other.x") })) }), "a certificate for a different name");
rejects(() => deliver({ tamper: setOut(0, () => ({ token: undefined })) }), "no certificate at all");
rejects(() => deliver({ tamper: setOut(0, () => ({ token: tokenOf(fakeTxid(9), NAME, "minting") })) }), "a certificate that can mint more of itself");
rejects(() => deliver({ tamper: setOut(0, () => ({ token: tokenOf(fakeTxid(9), NAME, "mutable") })) }), "a certificate whose name can be rewritten");
rejects(() => deliver({ tamper: setOut(0, () => ({ token: tokenOf(fakeTxid(9), NAME, "none", 5n) })) }), "fungible tokens minted alongside");
rejects(() => deliver({ tamper: setOut(1, () => ({ lockingBytecode: escrowTerms({ name: NAME, records: { u: "https://registrar.example" }, buyerPkh: buyer.pkh, certLock: buyer.lock, beaconLock: beacon.lock }).payloadLock })) }), "different records published");
rejects(() => deliver({ tamper: setOut(2, () => ({ lockingBytecode: registrar.lock })) }), "the beacon is not paid, so no resolver would see the name");
rejects(() => deliver({ tamper: setOut(2, () => ({ valueSatoshis: 599n })) }), "the beacon paid less than its dust");
});
test("escrow: the deliverer cannot keep a second token of the buyer's category", () => {
// A minting token of the same category would let its holder issue a
// transaction resolvers accept as an update of the buyer's name.
for (const i of [3]) {
rejects(() => deliver({ tamper: setOut(i, () => ({ token: tokenOf(fakeTxid(9), "", "minting") })) }), `a minting token of the category kept in output ${i}`);
}
rejects(() => deliver({ tamper: (tx) => ({ ...tx, outputs: [...tx.outputs.slice(0, 3), { lockingBytecode: registrar.lock, valueSatoshis: 1000n, token: tokenOf(fakeTxid(9), "", "minting") }, ...tx.outputs.slice(3)] }) }), "…slipped in as an extra output");
rejects(() => deliver({ tamper: (tx) => ({ ...tx, outputs: [...tx.outputs, { lockingBytecode: registrar.lock, valueSatoshis: 546n }] }) }), "five outputs: one would go unchecked");
rejects(() => deliver({ tamper: setOut(2, (o) => ({ token: tokenOf(fakeTxid(9), "", "minting") })) }), "a token riding on the beacon output");
});
test("escrow: a second data output cannot replace the agreed registration", () => {
// Found by review. Resolvers read one protocol message per transaction; the
// first draft pinned output 1 and let later outputs be anything token-free,
// so a later OP_RETURN decided what the resolver saw. The deliverer was paid
// and the buyer's name registered to nobody — or with other records.
const opReturn = (payload) => { const d = Buffer.from("BNS1" + JSON.stringify(payload)); return Uint8Array.from([0x6a, ...(d.length <= 75 ? [d.length] : [0x4c, d.length]), ...d]); };
const hijacks = [
["other records for the same name", { o: "REG", n: NAME, r: { u: "https://deliverer.example" } }],
["an UPD, so nothing registers", { o: "UPD", n: NAME, r: {} }],
["a REG for another name", { o: "REG", n: "elsewhere.x", r: {} }],
];
for (const [what, payload] of hijacks) {
rejects(() => deliver({ tamper: setOut(3, () => ({ lockingBytecode: opReturn(payload), valueSatoshis: 0n })) }), `output 3 is a data output: ${what}`);
rejects(() => deliver({ tamper: (tx) => ({ ...tx, outputs: [...tx.outputs, { lockingBytecode: opReturn(payload), valueSatoshis: 0n }] }) }), `a fifth output: ${what}`);
}
rejects(() => deliver({ tamper: setOut(3, () => ({ lockingBytecode: Uint8Array.of(0x6a), valueSatoshis: 0n })) }), "a bare OP_RETURN in the change slot");
});
test("escrow: a co-signed TLD is refused up front, and a bad refund wait is refused by the contract", () => {
const cosignTld = { category: "f".repeat(64), records: { policy: "cosign" }, updates: [{ height: 1, txid: "0".repeat(64), records: { policy: "cosign" } }] };
assert.throws(() => assertTldAllowsEscrow(cosignTld, 100, "private"), /co-signature/);
assertTldAllowsEscrow({ category: "f".repeat(64), records: {}, updates: [{ height: 1, txid: "0".repeat(64), records: {} }] }, 100, "open");
assertTldAllowsEscrow(null, 100, "unknown");
const bad = (refundAfter) => ({ ...ESC, refundAfter });
const refundWith = (terms, sequenceNumber) => () =>
buildEscrowRefundTx({ escrow: escUtxo(terms), terms, toLock: buyer.lock, keyFor: keyring(buyer), tamper: setIn(0, () => ({ sequenceNumber })) });
rejects(refundWith(bad(0x10000), 0), "a wait of 0x10000 read as no wait");
rejects(refundWith(bad(0x400001), 0x400001), "a wait read as seconds");
rejects(refundWith(bad(0x80000001), 0xffffffff), "a wait with the disable bit");
});
test("escrow: the certificate must be new, and one delivery collects one payment", () => {
rejects(() => deliver({ tamper: setIn(0, () => ({ outpointIndex: 1 })) }), "input 0 is not a genesis outpoint");
rejects(() => deliver({ tamper: (tx) => ({ ...tx, inputs: [tx.inputs[1], tx.inputs[0]] }) }), "the escrow is not input 1");
rejects(() => deliver({ genesis: coin(9, registrar, 20_000n, 1) }), "library: genesis coin not at index 0");
// Two payments for the same name in one transaction: the second sits at input 2.
const a = deliver();
const second = escUtxo(ESC, 300_000n, 6);
const dup = {
...a.transaction,
inputs: [...a.transaction.inputs.slice(0, 2), { ...a.transaction.inputs[1], outpointTransactionHash: hexToBin(second.txid) }, ...a.transaction.inputs.slice(2)],
};
const sources = [...a.sourceOutputs.slice(0, 2), { ...a.sourceOutputs[1], outpointTransactionHash: hexToBin(second.txid) }, ...a.sourceOutputs.slice(2)];
rejects(() => ({ transaction: dup, sourceOutputs: sources }), "two escrows collected against one certificate");
});
const refund = (o = {}) => buildEscrowRefundTx({ escrow: escUtxo(), terms: ESC, toLock: buyer.lock, keyFor: keyring(buyer), ...o });
test("escrow: the buyer refunds after the wait, and only the buyer, and only after it", () => {
const b = refund();
accepts(b, "refund");
assert.equal(b.transaction.inputs[0].sequenceNumber, 144);
assert.equal(b.transaction.outputs[0].valueSatoshis, 300_000n - b.feeSats);
rejects(() => refund({ tamper: setIn(0, () => ({ sequenceNumber: 143 })) }), "one block before the wait is over");
rejects(() => refund({ tamper: setIn(0, () => ({ sequenceNumber: 0xfffffffe })) }), "an immediate refund");
rejects(() => buildEscrowRefundTx({ escrow: escUtxo(), terms: ESC, toLock: registrar.lock, keyFor: asAnyone(registrar) }), "the registrar 'refunds' to itself");
});
// ---------- the negatives are real ----------
test("every negative case was refused by the contract itself, not by the library", () => {
assert.deepEqual(refusedByLibrary.sort(), ["library: genesis coin not at index 0"]);
assert.ok(refusedByVm >= 46, `expected the VM to refuse at least 46 tampered transactions, it refused ${refusedByVm}`);
});