covenant names: the actions a dashboard needs, and holdings that list them

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.
This commit is contained in:
Local Dev 2026-10-05 23:51:02 +02:00
parent b1c77db313
commit a9f4bfa95a
5 changed files with 384 additions and 7 deletions

View file

@ -28,7 +28,7 @@ import WebSocket from "ws";
// resolver-web.js lives at ../lib/, not alongside this file — the previous
// `./resolver-web.js` import was broken (file was moved into lib/ but this
// import wasn't updated). Fix while touching the file.
import { resolveName, buildIndex, normalizeName, connectAny, NETWORK, requireBeacons, buildBlocklists, blocklistAddress, withBlockFlag } from "./resolver-web.js";
import { resolveName, buildIndex, normalizeName, connectAny, NETWORK, requireBeacons, buildBlocklists, blocklistAddress, withBlockFlag, addressToScripthash } from "./resolver-web.js";
// Everything chain-specific comes from the network table (BNS_NETWORK env;
// chipnet unless set). Refuse to start on a network whose beacons are unpinned.
requireBeacons(NETWORK);
@ -1159,8 +1159,13 @@ const server = http.createServer(async (req, res) => {
// wallet just holds one or the other.
const idx = await getIndex();
const names = [];
// A covenant-held name's certificate sits at the covenant's address,
// not the owner's, so it is never among the wallet's coins. It
// belongs to the wallet whose key the verified state names.
const asked = new Set(scripthashes.map((s) => s.toLowerCase()));
const holdsCovenantName = (entry) => { try { return !!entry.covenant && asked.has(addressToScripthash(entry.owner)); } catch { return false; } };
for (const [key, entry] of idx) {
if (!categories.has(entry.category)) continue;
if (!categories.has(entry.category) && !holdsCovenantName(entry)) continue;
const dot = key.indexOf(".");
const displayName = dot < 0 ? key + ".bch" : key;
const tld = dot < 0 ? "bch" : key.slice(dot + 1);
@ -1172,6 +1177,14 @@ const server = http.createServer(async (req, res) => {
txid: entry.txid ?? null,
height: entry.height ?? null,
updated_txid: entry.updatedTxid ?? null,
// First height at which this registration can no longer be
// displaced by one with an older commit.
final_at: entry.height > 0 ? entry.height + (NETWORK.commitReveal?.contestBlocks ?? 0) + 1 : null,
...(entry.covenant ? {
covenant: { ownerPkh: entry.covenant.ownerPkh, expiry: entry.covenant.expiry, tier: entry.covenant.tier, version: entry.covenant.version },
status: entry.status, idle_end: entry.idleEnd,
...(entry.lapsedRecords ? { lapsed_records: entry.lapsedRecords } : {}),
} : {}),
});
}
names.sort((a, b) => a.name.localeCompare(b.name));

View file

@ -0,0 +1,164 @@
// What a storefront or dashboard calls to act on a covenant-held name with the
// built-in wallet. One function per thing an owner can do; each finds the
// certificate, builds the covenant transaction with the announcement resolvers
// need (PROTOCOL-ADDENDUM-covenant-names.md), signs and broadcasts.
//
// STATUS: chipnet. The contract these spend is a draft with no human review
// (Decentralized.DNS/REGISTRY-COVENANT-v1.md).
//
// `entry` is the name's index entry, as buildIndex or the gateway's
// /api/holdings returns it. For a covenant-held name it carries
// `covenant { ownerPkh, expiry }` — the state resolvers last verified. If the
// certificate has moved since without an announcement, it is not where that
// state says and these functions say so instead of guessing.
import { hexToBin, binToHex } from "@bitauth/libauth";
import { NETWORK, covenantTierIndex } from "./resolver-web.js";
import { getUtxos, getUtxosForAddresses, broadcast as broadcastTx, addressToLockingBytecode } from "./chain.js";
import { lockToAddress } from "./covenant.js";
import { normalizeName, displayName } from "./register-tx.js";
import {
registryTerms, registryLock,
buildRegistryEnterTx, buildRegistryRenewTx, buildRegistryTransferTx, buildRegistryReleaseTx,
} from "./registry-covenant.js";
const covenantOf = (network = NETWORK) => {
if (!network.covenant) throw new Error(`${network.label} has no registry covenant yet`);
return network.covenant;
};
const beaconLock = () => addressToLockingBytecode(NETWORK.beacon);
const walletLock = (wallet) => addressToLockingBytecode(wallet.address);
const walletPkh = (wallet) => binToHex(walletLock(wallet).slice(3, 23));
const keyring = (wallet) => (lockHex) => wallet.keyFor(lockHex);
const plainCoins = async (client, wallet) => (await getUtxosForAddresses(client, wallet.watchedAddresses)).filter((u) => !u.token);
const commitmentOf = (name) => [...new TextEncoder().encode(normalizeName(name))].map((b) => b.toString(16).padStart(2, "0")).join("");
/** The covenant's fixed terms for a name (its price tier follows from the label's length). */
export function covenantTerms(name, network = NETWORK) {
const cov = covenantOf(network);
const tier = covenantTierIndex(displayName(normalizeName(name)), cov) + 1;
if (tier < 1) throw new Error(`no price tier fits "${name}"`);
return registryTerms({ tier, fundLock: hexToBin(cov.fundLock), stewardPk: hexToBin(cov.steward) });
}
/** What covenant custody costs for a name: { tier, yearSats, blocksPerYear, graceBlocks }. */
export function covenantQuote(name, network = NETWORK) {
const t = covenantTerms(name, network);
return { tier: t.tier, yearSats: BigInt(t.renewalSats), blocksPerYear: t.blocksPerYear, graceBlocks: t.graceBlocks };
}
/**
* Where a covenant-held name is in its life at `height`:
* { status: "active" | "idle" | "open", expiry, idleEnd, blocksLeft }.
* blocksLeft counts down to the next change — expiry while active, the end of
* the grace period while idle, 0 once open.
*/
export function covenantLifecycle(entry, height, network = NETWORK) {
const cov = covenantOf(network);
const expiry = entry.covenant.expiry;
const idleEnd = expiry + cov.graceBlocks;
const status = height < expiry ? "active" : height < idleEnd ? "idle" : "open";
return { status, expiry, idleEnd, blocksLeft: status === "active" ? expiry - height : status === "idle" ? idleEnd - height : 0 };
}
const stateOf = (entry) => {
if (!entry?.covenant) throw new Error(`${entry?.name ?? "this name"} is not held by the covenant`);
return { ownerPkh: entry.covenant.ownerPkh, expiry: entry.covenant.expiry };
};
/** The certificate's UTXO at the covenant address its last verified state names. */
export async function findCovenantUtxo(client, entry) {
const state = stateOf(entry);
const terms = covenantTerms(entry.name);
const lock = registryLock(terms, state);
const utxos = await getUtxos(client, lockToAddress(lock, NETWORK.prefix));
const cov = utxos.find((u) => u.token?.category === entry.category && u.token?.nft?.commitment === commitmentOf(entry.name));
if (!cov) throw new Error(`the certificate of ${entry.name} is not at the covenant address the registry last saw — it was moved, renewed or taken over without an announcement`);
return { cov: { ...cov, lockingBytecode: lock }, state, terms };
}
const requireOwner = (wallet, state) => {
if (walletPkh(wallet) !== state.ownerPkh) throw new Error("this wallet does not hold the owner key of this name");
};
const send = async (client, built, onProgress, done) => {
onProgress("broadcasting");
const txid = await broadcastTx(client, built.hex);
onProgress(done, { txid });
return { txid, hex: built.hex, feeSats: built.feeSats, state: built.state ?? null };
};
/**
* Put an ordinary name this wallet holds into the covenant, paying the fund
* for `years`. From then on the name expires unless renewed, and can only be
* moved by the covenant's own paths.
*/
export async function enterCovenantWithBuiltInWallet(client, { wallet, entry, years = 1, onProgress = () => {} }) {
if (entry.covenant) throw new Error(`${entry.name} is already held by the covenant`);
const terms = covenantTerms(entry.name);
onProgress("loading-coins");
const utxos = await getUtxosForAddresses(client, wallet.watchedAddresses);
const cert = utxos.find((u) => u.token?.category === entry.category && u.token?.nft?.commitment === commitmentOf(entry.name));
if (!cert) throw new Error("certificate not found in this wallet (already moved?)");
const head = await client.call("blockchain.headers.subscribe", []);
if (!(head?.height > 0)) throw new Error("could not read the chain height");
onProgress("building");
const built = buildRegistryEnterTx({
cert, terms, name: entry.name, records: entry.records ?? {}, ownerPkh: walletPkh(wallet), height: head.height, years,
beaconLock: beaconLock(), coins: utxos.filter((u) => !u.token), changeLock: walletLock(wallet), keyFor: keyring(wallet),
});
return send(client, built, onProgress, "entered");
}
/** Renew (or, after expiry, reclaim) for `years`. */
export async function renewCovenantNameWithBuiltInWallet(client, { wallet, entry, years = 1, onProgress = () => {} }) {
onProgress("resolving");
const { cov, state, terms } = await findCovenantUtxo(client, entry);
requireOwner(wallet, state);
onProgress("building");
const built = buildRegistryRenewTx({
cov, terms, state, years, announce: { name: entry.name, records: entry.lapsedRecords ?? entry.records ?? {}, beaconLock: beaconLock() },
coins: await plainCoins(client, wallet), changeLock: walletLock(wallet), keyFor: keyring(wallet),
});
return send(client, built, onProgress, "renewed");
}
/** Publish new records: a transfer to the same key, announced. */
export async function setCovenantRecordsWithBuiltInWallet(client, { wallet, entry, records, onProgress = () => {} }) {
onProgress("resolving");
const { cov, state, terms } = await findCovenantUtxo(client, entry);
requireOwner(wallet, state);
onProgress("building");
const built = buildRegistryTransferTx({
cov, terms, state, newOwnerPkh: state.ownerPkh, announce: { name: entry.name, records: records ?? {}, beaconLock: beaconLock() },
coins: await plainCoins(client, wallet), changeLock: walletLock(wallet), keyFor: keyring(wallet),
});
return send(client, built, onProgress, "updated");
}
/** Hand the name to another key. The expiry does not move. `toAddress` must be an ordinary (P2PKH) address. */
export async function transferCovenantNameWithBuiltInWallet(client, { wallet, entry, toAddress, onProgress = () => {} }) {
const toLock = addressToLockingBytecode(toAddress);
if (toLock.length !== 25 || toLock[0] !== 0x76) throw new Error("a covenant-held name can only be transferred to an ordinary address, not a script address");
onProgress("resolving");
const { cov, state, terms } = await findCovenantUtxo(client, entry);
requireOwner(wallet, state);
onProgress("building");
const built = buildRegistryTransferTx({
cov, terms, state, newOwnerPkh: binToHex(toLock.slice(3, 23)), announce: { name: entry.name, records: entry.records ?? {}, beaconLock: beaconLock() },
coins: await plainCoins(client, wallet), changeLock: walletLock(wallet), keyFor: keyring(wallet),
});
return send(client, built, onProgress, "transferred");
}
/** Give the name up: the certificate is destroyed and the name is free from the next block. */
export async function releaseCovenantNameWithBuiltInWallet(client, { wallet, entry, onProgress = () => {} }) {
onProgress("resolving");
const { cov, state, terms } = await findCovenantUtxo(client, entry);
requireOwner(wallet, state);
onProgress("building");
const built = buildRegistryReleaseTx({
cov, terms, state, announce: { name: entry.name, beaconLock: beaconLock() },
coins: await plainCoins(client, wallet), changeLock: walletLock(wallet), keyFor: keyring(wallet),
});
return send(client, built, onProgress, "released");
}

View file

@ -763,11 +763,19 @@ export async function buildIndex({ network = NETWORK, electrum = getNetwork(netw
// Fetch first, then order. An UPD is only valid once its REG has been seen,
// so a transaction must never be indexed before one it spends from.
// A few at a time: one by one, a registry of a couple of hundred
// transactions took most of half a minute on a public server.
const txs = new Map();
for (const { tx_hash } of history) {
try { txs.set(tx_hash, await el.call("blockchain.transaction.get", [tx_hash, true])); }
catch { /* unreadable tx — skip it entirely */ }
}
const fetched = new Map();
const queue = history.map((h) => h.tx_hash);
const worker = async () => {
for (let id = queue.shift(); id !== undefined; id = queue.shift()) {
try { fetched.set(id, await el.call("blockchain.transaction.get", [id, true])); }
catch { /* unreadable tx — skip it entirely */ }
}
};
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, worker));
for (const { tx_hash } of history) if (fetched.has(tx_hash)) txs.set(tx_hash, fetched.get(tx_hash)); // keep history order
const names = new Map();
const released = new Map(); // name -> height of its release (see mayReRegister)

View file

@ -43,8 +43,13 @@ export {
} from "../lib/register-tx.js";
export { signTransaction, txidOf, txidOfHex } from "../lib/sign-tx.js";
export { buildListing, completeSale, verifyListing, signInputs, sourceOutputToJson, sourceOutputFromJson } from "../lib/market-tx.js";
export { findTldCertificateUtxo, buildCosignRequest, signCosignRequest, commitWithBuiltInWallet, commitState } from "../lib/registrar.js";
export { findTldCertificateUtxo, buildCosignRequest, signCosignRequest, commitWithBuiltInWallet, commitState } from "../lib/registrar.js";
export { commitmentHash, newRegistrationSalt, buildCommitTx, REG_SALT_BYTES } from "../lib/register-tx.js";
export {
covenantTerms, covenantQuote, covenantLifecycle, findCovenantUtxo,
enterCovenantWithBuiltInWallet, renewCovenantNameWithBuiltInWallet, setCovenantRecordsWithBuiltInWallet,
transferCovenantNameWithBuiltInWallet, releaseCovenantNameWithBuiltInWallet,
} from "../lib/covenant-registrar.js";
export { usdListingRedeem, usdListingAddress, payoutSats, parseOracleMessage, verifyOracleMessage, buildUsdListingTx, buildUsdBuyTx, buildUsdCancelTx, verifyUsdListing, DEFAULT_MAX_AGE_SECS } from "../lib/market-usd.js";
export { toTokenAddress, addressToLockingBytecode, addressToScripthash, getUtxos, getUtxosForAddresses, broadcast } from "../lib/chain.js";
export { resolveName, buildIndex, NETWORK, NETWORKS, getNetwork, requireBeacons, DEFAULT_NETWORK_ID } from "../lib/resolver-web.js";

View file

@ -0,0 +1,187 @@
// 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)));
});