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