BNS: resolvers read covenant-held names

A name whose certificate sits in the registry covenant has an owner and an
expiry that are hashed into its address, not written on the chain. Until
now a resolver saw only "held by some script address", forever.

The transaction that moves the certificate now says the state (`c` in the
payload) and the resolver checks it against the address, using the
covenant's code from the network table. Nobody is trusted about who owns
such a name or until when.

Entering is the one step script cannot police - a certificate can be sent
to a covenant address with any expiry - so resolvers set the price: the
entering transaction pays the fund for whole years and may claim no more
than it paid for. Once a name is inside, only a verified state speaks for
it; otherwise an owner could announce their way back to a name that never
expires.

A name past its grace period stays taken but resolves to nothing, judged
at the chain tip (the indexer now answers what that is) or, failing that,
at the newest block in the beacon history, so a stale index lapses a name
late and never early.

Same rules in the gateway, the Ariadne resolver and the Android resolver;
the builders announce every move, and there is one to put an existing
name into the covenant. Chipnet's fund and steward are the operator key.
This commit is contained in:
Local Dev 2026-10-04 18:08:00 +02:00
parent a2e5ebffb4
commit 9404c024b7
10 changed files with 739 additions and 19 deletions

View file

@ -471,6 +471,7 @@ function stripHtmlExt(html) {
function transformHtml(html, baseHref) { function transformHtml(html, baseHref) {
return stripHtmlExt(rewriteBnsLinks(injectBase(html, baseHref))); return stripHtmlExt(rewriteBnsLinks(injectBase(html, baseHref)));
} }
const lapsed = (h, e) => `<div style="font:16px system-ui;padding:2rem;color:#333"><h2>Expired</h2><p><code>${h}</code> expired at block ${e.expiry} and was not renewed. It can be taken over.</p></div>`;
const nx = (h) => `<div style="font:16px system-ui;padding:2rem;color:#333"><h2>NXDOMAIN</h2><p><code>${h}</code> is not registered on the Bitcoin Cash chain.</p></div>`; const nx = (h) => `<div style="font:16px system-ui;padding:2rem;color:#333"><h2>NXDOMAIN</h2><p><code>${h}</code> is not registered on the Bitcoin Cash chain.</p></div>`;
// BNS `ip`-record fetch. The default `fetch` fails here for two reasons: // 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; let entry;
try { entry = await resolve(name); } catch (e) { res.writeHead(502); return res.end("resolve error: " + e.message); } 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)); } 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. // 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 // Policy "warn": the visitor may continue, per name, for a day — except
// for "csam", where the gateway refuses outright. // for "csam", where the gateway refuses outright.
@ -1263,6 +1267,13 @@ const server = http.createServer(async (req, res) => {
name: key, tld, registered: !!entry, name: key, tld, registered: !!entry,
records: entry?.records ?? null, category: entry?.category ?? null, records: entry?.records ?? null, category: entry?.category ?? null,
owner: entry?.owner ?? 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) } : {}), ...(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 } : {}), ...(flag ? { blocked: true, block_reason: flag.reason, block_report_txid: flag.reportTxid, blocked_by: flag.source } : {}),
})); }));

View file

@ -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 <cashaddr> --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 <cashaddr> --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"));
}

View file

@ -60,6 +60,17 @@ ${net.electrum.map(javaServer).join("\n")}
public static final String[][] BLOCKLISTS = { public static final String[][] BLOCKLISTS = {
${(net.blocklists || []).map((l) => ` { ${q(l.name)}, ${q(l.beacon)}, ${q(l.scripthash)} },`).join("\n")} ${(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 }); mkdirSync(path.dirname(OUT), { recursive: true });

View file

@ -337,6 +337,10 @@ export function startElectrumWS(index, { bind, port }, rpc) {
return { id, result: index.getHistory(params[0]) }; return { id, result: index.getHistory(params[0]) };
if (method === "blockchain.transaction.get") if (method === "blockchain.transaction.get")
return { id, result: await getTx(params[0], params[1]) }; 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 } }; return { id, error: { code: -32601, message: "unsupported method: " + method } };
} catch (e) { return { id, error: { code: -1, message: String(e && e.message || e) } }; } } catch (e) { return { id, error: { code: -1, message: String(e && e.message || e) } }; }
} }

View file

@ -1,7 +1,7 @@
// BNS1 core: payload codec, chain indexer/resolver, and tx builders. // BNS1 core: payload codec, chain indexer/resolver, and tx builders.
import { OpReturnData, TokenSendRequest, NFTCapability } from "mainnet-js"; import { OpReturnData, TokenSendRequest, NFTCapability } from "mainnet-js";
import { BEACON, TLD_BEACON, PREFIX, BEACON_DUST_SAT, NAME_RE } from "../config.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 ---------- // ---------- payload codec ----------
@ -235,7 +235,7 @@ export function mayReRegister(releasedAt, height) {
return !(height > 0) || height > releasedAt; 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 // Fetch the TLD map first — needed for the existence gate when enforcing
// and always for the co-sign rule. Both fetches share `provider`. // and always for the co-sign rule. Both fetches share `provider`.
let tldMap = null; let tldMap = null;
@ -288,6 +288,8 @@ export async function buildIndex(provider, { enforceTld = false, cosignGate = tr
}; };
names.set(name, entry); names.set(name, entry);
byCategory.set(entry.category, 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); released.delete(name);
} else if (payload.o === "REL") { } else if (payload.o === "REL") {
const entry = names.get(name); const entry = names.get(name);
@ -301,12 +303,15 @@ export async function buildIndex(provider, { enforceTld = false, cosignGate = tr
if (!entry) continue; if (!entry) continue;
// ownership proof: some output carries the name's category // ownership proof: some output carries the name's category
// (consensus: impossible unless an input held that category) // (consensus: impossible unless an input held that category)
const proves = tx.vout.some((v) => v.tokenData?.category === entry.category); const proving = tx.vout.find((v) => v.tokenData?.category === entry.category);
if (!proves) continue; 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.records = payload.r ?? {};
entry.updatedTxid = tx_hash; entry.updatedTxid = tx_hash;
} }
} }
applyLifecycle(names, Math.max(atHeight > 0 ? atHeight : 0, historyHeight(history)));
Object.defineProperty(names, "released", { value: released, enumerable: false }); Object.defineProperty(names, "released", { value: released, enumerable: false });
return names; return names;
} }

View file

@ -108,8 +108,10 @@ export function displayName(rawName) {
return rawName.includes(".") ? rawName : `${rawName}.${DEFAULT_TLD}`; return rawName.includes(".") ? rawName : `${rawName}.${DEFAULT_TLD}`;
} }
export function encodePayload(op, rawName, records) { // `claim` is a covenant-held name's state, { o: ownerPkh hex, e: expiry }
const json = JSON.stringify({ o: op, n: rawName, r: records ?? {} }); // (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); const bytes = utf8ToBin(PREFIX + json);
if (bytes.length > MAX_PAYLOAD_BYTES) { if (bytes.length > MAX_PAYLOAD_BYTES) {
throw new Error(`records too large (${bytes.length} > ${MAX_PAYLOAD_BYTES} bytes)`); throw new Error(`records too large (${bytes.length} > ${MAX_PAYLOAD_BYTES} bytes)`);

View file

@ -30,10 +30,10 @@ import { NAME_REGISTRY } from "./name-registry-artifact.js";
import { COMMIT_BOX } from "./commit-box-artifact.js"; import { COMMIT_BOX } from "./commit-box-artifact.js";
import { import {
asmToBytecode, selectorsOf, pushInt, push, bin, pkhLock, p2sh32Lock, lockToAddress, asmToBytecode, selectorsOf, pushInt, push, bin, pkhLock, p2sh32Lock, lockToAddress,
unlockScript, SIG, PUBKEY, assemble, unlockScript, SIG, PUBKEY, assemble, p2pkhSpecial,
} from "./covenant.js"; } from "./covenant.js";
import { tokenOf } from "./market-tx.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 { import {
BLOCKS_PER_YEAR, GRACE_BLOCKS, SPLIT, BLOCKS_PER_YEAR, GRACE_BLOCKS, SPLIT,
renewalPriceSats, idlePremiumSats, openFloorSats, takeoverPriceSats, splitTakeover, phaseAt, PHASE, 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 }; 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 ---------- // ---------- 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 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) => { 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. */ /** 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"); if (!Number.isInteger(years) || years < 1) throw new Error("years must be a whole number, at least 1");
const redeem = registryRedeem(terms, state); const redeem = registryRedeem(terms, state);
const next = { ownerPkh: state.ownerPkh, expiry: state.expiry + years * terms.blocksPerYear }; const next = { ownerPkh: state.ownerPkh, expiry: state.expiry + years * terms.blocksPerYear };
const built = assemble({ const built = assemble({
special: [registrySpecial(cov, redeem, "renew", ownerKey(keyFor, state), [years])], special: [registrySpecial(cov, redeem, "renew", ownerKey(keyFor, state), [years])],
outputs: [certOut(registryLock(terms, next), cov), { lockingBytecode: terms.fundLock, valueSatoshis: BigInt(terms.renewalSats) * BigInt(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 }; return { ...built, state: next };
} }
/** Transfer to `newOwnerPkh` (or to the same key: the carrier for a records update). */ /** 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 redeem = registryRedeem(terms, state);
const next = { ownerPkh: binToHex(bin(newOwnerPkh)), expiry: state.expiry }; const next = { ownerPkh: binToHex(bin(newOwnerPkh)), expiry: state.expiry };
const built = assemble({ const built = assemble({
special: [registrySpecial(cov, redeem, "transfer", ownerKey(keyFor, state), [bin(newOwnerPkh)])], special: [registrySpecial(cov, redeem, "transfer", ownerKey(keyFor, state), [bin(newOwnerPkh)])],
outputs: [certOut(registryLock(terms, next), cov)], 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 }; 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 * 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. * 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); const quote = takeoverQuote(terms, state, height);
if (!quote) throw new Error("the name is still ACTIVE — it cannot be taken over before it expires"); if (!quote) throw new Error("the name is still ACTIVE — it cannot be taken over before it expires");
const redeem = registryRedeem(terms, state); const redeem = registryRedeem(terms, state);
@ -225,13 +273,15 @@ export function buildRegistryTakeoverTx({ cov, terms, state, name, height, newOw
{ lockingBytecode: terms.fundLock, valueSatoshis: quote.fundSats }, { lockingBytecode: terms.fundLock, valueSatoshis: quote.fundSats },
...(quote.ownerSats > 0n ? [{ lockingBytecode: pkhLock(state.ownerPkh), valueSatoshis: quote.ownerSats }] : []), ...(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 }; return { ...built, state: next, quote };
} }
/** Release: the certificate is destroyed. At most four outputs, none carrying it. */ /** 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"); if (extra.length > 3) throw new Error("a release may have at most four outputs in total");
const redeem = registryRedeem(terms, state); const redeem = registryRedeem(terms, state);
// Output 0 must exist and must not carry the certificate: a dust output takes that slot. // Output 0 must exist and must not carry the certificate: a dust output takes that slot.

View file

@ -62,6 +62,18 @@ export interface BnsNetwork {
export function onlyPayload(vouts: any[]): any | null; export function onlyPayload(vouts: any[]): any | null;
/** REG/TREG: is `mint` the only token of its category there is or can ever be? */ /** REG/TREG: is `mint` the only token of its category there is or can ever be? */
export function soleCertificate(tx: any, mint: any): boolean; 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<T extends Map<string, any>>(names: T, height: number, network?: any): T;
/** REL: does `tx` burn `entry`'s certificate, spending it from a beacon transaction in `txs`? */ /** 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<string, any>): boolean; export function burnsCertificate(tx: any, entry: { category: string }, txs: Map<string, any>): boolean;
/** One-block gap after a release. `releasedAt` undefined = never released. */ /** One-block gap after a release. `releasedAt` undefined = never released. */

View file

@ -97,6 +97,27 @@ export const NETWORKS = {
scripthash: "fa14c5fd7a22bd1856b44168823a1ab3ff50961c917dafbcba749aeaa7b26646", 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 <address> --steward <pubkey>
// 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: { mainnet: {
id: "mainnet", id: "mainnet",
@ -137,6 +158,8 @@ export const NETWORKS = {
// NOT PINNED: the blocklist beacon is one of the addresses reserved before // NOT PINNED: the blocklist beacon is one of the addresses reserved before
// the mainnet beacon goes live (ROADMAP-MAINNET.md §6). // the mainnet beacon goes live (ROADMAP-MAINNET.md §6).
blocklists: [], blocklists: [],
// NOT PINNED: needs the fund's and the steward's mainnet keys.
covenant: null,
}, },
}; };
export const DEFAULT_NETWORK_ID = "chipnet"; export const DEFAULT_NETWORK_ID = "chipnet";
@ -587,7 +610,7 @@ export function mayReRegister(releasedAt, height) {
return !(height > 0) || height > releasedAt; 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 net = requireBeacons(network);
const el = client ?? (await connectAny(electrum, WS, { directIP })); const el = client ?? (await connectAny(electrum, WS, { directIP }));
try { 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. // Updated in the UPD branch below on every ownership-touching tx.
owner: ownerFromVout(mint), owner: ownerFromVout(mint),
}); });
// registered straight into the registry covenant?
admitCovenantState(names.get(key), payload, mint, tx, height, net);
released.delete(key); released.delete(key);
} else if (payload.o === "REL") { } else if (payload.o === "REL") {
const entry = names.get(key); 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. // manifest verifier reads the CURRENT holder, not the mint address.
const proving = vouts.find((v) => v.tokenData && v.tokenData.category === entry.category); const proving = vouts.find((v) => v.tokenData && v.tokenData.category === entry.category);
if (!proving) continue; 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.records = payload.r ?? {};
entry.updatedTxid = tx_hash; 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 // Attached rather than returned separately so every existing caller keeps
// working. A caller that cares (the registrar, before taking money) can ask // working. A caller that cares (the registrar, before taking money) can ask
// whether the servers agreed on what exists. // 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 // Signature deliberately mirrors buildIndex so it's a drop-in for callers
// that already had one (Theseus's ensureIndex warm-start path). // 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)) { if (!snapshot || !Array.isArray(snapshot.history)) {
throw new Error("buildIndexFromSnapshot: snapshot with `history` array required"); throw new Error("buildIndexFromSnapshot: snapshot with `history` array required");
} }
@ -721,6 +758,7 @@ export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap =
// free of surprise dependencies. // free of surprise dependencies.
throw new Error("buildIndexFromSnapshot: enforceTld=true requires an explicit tldMap"); 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 txMap = snapshot.txs instanceof Map ? snapshot.txs : new Map(Object.entries(snapshot.txs || {}));
const history = [...snapshot.history].sort(byHeightThenTxid); const history = [...snapshot.history].sort(byHeightThenTxid);
const names = new Map(); const names = new Map();
@ -746,6 +784,8 @@ export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap =
txid: tx_hash, height, updatedTxid: null, txid: tx_hash, height, updatedTxid: null,
owner: ownerFromVout(mint), owner: ownerFromVout(mint),
}); });
// registered straight into the registry covenant?
admitCovenantState(names.get(key), payload, mint, tx, height, net);
released.delete(key); released.delete(key);
} else if (payload.o === "REL") { } else if (payload.o === "REL") {
const entry = names.get(key); const entry = names.get(key);
@ -758,11 +798,14 @@ export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap =
if (!entry) continue; if (!entry) continue;
const proving = vouts.find((v) => v.tokenData && v.tokenData.category === entry.category); const proving = vouts.find((v) => v.tokenData && v.tokenData.category === entry.category);
if (!proving) continue; 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.records = payload.r ?? {};
entry.updatedTxid = tx_hash; 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 }); Object.defineProperty(names, "released", { value: released, enumerable: false });
return names; return names;
} }
@ -861,6 +904,131 @@ export function addressToLockHex(address) {
export function addressToScripthash(address) { export function addressToScripthash(address) {
return bytesToHex(sha256(hexToBytes(addressToLockHex(address))).reverse()); 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. // 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"]); export const BLOCK_REASONS = new Set(["phishing", "impersonation", "malware", "scam-financial", "csam", "stolen"]);

View file

@ -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"]);
});