diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs
index 6443acd..5d04dc3 100644
--- a/src/gateway/public-gateway.mjs
+++ b/src/gateway/public-gateway.mjs
@@ -471,6 +471,7 @@ function stripHtmlExt(html) {
function transformHtml(html, baseHref) {
return stripHtmlExt(rewriteBnsLinks(injectBase(html, baseHref)));
}
+const lapsed = (h, e) => `
Expired
${h} expired at block ${e.expiry} and was not renewed. It can be taken over.
`;
const nx = (h) => `NXDOMAIN
${h} is not registered on the Bitcoin Cash chain.
`;
// BNS `ip`-record fetch. The default `fetch` fails here for two reasons:
@@ -603,6 +604,9 @@ async function serve(res, name, reqPath, baseHref, req = null) {
let entry;
try { entry = await resolve(name); } catch (e) { res.writeHead(502); return res.end("resolve error: " + e.message); }
if (!entry) { res.writeHead(404, { "content-type": "text/html" }); return res.end(nx(name)); }
+ // A covenant-held name past its grace period is still registered — its
+ // certificate waits for a taker — but it no longer resolves.
+ if (entry.status === "open") { res.writeHead(410, { "content-type": "text/html", "cache-control": "no-store" }); return res.end(lapsed(name, entry)); }
// A flagged name (and every subdomain of it) is served behind a warning.
// Policy "warn": the visitor may continue, per name, for a day — except
// for "csam", where the gateway refuses outright.
@@ -1263,6 +1267,13 @@ const server = http.createServer(async (req, res) => {
name: key, tld, registered: !!entry,
records: entry?.records ?? null, category: entry?.category ?? null,
owner: entry?.owner ?? null,
+ // A covenant-held name: who holds it and until when, checked against
+ // the certificate's address. status "open" = lapsed, resolves to nothing.
+ ...(entry?.covenant ? {
+ covenant: { owner_pkh: entry.covenant.ownerPkh, expiry: entry.covenant.expiry, idle_end: entry.idleEnd, tier: entry.covenant.tier, version: entry.covenant.version },
+ status: entry.status, height: index.height ?? null,
+ ...(entry.lapsedRecords ? { lapsed_records: entry.lapsedRecords } : {}),
+ } : {}),
...(releasedAt !== undefined ? { released_at: releasedAt, release_pending: !(releasedAt > 0) } : {}),
...(flag ? { blocked: true, block_reason: flag.reason, block_report_txid: flag.reportTxid, blocked_by: flag.source } : {}),
}));
diff --git a/src/generate-covenant-table.mjs b/src/generate-covenant-table.mjs
new file mode 100644
index 0000000..3407c8e
--- /dev/null
+++ b/src/generate-covenant-table.mjs
@@ -0,0 +1,75 @@
+// Prints the `covenant` block of a network record in src/lib/resolver-web.js.
+//
+// The resolver is a dependency-free single file: it cannot compile a contract
+// or import the artifact. What it needs to recognise a covenant-held name is
+// the covenant's CODE — every byte of the redeem script after the 26 state
+// bytes — so that code is written into the network table, and this script is
+// where it comes from. test/covenant-names.test.mjs fails if the table and the
+// contract ever disagree.
+//
+// node src/generate-covenant-table.mjs --fund --steward <33-byte pubkey hex>
+//
+// CODE = HEAD(tier) ‖ TAIL. Only the three prices differ between tiers, and
+// they are pushed first, so the table stores one short head per tier and the
+// long tail once.
+
+import { binToHex, cashAddressToLockingBytecode, flattenBinArray } from "@bitauth/libauth";
+import { registryTerms, registryCode, REGISTRY_VERSION } from "./lib/registry-covenant.js";
+import { pushInt } from "./lib/covenant.js";
+import { TIERS } from "./lib/registry-lifecycle.js";
+
+/** { version, fundLock, steward, blocksPerYear, graceBlocks, tiers: [{ maxLabel, renewalSats, head }], tail } */
+export function covenantTable({ fundLock, stewardPk }) {
+ let tail = null;
+ let shared = null;
+ const tiers = TIERS.map((t) => {
+ const terms = registryTerms({ tier: t.tier, fundLock, stewardPk });
+ const code = binToHex(registryCode(terms));
+ const head = binToHex(flattenBinArray([
+ pushInt(terms.ownerPercentIdle), pushInt(terms.blocksPerYear), pushInt(terms.graceBlocks),
+ pushInt(terms.floorSats), pushInt(terms.premiumSats), pushInt(terms.renewalSats),
+ ]));
+ if (!code.startsWith(head)) throw new Error("the code does not start with the tier's price pushes");
+ const rest = code.slice(head.length);
+ if (tail !== null && rest !== tail) throw new Error("tiers differ in more than their prices");
+ tail = rest;
+ shared = terms;
+ return { maxLabel: Number.isFinite(t.maxLength) ? t.maxLength : 255, renewalSats: Number(terms.renewalSats), head };
+ });
+ return {
+ version: REGISTRY_VERSION,
+ fundLock: binToHex(shared.fundLock),
+ steward: binToHex(shared.stewardPk),
+ blocksPerYear: shared.blocksPerYear,
+ graceBlocks: shared.graceBlocks,
+ tiers,
+ tail,
+ };
+}
+
+const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/").split("/").pop());
+if (isMain) {
+ const arg = (name) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : null; };
+ const fund = arg("fund"), steward = arg("steward");
+ if (!fund || !steward) {
+ console.error("usage: node src/generate-covenant-table.mjs --fund --steward <33-byte pubkey hex>");
+ process.exit(1);
+ }
+ const decoded = cashAddressToLockingBytecode(fund);
+ if (typeof decoded === "string") { console.error(decoded); process.exit(1); }
+ const t = covenantTable({ fundLock: decoded.bytecode, stewardPk: steward });
+ const lines = [
+ " covenant: {",
+ ` version: ${t.version},`,
+ ` fundLock: "${t.fundLock}",`,
+ ` steward: "${t.steward}",`,
+ ` blocksPerYear: ${t.blocksPerYear},`,
+ ` graceBlocks: ${t.graceBlocks},`,
+ " tiers: [",
+ ...t.tiers.map((x) => ` { maxLabel: ${x.maxLabel}, renewalSats: ${x.renewalSats}, head: "${x.head}" },`),
+ " ],",
+ ` tail: "${t.tail}",`,
+ " },",
+ ];
+ console.log(lines.join("\n"));
+}
diff --git a/src/generate-network-constants.mjs b/src/generate-network-constants.mjs
index 7bae4c2..0b7e0e1 100644
--- a/src/generate-network-constants.mjs
+++ b/src/generate-network-constants.mjs
@@ -60,6 +60,17 @@ ${net.electrum.map(javaServer).join("\n")}
public static final String[][] BLOCKLISTS = {
${(net.blocklists || []).map((l) => ` { ${q(l.name)}, ${q(l.beacon)}, ${q(l.scripthash)} },`).join("\n")}
};
+ // The registry covenant (PROTOCOL-ADDENDUM-covenant-names.md): a
+ // covenant-held name sits at P2SH32(hash256(STATE + head + tail)).
+ // COVENANT_TAIL is null while the network has none pinned.
+ public static final String COVENANT_TAIL = ${net.covenant ? q(net.covenant.tail) : "null"};
+ public static final String COVENANT_FUND_LOCK = ${net.covenant ? q(net.covenant.fundLock) : "null"};
+ public static final int COVENANT_VERSION = ${net.covenant ? net.covenant.version : 0};
+ public static final long COVENANT_BLOCKS_PER_YEAR = ${net.covenant ? net.covenant.blocksPerYear : 0}L;
+ public static final long COVENANT_GRACE_BLOCKS = ${net.covenant ? net.covenant.graceBlocks : 0}L;
+ public static final int[] COVENANT_TIER_MAX_LABEL = { ${(net.covenant ? net.covenant.tiers : []).map((t) => t.maxLabel).join(", ")} };
+ public static final long[] COVENANT_TIER_RENEWAL_SATS = { ${(net.covenant ? net.covenant.tiers : []).map((t) => t.renewalSats + "L").join(", ")} };
+ public static final String[] COVENANT_TIER_HEAD = { ${(net.covenant ? net.covenant.tiers : []).map((t) => q(t.head)).join(", ")} };
}
`;
mkdirSync(path.dirname(OUT), { recursive: true });
diff --git a/src/indexer/bns-indexer.js b/src/indexer/bns-indexer.js
index 93b529c..987f704 100644
--- a/src/indexer/bns-indexer.js
+++ b/src/indexer/bns-indexer.js
@@ -337,6 +337,10 @@ export function startElectrumWS(index, { bind, port }, rpc) {
return { id, result: index.getHistory(params[0]) };
if (method === "blockchain.transaction.get")
return { id, result: await getTx(params[0], params[1]) };
+ // Resolvers judge a covenant-held name's expiry at the tip. Answered once;
+ // this server sends no notifications.
+ if (method === "blockchain.headers.subscribe" && rpc)
+ return { id, result: { height: await rpc("getblockcount", []) } };
return { id, error: { code: -32601, message: "unsupported method: " + method } };
} catch (e) { return { id, error: { code: -1, message: String(e && e.message || e) } }; }
}
diff --git a/src/lib/bns.js b/src/lib/bns.js
index 135e1a4..ce9c86c 100644
--- a/src/lib/bns.js
+++ b/src/lib/bns.js
@@ -1,7 +1,7 @@
// BNS1 core: payload codec, chain indexer/resolver, and tx builders.
import { OpReturnData, TokenSendRequest, NFTCapability } from "mainnet-js";
import { BEACON, TLD_BEACON, PREFIX, BEACON_DUST_SAT, NAME_RE } from "../config.js";
-import { reduceBlocklist, soleCertificate } from "./resolver-web.js";
+import { reduceBlocklist, soleCertificate, admitCovenantState, applyLifecycle, historyHeight } from "./resolver-web.js";
// ---------- payload codec ----------
@@ -235,7 +235,7 @@ export function mayReRegister(releasedAt, height) {
return !(height > 0) || height > releasedAt;
}
-export async function buildIndex(provider, { enforceTld = false, cosignGate = true } = {}) {
+export async function buildIndex(provider, { enforceTld = false, cosignGate = true, height: atHeight = null } = {}) {
// Fetch the TLD map first — needed for the existence gate when enforcing
// and always for the co-sign rule. Both fetches share `provider`.
let tldMap = null;
@@ -288,6 +288,8 @@ export async function buildIndex(provider, { enforceTld = false, cosignGate = tr
};
names.set(name, entry);
byCategory.set(entry.category, entry);
+ // registered straight into the registry covenant? (rules: resolver-web.js)
+ admitCovenantState(entry, payload, mint, tx, height);
released.delete(name);
} else if (payload.o === "REL") {
const entry = names.get(name);
@@ -301,12 +303,15 @@ export async function buildIndex(provider, { enforceTld = false, cosignGate = tr
if (!entry) continue;
// ownership proof: some output carries the name's category
// (consensus: impossible unless an input held that category)
- const proves = tx.vout.some((v) => v.tokenData?.category === entry.category);
- if (!proves) continue;
+ const proving = tx.vout.find((v) => v.tokenData?.category === entry.category);
+ if (!proving) continue;
+ // a covenant-held name answers only to a verified state (resolver-web.js)
+ if (!admitCovenantState(entry, payload, proving, tx, height)) continue;
entry.records = payload.r ?? {};
entry.updatedTxid = tx_hash;
}
}
+ applyLifecycle(names, Math.max(atHeight > 0 ? atHeight : 0, historyHeight(history)));
Object.defineProperty(names, "released", { value: released, enumerable: false });
return names;
}
diff --git a/src/lib/register-tx.js b/src/lib/register-tx.js
index 7261130..e7c802a 100644
--- a/src/lib/register-tx.js
+++ b/src/lib/register-tx.js
@@ -108,8 +108,10 @@ export function displayName(rawName) {
return rawName.includes(".") ? rawName : `${rawName}.${DEFAULT_TLD}`;
}
-export function encodePayload(op, rawName, records) {
- const json = JSON.stringify({ o: op, n: rawName, r: records ?? {} });
+// `claim` is a covenant-held name's state, { o: ownerPkh hex, e: expiry }
+// (PROTOCOL-ADDENDUM-covenant-names.md); absent for an ordinary name.
+export function encodePayload(op, rawName, records, claim = null) {
+ const json = JSON.stringify(claim ? { o: op, n: rawName, r: records ?? {}, c: claim } : { o: op, n: rawName, r: records ?? {} });
const bytes = utf8ToBin(PREFIX + json);
if (bytes.length > MAX_PAYLOAD_BYTES) {
throw new Error(`records too large (${bytes.length} > ${MAX_PAYLOAD_BYTES} bytes)`);
diff --git a/src/lib/registry-covenant.js b/src/lib/registry-covenant.js
index c0c36c0..18208a3 100644
--- a/src/lib/registry-covenant.js
+++ b/src/lib/registry-covenant.js
@@ -30,10 +30,10 @@ import { NAME_REGISTRY } from "./name-registry-artifact.js";
import { COMMIT_BOX } from "./commit-box-artifact.js";
import {
asmToBytecode, selectorsOf, pushInt, push, bin, pkhLock, p2sh32Lock, lockToAddress,
- unlockScript, SIG, PUBKEY, assemble,
+ unlockScript, SIG, PUBKEY, assemble, p2pkhSpecial,
} from "./covenant.js";
import { tokenOf } from "./market-tx.js";
-import { normalizeName, P2PKH_DUST_SAT, CERTIFICATE_DUST_SAT } from "./register-tx.js";
+import { normalizeName, encodePayload, opReturnScript, P2PKH_DUST_SAT, CERTIFICATE_DUST_SAT, BEACON_DUST_SAT } from "./register-tx.js";
import {
BLOCKS_PER_YEAR, GRACE_BLOCKS, SPLIT,
renewalPriceSats, idlePremiumSats, openFloorSats, takeoverPriceSats, splitTakeover, phaseAt, PHASE,
@@ -154,8 +154,56 @@ export function takeoverQuote(terms, state, height) {
return { priceSats: price, ownerSats, fundSats: price - ownerSats, newExpiry: height + terms.blocksPerYear };
}
+// ---------- telling resolvers ----------
+//
+// The owner and expiry of a covenant-held name are hashed into its address,
+// so a resolver cannot read them off the chain: the transaction has to say
+// them, and the resolver checks what it says against the address
+// (Decentralized.DNS/PROTOCOL-ADDENDUM-covenant-names.md). Every builder below
+// takes `announce: { name, records, beaconLock }` and adds the two outputs
+// that do it. A move that is not announced is still valid on chain; resolvers
+// simply go on believing the last state they were told, until it expires.
+
+/** The claim a payload carries: the state its certificate output is locked to. */
+export const covenantClaimOf = (state) => ({ o: binToHex(bin(state.ownerPkh)), e: state.expiry });
+
+/** [payload, beacon dust] — `state` null for a release, which has nothing to claim. */
+export function registryAnnouncement({ name, records = {}, beaconLock }, state, op = "UPD") {
+ if (!beaconLock) throw new Error("announce.beaconLock is required");
+ const payload = encodePayload(op, normalizeName(name), records, state ? covenantClaimOf(state) : null);
+ return [
+ { lockingBytecode: opReturnScript(payload), valueSatoshis: 0n },
+ { lockingBytecode: bin(beaconLock), valueSatoshis: BEACON_DUST_SAT },
+ ];
+}
+const announced = (announce, state, extra, op) => (announce ? [...registryAnnouncement(announce, state, op), ...extra] : extra);
+
// ---------- transactions ----------
+/**
+ * Put an ordinary name into the covenant. `cert` is the certificate's UTXO,
+ * held by a plain key. No contract runs here — the certificate is simply sent
+ * to the covenant's address — so it is resolvers that decide whether the name
+ * counts as covenant-held: the transaction must pay the fund for `years`, and
+ * the expiry may be at most that many years past the block it is mined in.
+ * `height` is the current tip; the expiry is counted from it.
+ */
+export function buildRegistryEnterTx({ cert, terms, name, records = {}, ownerPkh, height, years = 1, beaconLock, coins, changeLock, keyFor, feeRate = 1 }) {
+ if (!Number.isInteger(years) || years < 1) throw new Error("years must be a whole number, at least 1");
+ if (!cert.token) throw new Error("cert must be the certificate's UTXO");
+ const state = { ownerPkh: binToHex(bin(ownerPkh)), expiry: height + years * terms.blocksPerYear };
+ const built = assemble({
+ special: [p2pkhSpecial(cert, keyFor)],
+ outputs: [
+ certOut(registryLock(terms, state), cert),
+ { lockingBytecode: terms.fundLock, valueSatoshis: BigInt(terms.renewalSats) * BigInt(years) },
+ ],
+ extra: registryAnnouncement({ name, records, beaconLock }, state),
+ coins, changeLock, keyFor, feeRate,
+ });
+ return { ...built, state };
+}
+
const certOut = (lock, cov) => ({ lockingBytecode: lock, valueSatoshis: BigInt(cov.satoshis) < CERTIFICATE_DUST_SAT ? CERTIFICATE_DUST_SAT : BigInt(cov.satoshis), token: tokenOf(cov.token) });
const ownerKey = (keyFor, state) => {
@@ -174,26 +222,26 @@ const registrySpecial = (cov, redeem, fn, key, args) => ({
});
/** Renew (or reclaim) for `years`. Pays renewalSats × years to the fund. */
-export function buildRegistryRenewTx({ cov, terms, state, years = 1, coins, changeLock, keyFor, extra = [], feeRate = 1, tamper = null }) {
+export function buildRegistryRenewTx({ cov, terms, state, years = 1, coins, changeLock, keyFor, announce = null, extra = [], feeRate = 1, tamper = null }) {
if (!Number.isInteger(years) || years < 1) throw new Error("years must be a whole number, at least 1");
const redeem = registryRedeem(terms, state);
const next = { ownerPkh: state.ownerPkh, expiry: state.expiry + years * terms.blocksPerYear };
const built = assemble({
special: [registrySpecial(cov, redeem, "renew", ownerKey(keyFor, state), [years])],
outputs: [certOut(registryLock(terms, next), cov), { lockingBytecode: terms.fundLock, valueSatoshis: BigInt(terms.renewalSats) * BigInt(years) }],
- extra, coins, changeLock, keyFor, feeRate, tamper,
+ extra: announced(announce, next, extra), coins, changeLock, keyFor, feeRate, tamper,
});
return { ...built, state: next };
}
/** Transfer to `newOwnerPkh` (or to the same key: the carrier for a records update). */
-export function buildRegistryTransferTx({ cov, terms, state, newOwnerPkh, coins, changeLock, keyFor, extra = [], feeRate = 1, tamper = null }) {
+export function buildRegistryTransferTx({ cov, terms, state, newOwnerPkh, coins, changeLock, keyFor, announce = null, extra = [], feeRate = 1, tamper = null }) {
const redeem = registryRedeem(terms, state);
const next = { ownerPkh: binToHex(bin(newOwnerPkh)), expiry: state.expiry };
const built = assemble({
special: [registrySpecial(cov, redeem, "transfer", ownerKey(keyFor, state), [bin(newOwnerPkh)])],
outputs: [certOut(registryLock(terms, next), cov)],
- extra, coins, changeLock, keyFor, feeRate, tamper,
+ extra: announced(announce, next, extra), coins, changeLock, keyFor, feeRate, tamper,
});
return { ...built, state: next };
}
@@ -203,7 +251,7 @@ export function buildRegistryTransferTx({ cov, terms, state, newOwnerPkh, coins,
* locktime, so it cannot be mined earlier). `box` is the taker's CommitBox
* UTXO, created at least a block before, for this name, salt and key.
*/
-export function buildRegistryTakeoverTx({ cov, terms, state, name, height, newOwnerPkh, salt, box, coins, changeLock, keyFor, extra = [], feeRate = 1, tamper = null }) {
+export function buildRegistryTakeoverTx({ cov, terms, state, name, height, newOwnerPkh, salt, box, coins, changeLock, keyFor, announce = null, extra = [], feeRate = 1, tamper = null }) {
const quote = takeoverQuote(terms, state, height);
if (!quote) throw new Error("the name is still ACTIVE — it cannot be taken over before it expires");
const redeem = registryRedeem(terms, state);
@@ -225,13 +273,15 @@ export function buildRegistryTakeoverTx({ cov, terms, state, name, height, newOw
{ lockingBytecode: terms.fundLock, valueSatoshis: quote.fundSats },
...(quote.ownerSats > 0n ? [{ lockingBytecode: pkhLock(state.ownerPkh), valueSatoshis: quote.ownerSats }] : []),
],
- extra, coins, changeLock, keyFor, locktime: height, feeRate, tamper,
+ extra: announced(announce, next, extra), coins, changeLock, keyFor, locktime: height, feeRate, tamper,
});
return { ...built, state: next, quote };
}
/** Release: the certificate is destroyed. At most four outputs, none carrying it. */
-export function buildRegistryReleaseTx({ cov, terms, state, coins = [], changeLock, keyFor, extra = [], feeRate = 1, tamper = null }) {
+export function buildRegistryReleaseTx({ cov, terms, state, coins = [], changeLock, keyFor, announce = null, extra = [], feeRate = 1, tamper = null }) {
+ // Announced as an ordinary REL: the certificate is burned, the name is free.
+ extra = announced(announce ? { ...announce, records: {} } : null, null, extra, "REL");
if (extra.length > 3) throw new Error("a release may have at most four outputs in total");
const redeem = registryRedeem(terms, state);
// Output 0 must exist and must not carry the certificate: a dust output takes that slot.
diff --git a/src/lib/resolver-web.d.ts b/src/lib/resolver-web.d.ts
index a691567..9f86b5c 100644
--- a/src/lib/resolver-web.d.ts
+++ b/src/lib/resolver-web.d.ts
@@ -62,6 +62,18 @@ export interface BnsNetwork {
export function onlyPayload(vouts: any[]): any | null;
/** REG/TREG: is `mint` the only token of its category there is or can ever be? */
export function soleCertificate(tx: any, mint: any): boolean;
+/** A covenant-held name's verified state (PROTOCOL-ADDENDUM-covenant-names.md). */
+export interface CovenantState { ownerPkh: string; expiry: number; tier: number; version: number }
+export const COVENANT_MAX_EXPIRY: number;
+export function pkhToAddress(pkhHex: string, prefix?: string): string;
+export function covenantTierIndex(name: string, covenant: any): number;
+export function covenantLockHex(covenant: any, tierIndex: number, ownerPkh: string, expiry: number): string;
+export function covenantClaim(payload: any, holder: any, name: string, network?: any): CovenantState | null;
+export function covenantEntryPaid(claim: CovenantState, tx: any, height: number, network?: any): boolean;
+export function admitCovenantState(entry: any, payload: any, holder: any, tx: any, height: number, network?: any): boolean;
+export function covenantPhase(state: CovenantState | null | undefined, height: number, network?: any): { status: "active" | "idle" | "open"; expiry: number; idleEnd: number } | null;
+export function historyHeight(history: Array<{ height: number }>): number;
+export function applyLifecycle>(names: T, height: number, network?: any): T;
/** REL: does `tx` burn `entry`'s certificate, spending it from a beacon transaction in `txs`? */
export function burnsCertificate(tx: any, entry: { category: string }, txs: Map): boolean;
/** One-block gap after a release. `releasedAt` undefined = never released. */
diff --git a/src/lib/resolver-web.js b/src/lib/resolver-web.js
index c6240e8..f605257 100644
--- a/src/lib/resolver-web.js
+++ b/src/lib/resolver-web.js
@@ -97,6 +97,27 @@ export const NETWORKS = {
scripthash: "fa14c5fd7a22bd1856b44168823a1ab3ff50961c917dafbcba749aeaa7b26646",
},
],
+ // The registry covenant a resolver recognises (PROTOCOL-ADDENDUM-covenant-names.md).
+ // A covenant-held name sits at P2SH32(hash256(STATE ‖ head ‖ tail)); given
+ // these bytes a resolver checks a claimed owner and expiry against the
+ // address instead of trusting anyone. GENERATED — do not edit by hand:
+ // node src/generate-covenant-table.mjs --fund --steward
+ // test/covenant-names.test.mjs fails if it drifts from the contract.
+ // Chipnet's fund and steward are the operator's own key.
+ covenant: {
+ version: 1,
+ fundLock: "76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac",
+ steward: "02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d",
+ blocksPerYear: 52560,
+ graceBlocks: 26280,
+ tiers: [
+ { maxLabel: 2, renewalSats: 2000000, head: "011e0350cd0002a86604809698000400e1f5050380841e" },
+ { maxLabel: 4, renewalSats: 500000, head: "011e0350cd0002a86603a025260440787d010320a107" },
+ { maxLabel: 7, renewalSats: 200000, head: "011e0350cd0002a8660340420f048096980003400d03" },
+ { maxLabel: 255, renewalSats: 100000, head: "011e0350cd0002a8660320a10703404b4c03a08601" },
+ ],
+ tail: "105279a97b887b7bad51b275827701209c2102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d1976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac5b79009c635c79a95b79885d7a5d7aad5c7951a269597a815c79597a959376040065cd1d9f6901145a7a7e547e7c54807ec1011a7f777eaa00cd02aa207b7e01877e8800d1c0ce8800d2c0cf8800ccc0c6a26951cd8851cc537a597a95a26951d100886d6d6d7551675b79519c635e7a820114885d79a95c7a885d7a5d7aad01147c7e547e5a7a7ec1011a7f777eaa00cd02aa207b7e01877e8800d1c0ce8800d2c0cf8800ccc0c6a2696d6d6d6d6d51675b79529c635c7a820114885a7a8176b175c576040065cd1d9f697c597993587900709f635a795a795c79945479567994955d7996937b757c785e799501649677686079827701209dc0cf01117a7eaa011456797e01207e7c7e587a7e51c702aa207baa7e01877e8853795c7993040065cd1d9f690114557a7e547e547a5b7a9354807ec1011a7f777eaa00cd02aa207b7e01877e8800d1c0ce8800d2c0cf8800ccc0c6a26951cd547a8851cc7b527994a26951d100887600a06352cd0376a9145a797e0288ac7e8852cc78a26952d10088686d6d6d6d6d51675b79539c635c79a95b7a885c7a5c7aadc0cec454a16900d178879169c451a06351d17887916968c452a06352d17887916968c453a06353d178879169686d6d6d6d6d6d51675b7a549d5b79a95b79885c7a5c7aadc1011a7f77aa5c7a7c5d79aa7eaa537abb01145a7a7e547e597a7e597a7eaa00cd02aa207b7e01877e8800d1c0ce8800d2c0cf8800ccc0c6a2696d6d6d6d5168686868",
+ },
},
mainnet: {
id: "mainnet",
@@ -137,6 +158,8 @@ export const NETWORKS = {
// NOT PINNED: the blocklist beacon is one of the addresses reserved before
// the mainnet beacon goes live (ROADMAP-MAINNET.md §6).
blocklists: [],
+ // NOT PINNED: needs the fund's and the steward's mainnet keys.
+ covenant: null,
},
};
export const DEFAULT_NETWORK_ID = "chipnet";
@@ -587,7 +610,7 @@ export function mayReRegister(releasedAt, height) {
return !(height > 0) || height > releasedAt;
}
-export async function buildIndex({ network = NETWORK, electrum = getNetwork(network).electrum, WebSocket: WS, client, directIP = false, enforceTld = false, cosignGate = true } = {}) {
+export async function buildIndex({ network = NETWORK, electrum = getNetwork(network).electrum, WebSocket: WS, client, directIP = false, enforceTld = false, cosignGate = true, height: atHeight = null } = {}) {
const net = requireBeacons(network);
const el = client ?? (await connectAny(electrum, WS, { directIP }));
try {
@@ -667,6 +690,8 @@ export async function buildIndex({ network = NETWORK, electrum = getNetwork(netw
// Updated in the UPD branch below on every ownership-touching tx.
owner: ownerFromVout(mint),
});
+ // registered straight into the registry covenant?
+ admitCovenantState(names.get(key), payload, mint, tx, height, net);
released.delete(key);
} else if (payload.o === "REL") {
const entry = names.get(key);
@@ -684,11 +709,23 @@ export async function buildIndex({ network = NETWORK, electrum = getNetwork(netw
// manifest verifier reads the CURRENT holder, not the mint address.
const proving = vouts.find((v) => v.tokenData && v.tokenData.category === entry.category);
if (!proving) continue;
+ // A covenant-held name answers only to a verified state (admitCovenantState).
+ if (!admitCovenantState(entry, payload, proving, tx, height, net)) continue;
entry.records = payload.r ?? {};
entry.updatedTxid = tx_hash;
- entry.owner = ownerFromVout(proving) ?? entry.owner;
+ if (!entry.covenant) entry.owner = ownerFromVout(proving) ?? entry.owner;
}
}
+ // Expiry is judged at the chain tip when a server will say what it is, and
+ // otherwise at the newest block in the beacon history (historyHeight).
+ let tip = atHeight;
+ if (!(tip > 0)) {
+ try {
+ const head = await el.call("blockchain.headers.subscribe", []);
+ if (head && head.height > 0) tip = head.height;
+ } catch { /* a beacon-only indexer may not answer this */ }
+ }
+ applyLifecycle(names, Math.max(tip > 0 ? tip : 0, historyHeight(history)), net);
// Attached rather than returned separately so every existing caller keeps
// working. A caller that cares (the registrar, before taking money) can ask
// whether the servers agreed on what exists.
@@ -711,7 +748,7 @@ export async function buildIndex({ network = NETWORK, electrum = getNetwork(netw
//
// Signature deliberately mirrors buildIndex so it's a drop-in for callers
// that already had one (Theseus's ensureIndex warm-start path).
-export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap = null } = {}) {
+export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap = null, network = NETWORK, height: atHeight = null } = {}) {
if (!snapshot || !Array.isArray(snapshot.history)) {
throw new Error("buildIndexFromSnapshot: snapshot with `history` array required");
}
@@ -721,6 +758,7 @@ export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap =
// free of surprise dependencies.
throw new Error("buildIndexFromSnapshot: enforceTld=true requires an explicit tldMap");
}
+ const net = getNetwork(network);
const txMap = snapshot.txs instanceof Map ? snapshot.txs : new Map(Object.entries(snapshot.txs || {}));
const history = [...snapshot.history].sort(byHeightThenTxid);
const names = new Map();
@@ -746,6 +784,8 @@ export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap =
txid: tx_hash, height, updatedTxid: null,
owner: ownerFromVout(mint),
});
+ // registered straight into the registry covenant?
+ admitCovenantState(names.get(key), payload, mint, tx, height, net);
released.delete(key);
} else if (payload.o === "REL") {
const entry = names.get(key);
@@ -758,11 +798,14 @@ export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap =
if (!entry) continue;
const proving = vouts.find((v) => v.tokenData && v.tokenData.category === entry.category);
if (!proving) continue;
+ // A covenant-held name answers only to a verified state (admitCovenantState).
+ if (!admitCovenantState(entry, payload, proving, tx, height, net)) continue;
entry.records = payload.r ?? {};
entry.updatedTxid = tx_hash;
- entry.owner = ownerFromVout(proving) ?? entry.owner;
+ if (!entry.covenant) entry.owner = ownerFromVout(proving) ?? entry.owner;
}
}
+ applyLifecycle(names, Math.max(atHeight > 0 ? atHeight : 0, snapshot.height > 0 ? snapshot.height : 0, historyHeight(history)), net);
Object.defineProperty(names, "released", { value: released, enumerable: false });
return names;
}
@@ -861,6 +904,131 @@ export function addressToLockHex(address) {
export function addressToScripthash(address) {
return bytesToHex(sha256(hexToBytes(addressToLockHex(address))).reverse());
}
+// 20-byte public key hash -> P2PKH cashaddr.
+export function pkhToAddress(pkhHex, prefix = NETWORK.prefix) {
+ const data = [];
+ let acc = 0, bits = 0;
+ for (const b of [0, ...hexToBytes(pkhHex)]) {
+ acc = ((acc << 8) | b) & 0xfff; bits += 8;
+ while (bits >= 5) { bits -= 5; data.push((acc >> bits) & 31); }
+ }
+ if (bits) data.push((acc << (5 - bits)) & 31);
+ const mod = cashaddrPolymod([...[...prefix].map((ch) => ch.charCodeAt(0) & 31), 0, ...data, 0, 0, 0, 0, 0, 0, 0, 0]);
+ const check = [];
+ for (let i = 0; i < 8; i++) check.push(Number((mod >> BigInt(5 * (7 - i))) & 31n));
+ return prefix + ":" + [...data, ...check].map((v) => CASHADDR_CHARSET[v]).join("");
+}
+
+// ---- covenant-held names (Decentralized.DNS/PROTOCOL-ADDENDUM-covenant-names.md) ----
+// A name whose certificate sits in the registry covenant has an owner and an
+// expiry that are not visible in the output: they are 26 bytes hashed into its
+// address. The transaction that puts it there says what they are (`c` in the
+// payload) and the resolver checks the claim against the address. A claim
+// that does not hash to the address is not a claim.
+//
+// Everything a reducer needs is here, so the three of them cannot drift:
+// admitCovenantState() while reducing, applyLifecycle() once at the end.
+
+export const COVENANT_MAX_EXPIRY = 500000000; // above this a locktime is a timestamp, not a height
+
+// Index (0-based) of the price tier a name belongs to: by label length.
+export function covenantTierIndex(name, covenant) {
+ const label = String(name).split(".")[0];
+ return covenant.tiers.findIndex((t) => label.length <= t.maxLabel);
+}
+// The locking bytecode of a covenant-held name with this owner and expiry.
+export function covenantLockHex(covenant, tierIndex, ownerPkh, expiry) {
+ const e = expiry >>> 0;
+ const le = [e & 0xff, (e >>> 8) & 0xff, (e >>> 16) & 0xff, (e >>> 24) & 0xff];
+ const redeem = "14" + ownerPkh + "04" + bytesToHex(le) + covenant.tiers[tierIndex].head + covenant.tail;
+ return "aa20" + bytesToHex(sha256(sha256(hexToBytes(redeem)))) + "87";
+}
+// The state a payload claims for `holder` (the output carrying the name's
+// certificate), or null when there is no claim or the address disagrees.
+export function covenantClaim(payload, holder, name, network = NETWORK) {
+ const covenant = getNetwork(network).covenant;
+ const c = payload ? payload.c : null;
+ if (!covenant || !c || typeof c !== "object" || !holder || !holder.scriptPubKey) return null;
+ if (typeof c.o !== "string" || !/^[0-9a-f]{40}$/.test(c.o)) return null;
+ if (!Number.isInteger(c.e) || c.e <= 0 || c.e >= COVENANT_MAX_EXPIRY) return null;
+ const tierIndex = covenantTierIndex(name, covenant);
+ if (tierIndex < 0) return null;
+ if (holder.scriptPubKey.hex !== covenantLockHex(covenant, tierIndex, c.o, c.e)) return null;
+ return { ownerPkh: c.o, expiry: c.e, tier: tierIndex + 1, version: covenant.version };
+}
+// Entering the covenant is the one step script does not police: whoever holds
+// a certificate can send it to any address, including a covenant address with
+// an expiry of their choosing. So the resolver sets the price of admission —
+// the entering transaction pays the fund for whole years, and the claimed
+// expiry is no later than that many years after the block it is mined in.
+// An unconfirmed entry has no block yet and waits for one.
+export function covenantEntryPaid(claim, tx, height, network = NETWORK) {
+ const covenant = getNetwork(network).covenant;
+ if (!covenant || !claim || !(height > 0)) return false;
+ let paid = 0;
+ for (const v of tx.vout || []) {
+ if (v && v.scriptPubKey && v.scriptPubKey.hex === covenant.fundLock && !v.tokenData) paid += Math.round(Number(v.value) * 1e8);
+ }
+ const years = Math.floor(paid / covenant.tiers[claim.tier - 1].renewalSats);
+ return years >= 1 && claim.expiry <= height + years * covenant.blocksPerYear;
+}
+// Called by every reducer for a REG it has just admitted and for an UPD that
+// carries the name's certificate. Returns false when the transaction must be
+// ignored.
+// · a name not in the covenant: a verified, paid claim puts it there;
+// anything else leaves it an ordinary name and the transaction counts as
+// it always did.
+// · a name in the covenant: only a verified claim speaks for it. From here
+// on the chain polices every move, so no payment check — but a
+// transaction that does not say where the certificate now is, or says it
+// wrongly, changes nothing. Otherwise an owner could announce their way
+// back to an ordinary name that never expires.
+export function admitCovenantState(entry, payload, holder, tx, height, network = NETWORK) {
+ const claim = covenantClaim(payload, holder, entry.name, network);
+ if (entry.covenant) {
+ if (!claim) return false;
+ } else if (!claim || !covenantEntryPaid(claim, tx, height, network)) {
+ return true;
+ }
+ entry.covenant = claim;
+ entry.owner = pkhToAddress(claim.ownerPkh, getNetwork(network).prefix);
+ return true;
+}
+// active: before expiry. idle: expired, the owner may still renew and keeps
+// control of the records. open: the grace period has passed; the name is the
+// next taker's and no longer resolves.
+export function covenantPhase(covenantState, height, network = NETWORK) {
+ const covenant = getNetwork(network).covenant;
+ if (!covenantState || !covenant) return null;
+ const idleEnd = covenantState.expiry + covenant.graceBlocks;
+ return { status: height < covenantState.expiry ? "active" : height < idleEnd ? "idle" : "open", expiry: covenantState.expiry, idleEnd };
+}
+// The height a reducer judges expiry at when it cannot ask for the tip: the
+// highest confirmed height in the history it was given. That is never ahead of
+// the chain, so a name can lapse late on a stale index but never early.
+export function historyHeight(history) {
+ let h = 0;
+ for (const x of history || []) if (x && x.height > h) h = x.height;
+ return h;
+}
+// Last step of every reducer. A name past its grace period stays in the index
+// — it is still taken, and its certificate is what a taker buys — but it
+// resolves to nothing: its records move to `lapsedRecords`.
+export function applyLifecycle(names, height, network = NETWORK) {
+ for (const entry of names.values()) {
+ const phase = covenantPhase(entry.covenant, height, network);
+ if (!phase) continue;
+ entry.status = phase.status;
+ entry.expiry = phase.expiry;
+ entry.idleEnd = phase.idleEnd;
+ if (phase.status === "open") {
+ entry.lapsedRecords = entry.records;
+ entry.records = {};
+ }
+ }
+ Object.defineProperty(names, "height", { value: height, enumerable: false, configurable: true });
+ return names;
+}
// The fixed reason vocabulary (addendum §4). Nothing else is a valid reason.
export const BLOCK_REASONS = new Set(["phishing", "impersonation", "malware", "scam-financial", "csam", "stolen"]);
diff --git a/test/covenant-names.test.mjs b/test/covenant-names.test.mjs
new file mode 100644
index 0000000..0b67850
--- /dev/null
+++ b/test/covenant-names.test.mjs
@@ -0,0 +1,382 @@
+// Resolvers reading covenant-held names.
+// (Decentralized.DNS/PROTOCOL-ADDENDUM-covenant-names.md)
+//
+// A covenant-held name's owner and expiry are hashed into its address. The
+// transaction says what they are; the resolver checks the claim against the
+// address, decides whether an entry was paid for, and stops resolving a name
+// whose grace period has passed.
+//
+// Every transaction here is built by the real builders and, where it spends
+// the covenant, accepted by the VM first — then turned into the verbose form
+// an electrum server returns and fed to all three JavaScript reducers, which
+// must agree.
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import * as L from "@bitauth/libauth";
+import {
+ NETWORKS, buildIndex, buildIndexFromSnapshot, covenantLockHex, covenantTierIndex, covenantClaim, covenantPhase,
+ pkhToAddress, addressToLockHex, historyHeight,
+} from "../src/lib/resolver-web.js";
+import * as legacy from "../src/lib/bns.js";
+import { covenantTable } from "../src/generate-covenant-table.mjs";
+import {
+ registryTerms, registryLock, commitBoxLock, commitHashFor, covenantClaimOf, registryAnnouncement,
+ buildRegistryEnterTx, buildRegistryRenewTx, buildRegistryTransferTx, buildRegistryTakeoverTx, buildRegistryReleaseTx,
+} from "../src/lib/registry-covenant.js";
+import { TIERS, tierForName, BLOCKS_PER_YEAR, GRACE_BLOCKS } from "../src/lib/registry-lifecycle.js";
+import { lockToAddress } from "../src/lib/covenant.js";
+import { encodePayload, opReturnScript } from "../src/lib/register-tx.js";
+
+const { hexToBin, binToHex, hash160, secp256k1, encodeLockingBytecodeP2pkh, generatePrivateKey } = L;
+const NET = NETWORKS.chipnet;
+const COV = NET.covenant;
+const VM = L.createVirtualMachineBch2025();
+
+// ---------- cast ----------
+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), address: lockToAddress(lock, NET.prefix, false) };
+};
+const owner = person(), buyer = person(), taker = person(), stranger = person();
+const keyring = (...people) => (lockHex) => { const p = people.find((x) => x.lockHex === lockHex); return p ? { privateKey: p.priv, publicKey: p.pub } : null; };
+const KEYS = keyring(owner, buyer, taker, stranger);
+const fundLock = hexToBin(COV.fundLock);
+const beaconLock = hexToBin(addressToLockHex(NET.beacon));
+const termsFor = (name) => registryTerms({ tier: tierForName(name), fundLock, stewardPk: hexToBin(COV.steward) });
+let coinSeq = 100;
+const coin = (who, sats = 50_000_000n) => ({ txid: (coinSeq++).toString(16).padStart(64, "0"), vout: 0, satoshis: sats, lockingBytecode: who.lock });
+
+// ---------- libauth transaction -> what an electrum server returns ----------
+const verbose = (built) => ({
+ txid: built.txid,
+ vin: built.transaction.inputs.map((i) => ({ txid: binToHex(i.outpointTransactionHash), vout: i.outpointIndex })),
+ vout: built.transaction.outputs.map((o, n) => {
+ const hex = binToHex(o.lockingBytecode);
+ let address;
+ try { address = lockToAddress(o.lockingBytecode, NET.prefix, false); } catch { address = undefined; }
+ return {
+ n, value: Number(o.valueSatoshis) / 1e8,
+ scriptPubKey: { hex, ...(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) } } : {}) } } : {}),
+ };
+ }),
+});
+const vmAccepts = (built, what) => assert.equal(VM.verify({ transaction: built.transaction, sourceOutputs: built.sourceOutputs }), true, `${what}: the VM must accept it`);
+const nextCov = (built, token) => ({ txid: built.txid, vout: 0, satoshis: built.transaction.outputs[0].valueSatoshis, lockingBytecode: built.transaction.outputs[0].lockingBytecode, token });
+
+// A plain registration, hand-made (no contract involved).
+let catSeq = 1;
+function registration(name, who, records = { u: "https://first.example" }) {
+ const category = (catSeq++).toString(16).padStart(2, "0").repeat(32);
+ const token = { category, amount: 0n, nft: { capability: "none", commitment: binToHex(new TextEncoder().encode(name)) } };
+ const txid = "f" + (catSeq).toString(16).padStart(63, "0");
+ const tx = {
+ txid, vin: [{ txid: category, vout: 0 }],
+ vout: [
+ { n: 0, value: 0.00001, scriptPubKey: { hex: who.lockHex, address: who.address }, tokenData: { category, amount: "0", nft: { capability: "none", commitment: token.nft.commitment } } },
+ { n: 1, value: 0, scriptPubKey: { hex: binToHex(opReturnScript(encodePayload("REG", name, records))) } },
+ { n: 2, value: 0.000006, scriptPubKey: { hex: binToHex(beaconLock), address: NET.beacon } },
+ ],
+ };
+ return { tx, cert: { txid, vout: 0, satoshis: 1000n, lockingBytecode: who.lock, token } };
+}
+const announce = (name, records) => ({ name, records, beaconLock });
+
+// ---------- the three reducers, which must agree ----------
+async function reduce(entries, height) {
+ const history = entries.map(([tx, h]) => ({ tx_hash: tx.txid, height: h }));
+ const txs = Object.fromEntries(entries.map(([tx]) => [tx.txid, tx]));
+ const snap = buildIndexFromSnapshot({ snapshot: { history, txs }, height });
+ const el = {
+ call: async (method, [a]) => method === "blockchain.scripthash.get_history" ? history.map((x) => ({ ...x }))
+ : method === "blockchain.headers.subscribe" ? { height }
+ : txs[a],
+ };
+ const live = await buildIndex({ client: el, cosignGate: false });
+ const old = await legacy.buildIndex({ getHistory: async () => history.map((x) => ({ ...x })), getRawTransactionObject: async (id) => txs[id] }, { cosignGate: false, height });
+ const shape = (idx) => [...idx].map(([k, e]) => JSON.stringify([k, e.records, e.covenant ?? null, e.status ?? null, e.expiry ?? null, e.lapsedRecords ?? null])).sort();
+ assert.deepEqual(shape(live), shape(snap), "buildIndex and buildIndexFromSnapshot disagree");
+ assert.deepEqual(shape(old), shape(snap), "bns.js and resolver-web.js disagree");
+ assert.equal(snap.height, height);
+ return snap;
+}
+
+// ---------- the table ----------
+
+test("the network table holds the contract's code, byte for byte", () => {
+ assert.deepEqual(covenantTable({ fundLock, stewardPk: hexToBin(COV.steward) }), COV, "regenerate: node src/generate-covenant-table.mjs");
+ for (const t of TIERS) {
+ const terms = registryTerms({ tier: t.tier, fundLock, stewardPk: hexToBin(COV.steward) });
+ const state = { ownerPkh: owner.pkh, expiry: 123456 + t.tier };
+ assert.equal(covenantLockHex(COV, t.tier - 1, state.ownerPkh, state.expiry), binToHex(registryLock(terms, state)), `tier ${t.tier}`);
+ }
+ for (const name of ["a.x", "ab.x", "abc.x", "abcd.x", "abcde.x", "abcdefg.x", "abcdefgh.x", "a-much-longer-label.test", "bare"]) {
+ assert.equal(covenantTierIndex(name, COV) + 1, tierForName(name), name);
+ }
+ assert.equal(NETWORKS.mainnet.covenant, null, "mainnet is not pinned");
+});
+
+test("pkhToAddress is the inverse of addressToLockHex", () => {
+ for (const p of [owner, buyer, taker]) {
+ assert.equal(pkhToAddress(p.pkh, NET.prefix), p.address);
+ assert.equal(addressToLockHex(pkhToAddress(p.pkh, NET.prefix)), p.lockHex);
+ }
+ assert.equal(pkhToAddress(owner.pkh, "bitcoincash"), lockToAddress(owner.lock, "bitcoincash", false));
+});
+
+// ---------- entering ----------
+
+const NAME = "holdme.x";
+const H_REG = 1000, TIP = 1999, H_ENTER = 2000;
+
+function entered({ name = NAME, who = owner, years = 1, height = TIP, records = { u: "https://held.example" } } = {}) {
+ const reg = registration(name, who);
+ const terms = termsFor(name);
+ const enter = buildRegistryEnterTx({ cert: reg.cert, terms, name, records, ownerPkh: who.pkh, height, years, beaconLock, coins: [coin(who)], changeLock: who.lock, keyFor: KEYS });
+ return { reg, terms, enter, cov: nextCov(enter, reg.cert.token), state: enter.state };
+}
+
+test("a paid entry makes the name covenant-held: owner and expiry come from the verified claim", async () => {
+ const { reg, enter, state } = entered();
+ const idx = await reduce([[reg.tx, H_REG], [verbose(enter), H_ENTER]], H_ENTER);
+ const e = idx.get(NAME);
+ assert.deepEqual(e.covenant, { ownerPkh: owner.pkh, expiry: TIP + BLOCKS_PER_YEAR, tier: 3, version: 1 });
+ assert.equal(e.owner, owner.address, "the owner is the key in the state, not the covenant's address");
+ assert.deepEqual(e.records, { u: "https://held.example" });
+ assert.equal(e.status, "active");
+ assert.equal(e.expiry, state.expiry);
+ assert.equal(e.idleEnd, state.expiry + GRACE_BLOCKS);
+});
+
+test("the lifecycle: resolves while active and idle, not once open — and stays taken", async () => {
+ const { reg, enter, state } = entered();
+ const at = async (h) => (await reduce([[reg.tx, H_REG], [verbose(enter), H_ENTER]], h)).get(NAME);
+ assert.equal((await at(state.expiry - 1)).status, "active");
+ const idle = await at(state.expiry);
+ assert.equal(idle.status, "idle");
+ assert.deepEqual(idle.records, { u: "https://held.example" }, "the owner keeps the records through the grace period");
+ assert.equal((await at(state.expiry + GRACE_BLOCKS - 1)).status, "idle");
+ const open = await at(state.expiry + GRACE_BLOCKS);
+ assert.equal(open.status, "open");
+ assert.deepEqual(open.records, {}, "a lapsed name resolves to nothing");
+ assert.deepEqual(open.lapsedRecords, { u: "https://held.example" });
+
+ // Its certificate still exists: a fresh registration of the same name is refused.
+ const again = registration(NAME, stranger, { u: "https://squatter.example" });
+ const idx = await reduce([[reg.tx, H_REG], [verbose(enter), H_ENTER], [again.tx, state.expiry + GRACE_BLOCKS + 5]], state.expiry + GRACE_BLOCKS + 10);
+ assert.equal(idx.get(NAME).category, reg.cert.token.category);
+ assert.deepEqual(idx.get(NAME).records, {});
+
+ assert.deepEqual(covenantPhase(open.covenant, state.expiry + GRACE_BLOCKS), { status: "open", expiry: state.expiry, idleEnd: state.expiry + GRACE_BLOCKS });
+ assert.equal(covenantPhase(null, 5), null);
+});
+
+test("an entry that was not paid for leaves an ordinary name", async () => {
+ const plain = async (enter, height, why) => {
+ const idx = await reduce([[enter.reg.tx, H_REG], [enter.tx, height]], Math.max(height, H_ENTER));
+ const e = idx.get(enter.name ?? NAME);
+ assert.equal(e.covenant, undefined, why);
+ assert.equal(e.status, undefined, why);
+ assert.deepEqual(e.records, { u: "https://held.example" }, `${why}: it is still a valid update of an ordinary name`);
+ return e;
+ };
+ const base = entered();
+ const fundIndex = verbose(base.enter).vout.findIndex((v) => v.scriptPubKey.hex === COV.fundLock && !v.tokenData);
+ assert.equal(fundIndex, 1);
+
+ // one satoshi short
+ const short = verbose(base.enter);
+ short.vout[1].value = (COV.tiers[2].renewalSats - 1) / 1e8;
+ await plain({ reg: base.reg, tx: short }, H_ENTER, "one satoshi short of a year");
+
+ // paid to somebody else
+ const elsewhere = verbose(base.enter);
+ elsewhere.vout[1].scriptPubKey = { hex: stranger.lockHex, address: stranger.address };
+ await plain({ reg: base.reg, tx: elsewhere }, H_ENTER, "paid to another address");
+
+ // a token output to the fund is not a payment
+ const tokenPay = verbose(base.enter);
+ tokenPay.vout[1].tokenData = { category: "ee".repeat(32), amount: "5" };
+ await plain({ reg: base.reg, tx: tokenPay }, H_ENTER, "the fund output carries a token");
+
+ // one year paid, two claimed
+ const far = entered({ height: TIP + BLOCKS_PER_YEAR });
+ await plain({ reg: far.reg, tx: verbose(far.enter) }, H_ENTER, "expiry further out than was paid for");
+
+ // not in a block yet
+ const e = await plain({ reg: base.reg, tx: verbose(base.enter) }, 0, "unconfirmed");
+ assert.notEqual(e.owner, owner.address, "until it confirms, the holder is just the address the certificate sits at");
+
+ // two years paid, two claimed: fine
+ const two = entered({ years: 2 });
+ const idx = await reduce([[two.reg.tx, H_REG], [verbose(two.enter), H_ENTER]], H_ENTER);
+ assert.equal(idx.get(NAME).covenant.expiry, TIP + 2 * BLOCKS_PER_YEAR);
+});
+
+test("a claim that does not hash to the address is not a claim", async () => {
+ const base = entered();
+ const holder = verbose(base.enter).vout[0];
+ const good = covenantClaimOf(base.state);
+ assert.ok(covenantClaim({ c: good }, holder, NAME));
+ const bad = [
+ { ...good, o: buyer.pkh }, { ...good, e: good.e + 1 }, { ...good, e: String(good.e) }, { ...good, o: good.o.toUpperCase() },
+ { o: good.o }, { e: good.e }, null, "x", { ...good, e: 500000000 }, { ...good, e: 0 }, { ...good, e: good.e + 0.5 },
+ ];
+ for (const c of bad) assert.equal(covenantClaim({ c }, holder, NAME), null, JSON.stringify(c));
+ // the right state under another tier's prices is another address
+ assert.equal(covenantClaim({ c: good }, holder, "ab.x"), null);
+ assert.equal(covenantClaim({ c: good }, holder, "a-long-label.x"), null);
+ // a network with no covenant pinned knows no covenant-held names
+ assert.equal(covenantClaim({ c: good }, holder, NAME, NETWORKS.mainnet), null);
+
+ // In the index: a short name entered at the cheap tier's address stays ordinary.
+ const reg = registration("ab.x", owner);
+ const cheap = registryTerms({ tier: 4, fundLock, stewardPk: hexToBin(COV.steward) });
+ const enter = buildRegistryEnterTx({ cert: reg.cert, terms: cheap, name: "ab.x", records: { u: "https://held.example" }, ownerPkh: owner.pkh, height: TIP, beaconLock, coins: [coin(owner)], changeLock: owner.lock, keyFor: KEYS });
+ const idx = await reduce([[reg.tx, H_REG], [verbose(enter), H_ENTER]], H_ENTER);
+ assert.equal(idx.get("ab.x").covenant, undefined);
+});
+
+// ---------- inside ----------
+
+test("inside the covenant: updates, transfers and renewals are read from announced states", async () => {
+ const { reg, terms, enter, cov, state } = entered();
+ const chain = [[reg.tx, H_REG], [verbose(enter), H_ENTER]];
+
+ // records update: a transfer to the same key
+ const upd = buildRegistryTransferTx({ cov, terms, state, newOwnerPkh: owner.pkh, announce: announce(NAME, { u: "https://second.example" }), coins: [coin(owner)], changeLock: owner.lock, keyFor: KEYS });
+ vmAccepts(upd, "records update");
+ chain.push([verbose(upd), 2100]);
+ let e = (await reduce(chain, 2100)).get(NAME);
+ assert.deepEqual(e.records, { u: "https://second.example" });
+ assert.equal(e.owner, owner.address);
+
+ // transfer to the buyer
+ const xfer = buildRegistryTransferTx({ cov: nextCov(upd, cov.token), terms, state: upd.state, newOwnerPkh: buyer.pkh, announce: announce(NAME, { u: "https://buyer.example" }), coins: [coin(owner)], changeLock: owner.lock, keyFor: KEYS });
+ vmAccepts(xfer, "transfer");
+ chain.push([verbose(xfer), 2200]);
+ e = (await reduce(chain, 2200)).get(NAME);
+ assert.equal(e.owner, buyer.address);
+ assert.equal(e.covenant.expiry, state.expiry, "a transfer does not move the expiry");
+
+ // renewal by the buyer
+ const renew = buildRegistryRenewTx({ cov: nextCov(xfer, cov.token), terms, state: xfer.state, years: 2, announce: announce(NAME, { u: "https://buyer.example" }), coins: [coin(buyer)], changeLock: buyer.lock, keyFor: KEYS });
+ vmAccepts(renew, "renew");
+ chain.push([verbose(renew), 2300]);
+ e = (await reduce(chain, state.expiry + GRACE_BLOCKS + 1)).get(NAME);
+ assert.equal(e.covenant.expiry, state.expiry + 2 * BLOCKS_PER_YEAR);
+ assert.equal(e.status, "active", "the renewal was announced, so the name did not lapse");
+
+ // an unannounced renewal is valid on chain and invisible here: the name lapses in the index
+ const quiet = (await reduce(chain.slice(0, -1), state.expiry + GRACE_BLOCKS + 1)).get(NAME);
+ assert.equal(quiet.status, "open");
+ assert.deepEqual(quiet.records, {});
+});
+
+test("once inside, a transaction without a verified state changes nothing", async () => {
+ const { reg, terms, enter, cov, state } = entered();
+ const base = [[reg.tx, H_REG], [verbose(enter), H_ENTER]];
+ const real = buildRegistryTransferTx({ cov, terms, state, newOwnerPkh: owner.pkh, announce: announce(NAME, { u: "https://hijack.example" }), coins: [coin(owner)], changeLock: owner.lock, keyFor: KEYS });
+ const payloadAt = verbose(real).vout.findIndex((v) => v.scriptPubKey.hex.startsWith("6a"));
+ const withPayload = (payload) => { const t = verbose(real); t.vout[payloadAt].scriptPubKey.hex = binToHex(opReturnScript(payload)); return t; };
+
+ const cases = [
+ ["no claim at all — the way back to a name that never expires", encodePayload("UPD", NAME, { u: "https://hijack.example" })],
+ ["a claim for another owner", encodePayload("UPD", NAME, { u: "https://hijack.example" }, { o: stranger.pkh, e: state.expiry })],
+ ["a claim for a later expiry", encodePayload("UPD", NAME, { u: "https://hijack.example" }, { o: owner.pkh, e: state.expiry + BLOCKS_PER_YEAR })],
+ ];
+ for (const [what, payload] of cases) {
+ const e = (await reduce([...base, [withPayload(payload), 2100]], 2100)).get(NAME);
+ assert.deepEqual(e.records, { u: "https://held.example" }, what);
+ assert.equal(e.covenant.expiry, state.expiry, what);
+ assert.equal(e.owner, owner.address, what);
+ }
+ // and the honest announcement of the same transaction is accepted
+ assert.deepEqual((await reduce([...base, [verbose(real), 2100]], 2100)).get(NAME).records, { u: "https://hijack.example" });
+});
+
+// ---------- leaving ----------
+
+test("a takeover brings a lapsed name back, for its new owner", async () => {
+ const { reg, terms, enter, cov, state } = entered();
+ const openAt = state.expiry + GRACE_BLOCKS + 100;
+ const salt = crypto.getRandomValues(new Uint8Array(32));
+ const box = { txid: "b0".repeat(32), vout: 0, satoshis: 1000n, lockingBytecode: commitBoxLock({ ownerPkh: taker.pkh, commitHash: commitHashFor(NAME, salt) }) };
+ const take = buildRegistryTakeoverTx({ cov, terms, state, name: NAME, height: openAt, newOwnerPkh: taker.pkh, salt, box, announce: announce(NAME, { u: "https://taker.example" }), coins: [coin(taker)], changeLock: taker.lock, keyFor: KEYS });
+ vmAccepts(take, "takeover");
+ const before = (await reduce([[reg.tx, H_REG], [verbose(enter), H_ENTER]], openAt)).get(NAME);
+ assert.deepEqual(before.records, {});
+ const after = (await reduce([[reg.tx, H_REG], [verbose(enter), H_ENTER], [verbose(take), openAt + 1]], openAt + 1)).get(NAME);
+ assert.equal(after.owner, taker.address);
+ assert.deepEqual(after.records, { u: "https://taker.example" });
+ assert.equal(after.status, "active");
+ assert.equal(after.covenant.expiry, openAt + BLOCKS_PER_YEAR);
+ assert.equal(after.category, reg.cert.token.category, "the same certificate: a takeover is not a new registration");
+});
+
+test("a release from the covenant frees the name like any other", async () => {
+ const { reg, terms, enter, cov, state } = entered();
+ const rel = buildRegistryReleaseTx({ cov, terms, state, announce: announce(NAME), coins: [coin(owner)], changeLock: owner.lock, keyFor: KEYS });
+ vmAccepts(rel, "release");
+ const chain = [[reg.tx, H_REG], [verbose(enter), H_ENTER], [verbose(rel), 2100]];
+ const idx = await reduce(chain, 2100);
+ assert.equal(idx.has(NAME), false);
+ assert.equal(idx.released.get(NAME), 2100);
+ const again = registration(NAME, stranger, { u: "https://next.example" });
+ assert.equal((await reduce([...chain, [again.tx, 2100]], 2101)).has(NAME), false, "not in the release's own block");
+ assert.deepEqual((await reduce([...chain, [again.tx, 2101]], 2101)).get(NAME).records, { u: "https://next.example" });
+});
+
+// ---------- registering straight in ----------
+
+test("a registration can go straight into the covenant, on the same terms as an entry", async () => {
+ const name = "straight.x";
+ const terms = termsFor(name);
+ const state = { ownerPkh: owner.pkh, expiry: TIP + BLOCKS_PER_YEAR };
+ const make = (paid) => {
+ const r = registration(name, owner, { u: "https://straight.example" });
+ r.tx.vout[0].scriptPubKey = { hex: binToHex(registryLock(terms, state)) };
+ r.tx.vout[1].scriptPubKey.hex = binToHex(registryAnnouncement({ name, records: { u: "https://straight.example" }, beaconLock }, state, "REG")[0].lockingBytecode);
+ if (paid) r.tx.vout.push({ n: 3, value: COV.tiers[covenantTierIndex(name, COV)].renewalSats / 1e8, scriptPubKey: { hex: COV.fundLock } });
+ return r.tx;
+ };
+ const held = (await reduce([[make(true), H_ENTER]], H_ENTER)).get(name);
+ assert.equal(held.covenant.expiry, state.expiry);
+ assert.equal(held.owner, owner.address);
+ const unpaid = (await reduce([[make(false), H_ENTER]], H_ENTER)).get(name);
+ assert.equal(unpaid.covenant, undefined, "registered, but an ordinary name");
+ assert.deepEqual(unpaid.records, { u: "https://straight.example" });
+});
+
+// ---------- which height ----------
+
+test("expiry is judged at the tip when known, else at the newest block in the history — never ahead of the chain", async () => {
+ const { reg, enter, state } = entered();
+ const history = [{ tx_hash: reg.tx.txid, height: H_REG }, { tx_hash: enter.txid, height: H_ENTER }];
+ const txs = { [reg.tx.txid]: reg.tx, [enter.txid]: verbose(enter) };
+ assert.equal(historyHeight(history), H_ENTER);
+ assert.equal(historyHeight([{ height: 0 }, { height: -1 }]), 0);
+
+ const noTip = buildIndexFromSnapshot({ snapshot: { history, txs } });
+ assert.equal(noTip.height, H_ENTER);
+ assert.equal(noTip.get(NAME).status, "active");
+ const stamped = buildIndexFromSnapshot({ snapshot: { history, txs, height: state.expiry + GRACE_BLOCKS } });
+ assert.equal(stamped.get(NAME).status, "open");
+
+ // a server that will not say the tip: fall back to the history
+ const el = { call: async (m, [a]) => { if (m === "blockchain.scripthash.get_history") return history.map((x) => ({ ...x })); if (m === "blockchain.headers.subscribe") throw new Error("unsupported"); return txs[a]; } };
+ const live = await buildIndex({ client: el, cosignGate: false });
+ assert.equal(live.height, H_ENTER);
+ // an explicit height wins
+ assert.equal((await buildIndex({ client: el, cosignGate: false, height: state.expiry })).get(NAME).status, "idle");
+});
+
+test("ordinary names are untouched: no status, no expiry, same entry as before", async () => {
+ const reg = registration("plain.x", owner);
+ const e = (await reduce([[reg.tx, H_REG]], 10_000_000)).get("plain.x");
+ assert.deepEqual(Object.keys(e).sort(), ["category", "height", "name", "owner", "records", "txid", "updatedTxid"]);
+});