A covenant-held name's certificate is at the covenant's address, so the gateway's holdings - which looked for certificates among a wallet's coins - never listed it under anyone. It is now listed under the key its verified state names, with its expiry and where it is in its life. covenant-registrar.js is one function per thing an owner does with the built-in wallet: move a name into the covenant, renew, publish records, transfer, release. Each announces the new state, and refuses a stale view of the name rather than guess where the certificate went. Tested against a simulated chain: every transaction is run through the VM, applied, and read back through the resolver. The name index fetched its transactions one at a time, which had grown to most of half a minute before a registration could be quoted; it now fetches eight at a time.
187 lines
11 KiB
JavaScript
187 lines
11 KiB
JavaScript
// 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)));
|
|
});
|