argus/test/covenant-registrar.test.mjs

188 lines
11 KiB
JavaScript
Raw Normal View History

// The dashboard's covenant actions, end to end against a simulated chain.
// (src/lib/covenant-registrar.js)
//
// A tiny electrum stand-in keeps a UTXO set and a beacon history. Every
// transaction the library broadcasts is checked by the VM against the coins
// it spends, applied to the UTXO set, and then read back through the
// resolver — so each step proves both that the chain would accept it and
// that resolvers see what the owner meant.
import test from "node:test";
import assert from "node:assert/strict";
import * as L from "@bitauth/libauth";
import { NETWORKS, buildIndexFromSnapshot, addressToScripthash, addressToLockHex } from "../src/lib/resolver-web.js";
import { BuiltInWallet } from "../src/lib/wallet-web.js";
import { buildRegistrationTx } from "../src/lib/register-tx.js";
import { signTransaction } from "../src/lib/sign-tx.js";
import { addressToLockingBytecode, getUtxosForAddresses } from "../src/lib/chain.js";
import { lockToAddress } from "../src/lib/covenant.js";
import { BLOCKS_PER_YEAR, GRACE_BLOCKS } from "../src/lib/registry-lifecycle.js";
import {
covenantQuote, covenantLifecycle, findCovenantUtxo,
enterCovenantWithBuiltInWallet, renewCovenantNameWithBuiltInWallet, setCovenantRecordsWithBuiltInWallet,
transferCovenantNameWithBuiltInWallet, releaseCovenantNameWithBuiltInWallet,
} from "../src/lib/covenant-registrar.js";
const { hexToBin, binToHex, decodeTransaction, hash256, sha256 } = L;
const NET = NETWORKS.chipnet;
const VM = L.createVirtualMachineBch2025();
// ---------- a chain small enough to read ----------
function chain(startHeight = 5000) {
const utxos = new Map(); // "txid:vout" -> { lockHex, satoshis, token }
const txs = {}; // txid -> verbose
const history = []; // beacon history
const beaconHex = addressToLockHex(NET.beacon);
let height = startHeight;
const scripthashOf = (lockHex) => binToHex(sha256.hash(hexToBin(lockHex)).slice().reverse());
const fund = (address, satoshis, vout = 0) => {
const txid = binToHex(crypto.getRandomValues(new Uint8Array(32)));
utxos.set(`${txid}:${vout}`, { lockHex: binToHex(addressToLockingBytecode(address)), satoshis: BigInt(satoshis), token: null });
return txid;
};
// Check with the VM, spend, create, and record if it pays the beacon.
function accept(hex) {
const tx = decodeTransaction(hexToBin(hex));
assert.notEqual(typeof tx, "string", "the transaction must decode");
const sourceOutputs = tx.inputs.map((i) => {
const u = utxos.get(`${binToHex(i.outpointTransactionHash)}:${i.outpointIndex}`);
assert.ok(u, "spends a coin that exists");
return { lockingBytecode: hexToBin(u.lockHex), valueSatoshis: u.satoshis, ...(u.token ? { token: u.token } : {}) };
});
const verdict = VM.verify({ transaction: tx, sourceOutputs });
assert.equal(verdict, true, `the VM must accept it: ${verdict}`);
const txid = binToHex(hash256(hexToBin(hex)).slice().reverse());
for (const i of tx.inputs) utxos.delete(`${binToHex(i.outpointTransactionHash)}:${i.outpointIndex}`);
tx.outputs.forEach((o, n) => { if (o.lockingBytecode[0] !== 0x6a) utxos.set(`${txid}:${n}`, { lockHex: binToHex(o.lockingBytecode), satoshis: o.valueSatoshis, token: o.token ?? null }); });
const verbose = {
txid,
vin: tx.inputs.map((i) => ({ txid: binToHex(i.outpointTransactionHash), vout: i.outpointIndex })),
vout: tx.outputs.map((o, n) => {
let address; try { address = lockToAddress(o.lockingBytecode, NET.prefix, false); } catch {}
return { n, value: Number(o.valueSatoshis) / 1e8, scriptPubKey: { hex: binToHex(o.lockingBytecode), ...(address ? { address } : {}) },
...(o.token ? { tokenData: { category: binToHex(o.token.category), amount: String(o.token.amount ?? 0n), ...(o.token.nft ? { nft: { capability: o.token.nft.capability, commitment: binToHex(o.token.nft.commitment) } } : {}) } } : {}) };
}),
};
txs[txid] = verbose;
height += 1; // every transaction gets its own block here
if (tx.outputs.some((o) => binToHex(o.lockingBytecode) === beaconHex)) history.push({ tx_hash: txid, height });
return txid;
}
const client = {
call: async (method, params) => {
if (method === "blockchain.headers.subscribe") return { height };
if (method === "blockchain.transaction.broadcast") return accept(params[0]);
if (method === "blockchain.scripthash.listunspent") {
return [...utxos].filter(([, u]) => scripthashOf(u.lockHex) === params[0]).map(([k, u]) => ({
tx_hash: k.split(":")[0], tx_pos: Number(k.split(":")[1]), value: Number(u.satoshis), height: 1,
...(u.token ? { token_data: { category: binToHex(u.token.category), amount: String(u.token.amount ?? 0n), ...(u.token.nft ? { nft: { capability: u.token.nft.capability, commitment: binToHex(u.token.nft.commitment) } } : {}) } } : {}),
}));
}
throw new Error("unexpected call " + method);
},
};
return {
client, fund, accept,
get height() { return height; },
mine: (n) => { height += n; },
index: () => buildIndexFromSnapshot({ snapshot: { history: history.map((h) => ({ ...h })), txs }, height }),
balance: (address) => [...utxos.values()].filter((u) => u.lockHex === binToHex(addressToLockingBytecode(address)) && !u.token).reduce((a, u) => a + u.satoshis, 0n),
};
}
const WORDS = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
const OTHER = "legal winner thank year wave sausage worth useful legal winner thank yellow";
const NAME = "tenant.x";
async function registered() {
const c = chain();
const owner = await BuiltInWallet.fromMnemonic(WORDS, NET.prefix);
const buyer = await BuiltInWallet.fromMnemonic(OTHER, NET.prefix);
c.fund(owner.address, 50_000_000n); c.fund(owner.address, 50_000_000n);
c.fund(buyer.address, 50_000_000n);
const built = buildRegistrationTx({ name: NAME, ownerAddress: owner.address, records: { u: "https://first.example" }, utxos: await getUtxosForAddresses(c.client, owner.watchedAddresses), addressToLockingBytecode });
c.accept(signTransaction(built.transaction, built.sourceOutputs, (h) => owner.keyFor(h)).hex);
return { c, owner, buyer };
}
const entryOf = (c) => c.index().get(NAME);
test("the whole life of a name in covenant custody, through the dashboard's functions", async () => {
const { c, owner, buyer } = await registered();
let e = entryOf(c);
assert.equal(e.owner, owner.address);
assert.equal(e.covenant, undefined);
const quote = covenantQuote(NAME);
assert.deepEqual([quote.tier, quote.yearSats], [3, 200000n]);
// --- enter ---
const steps = [];
const before = c.balance(NET.operatorAddress);
const entered = await enterCovenantWithBuiltInWallet(c.client, { wallet: owner, entry: e, years: 1, onProgress: (s) => steps.push(s) });
assert.deepEqual(steps, ["loading-coins", "building", "broadcasting", "entered"]);
assert.equal(c.balance(NET.operatorAddress) - before, quote.yearSats, "one year paid to the fund");
e = entryOf(c);
assert.equal(e.owner, owner.address, "still listed under the owner's key, though the certificate left the wallet");
assert.equal(e.covenant.expiry, entered.state.expiry);
assert.equal(e.status, "active");
assert.deepEqual(e.records, { u: "https://first.example" }, "the records came along");
assert.deepEqual(covenantLifecycle(e, c.height), { status: "active", expiry: e.covenant.expiry, idleEnd: e.covenant.expiry + GRACE_BLOCKS, blocksLeft: e.covenant.expiry - c.height });
await assert.rejects(() => enterCovenantWithBuiltInWallet(c.client, { wallet: owner, entry: e }), /already held by the covenant/);
// --- records ---
await setCovenantRecordsWithBuiltInWallet(c.client, { wallet: owner, entry: e, records: { s3: "bns/tenant.x/" } });
e = entryOf(c);
assert.deepEqual(e.records, { s3: "bns/tenant.x/" });
assert.equal(e.owner, owner.address);
// --- renew, two years ---
const exp0 = e.covenant.expiry;
await renewCovenantNameWithBuiltInWallet(c.client, { wallet: owner, entry: e, years: 2 });
e = entryOf(c);
assert.equal(e.covenant.expiry, exp0 + 2 * BLOCKS_PER_YEAR);
assert.deepEqual(e.records, { s3: "bns/tenant.x/" }, "a renewal keeps the records");
// --- a stale view of the name is refused, not guessed at ---
const stale = { ...e, covenant: { ...e.covenant, expiry: exp0 } };
await assert.rejects(() => setCovenantRecordsWithBuiltInWallet(c.client, { wallet: owner, entry: stale, records: {} }), /not at the covenant address the registry last saw/);
// --- transfer ---
await assert.rejects(() => transferCovenantNameWithBuiltInWallet(c.client, { wallet: buyer, entry: e, toAddress: buyer.address }), /does not hold the owner key/);
const scriptAddress = lockToAddress((await findCovenantUtxo(c.client, e)).cov.lockingBytecode, NET.prefix);
await assert.rejects(() => transferCovenantNameWithBuiltInWallet(c.client, { wallet: owner, entry: e, toAddress: scriptAddress }), /ordinary address/);
await transferCovenantNameWithBuiltInWallet(c.client, { wallet: owner, entry: e, toAddress: buyer.address });
e = entryOf(c);
assert.equal(e.owner, buyer.address);
assert.equal(e.covenant.expiry, exp0 + 2 * BLOCKS_PER_YEAR, "the expiry travels with the name");
await assert.rejects(() => setCovenantRecordsWithBuiltInWallet(c.client, { wallet: owner, entry: e, records: {} }), /does not hold the owner key/);
// --- the new owner lets it expire, then reclaims it in the grace period ---
c.mine(e.covenant.expiry - c.height + 10);
e = entryOf(c);
assert.equal(e.status, "idle");
await renewCovenantNameWithBuiltInWallet(c.client, { wallet: buyer, entry: e, years: 1 });
e = entryOf(c);
assert.equal(e.status, "active");
// --- past the grace period it resolves to nothing; a renewal still brings it back, with its records ---
c.mine(e.covenant.expiry + GRACE_BLOCKS - c.height + 1);
e = entryOf(c);
assert.equal(e.status, "open");
assert.deepEqual(e.records, {});
await renewCovenantNameWithBuiltInWallet(c.client, { wallet: buyer, entry: e, years: 2 });
e = entryOf(c);
assert.equal(e.status, "active");
assert.deepEqual(e.records, { s3: "bns/tenant.x/" }, "the lapsed records are republished by the renewal");
// --- release ---
await releaseCovenantNameWithBuiltInWallet(c.client, { wallet: buyer, entry: e });
assert.equal(c.index().has(NAME), false);
assert.ok(c.index().released.has(NAME));
});
test("what the gateway's holdings match on: the owner key's scripthash", async () => {
const { c, owner } = await registered();
await enterCovenantWithBuiltInWallet(c.client, { wallet: owner, entry: entryOf(c) });
const e = entryOf(c);
assert.ok(owner.watchedAddresses.map((a) => addressToScripthash(a)).includes(addressToScripthash(e.owner)));
});