From a9f4bfa95ae1271d276709f1e4eb8ebb2d667390 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 5 Oct 2026 23:51:02 +0200 Subject: [PATCH] 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. --- src/gateway/public-gateway.mjs | 17 ++- src/lib/covenant-registrar.js | 164 +++++++++++++++++++++++++++ src/lib/resolver-web.js | 16 ++- src/web/register-entry.js | 7 +- test/covenant-registrar.test.mjs | 187 +++++++++++++++++++++++++++++++ 5 files changed, 384 insertions(+), 7 deletions(-) create mode 100644 src/lib/covenant-registrar.js create mode 100644 test/covenant-registrar.test.mjs diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 2abd4aa..37efa5a 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -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)); diff --git a/src/lib/covenant-registrar.js b/src/lib/covenant-registrar.js new file mode 100644 index 0000000..eb6159b --- /dev/null +++ b/src/lib/covenant-registrar.js @@ -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"); +} diff --git a/src/lib/resolver-web.js b/src/lib/resolver-web.js index ccce46a..eb9bb1d 100644 --- a/src/lib/resolver-web.js +++ b/src/lib/resolver-web.js @@ -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) diff --git a/src/web/register-entry.js b/src/web/register-entry.js index 89e0754..6397abf 100644 --- a/src/web/register-entry.js +++ b/src/web/register-entry.js @@ -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"; diff --git a/test/covenant-registrar.test.mjs b/test/covenant-registrar.test.mjs new file mode 100644 index 0000000..89f25d1 --- /dev/null +++ b/test/covenant-registrar.test.mjs @@ -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))); +});