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:
parent
a2e5ebffb4
commit
9404c024b7
10 changed files with 739 additions and 19 deletions
|
|
@ -471,6 +471,7 @@ function stripHtmlExt(html) {
|
|||
function transformHtml(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>`;
|
||||
|
||||
// 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 } : {}),
|
||||
}));
|
||||
|
|
|
|||
75
src/generate-covenant-table.mjs
Normal file
75
src/generate-covenant-table.mjs
Normal 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"));
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) } }; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)`);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
12
src/lib/resolver-web.d.ts
vendored
12
src/lib/resolver-web.d.ts
vendored
|
|
@ -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<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`? */
|
||||
export function burnsCertificate(tx: any, entry: { category: string }, txs: Map<string, any>): boolean;
|
||||
/** One-block gap after a release. `releasedAt` undefined = never released. */
|
||||
|
|
|
|||
|
|
@ -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 <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: {
|
||||
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"]);
|
||||
|
|
|
|||
382
test/covenant-names.test.mjs
Normal file
382
test/covenant-names.test.mjs
Normal 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"]);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue