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