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.
444 lines
19 KiB
JavaScript
444 lines
19 KiB
JavaScript
// 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, admitCovenantState, applyLifecycle, historyHeight } from "./resolver-web.js";
|
|
|
|
// ---------- payload codec ----------
|
|
|
|
const LABEL_RE = /^[a-z0-9-]{1,32}$/;
|
|
const TLD_RE = /^[a-z0-9-]{1,16}$/;
|
|
const RAWNAME_RE = /^[a-z0-9-]{1,32}(\.[a-z0-9-]{1,16})?$/;
|
|
// On-chain name for a registration: bare "<label>" for .bch (backward compat),
|
|
// or "<label>.<tld>" for any other TLD. The rightmost label is the registry.
|
|
export function normalizeName(input) {
|
|
const parts = String(input).toLowerCase().trim().replace(/\.$/, "").split(".").filter(Boolean);
|
|
let label, tld;
|
|
if (parts.length === 1) { label = parts[0]; tld = "bch"; }
|
|
else { tld = parts[parts.length - 1]; label = parts[parts.length - 2]; }
|
|
if (!LABEL_RE.test(label) || !TLD_RE.test(tld)) {
|
|
throw new Error(`invalid name "${input}" (allowed: <label>.<tld>, [a-z0-9-])`);
|
|
}
|
|
return tld === "bch" ? label : `${label}.${tld}`;
|
|
}
|
|
|
|
export function encodePayload(op, name, records) {
|
|
const json = JSON.stringify({ o: op, n: name, r: records });
|
|
const buf = Buffer.from(PREFIX + json, "utf8");
|
|
if (buf.length > 200) throw new Error(`records too large (${buf.length} > 200 bytes)`);
|
|
return buf;
|
|
}
|
|
|
|
// Parse a raw output script hex; return payload object or null.
|
|
export function parseOpReturn(scriptHex) {
|
|
if (!scriptHex?.startsWith("6a")) return null; // OP_RETURN
|
|
let i = 2;
|
|
const pushes = [];
|
|
while (i < scriptHex.length) {
|
|
let op = parseInt(scriptHex.slice(i, i + 2), 16);
|
|
i += 2;
|
|
let len;
|
|
if (op <= 0x4b) len = op;
|
|
else if (op === 0x4c) { len = parseInt(scriptHex.slice(i, i + 2), 16); i += 2; }
|
|
else if (op === 0x4d) { len = parseInt(scriptHex.slice(i + 2, i + 4) + scriptHex.slice(i, i + 2), 16); i += 4; }
|
|
else return null;
|
|
pushes.push(Buffer.from(scriptHex.slice(i, i + len * 2), "hex"));
|
|
i += len * 2;
|
|
}
|
|
const data = pushes[0]?.toString("utf8") ?? "";
|
|
if (!data.startsWith(PREFIX)) return null;
|
|
try {
|
|
const p = JSON.parse(data.slice(PREFIX.length));
|
|
// Recognised opcodes:
|
|
// REG / UPD — name registrations & updates on the name beacon
|
|
// TREG / TUPD — TLD registrations & updates on the TLD beacon
|
|
// (Decentralized.DNS/DESIGN-tld-registry.md §3)
|
|
// RPT / BLK / UBK — blocklist report, block and unblock
|
|
// (PROTOCOL-ADDENDUM-blocklist.md); an RPT's `n` is an array.
|
|
// KEEP IN LOCKSTEP with resolver-web.js parseOpReturnHex — memory rule.
|
|
if (!KNOWN_OPS.has(p.o)) return null;
|
|
if (typeof p.n === "string") return p;
|
|
if (p.o === "RPT" && Array.isArray(p.n) && p.n.every((x) => typeof x === "string")) return p;
|
|
} catch { /* not ours */ }
|
|
return null;
|
|
}
|
|
|
|
// One message per transaction: more than one parseable BNS payload means none.
|
|
// KEEP IN LOCKSTEP with resolver-web.js onlyPayload (the reasoning is there).
|
|
export function onlyPayload(vouts) {
|
|
let found = null;
|
|
for (const v of vouts || []) {
|
|
const p = parseOpReturn(v?.scriptPubKey?.hex);
|
|
if (!p) continue;
|
|
if (found) return null;
|
|
found = p;
|
|
}
|
|
return found;
|
|
}
|
|
|
|
// The set of payload opcodes this codec recognises. New opcodes go here AND in
|
|
// resolver-web.js — divergence is a consensus split.
|
|
export const KNOWN_OPS = new Set(["REG", "UPD", "REL", "TREG", "TUPD", "RPT", "BLK", "UBK"]);
|
|
|
|
// ---------- indexer / resolver ----------
|
|
|
|
// (height, txid) order; unconfirmed (height<=0) last.
|
|
function byHeightThenTxid(a, b) {
|
|
const ha = a.height > 0 ? a.height : Infinity;
|
|
const hb = b.height > 0 ? b.height : Infinity;
|
|
return ha - hb || a.tx_hash.localeCompare(b.tx_hash);
|
|
}
|
|
|
|
// Never index a transaction before one it spends from — otherwise an UPD that
|
|
// happens to sort below its own REG is evaluated against an index that does not
|
|
// yet contain the name, and is dropped. Keeps the txid tiebreak for independent
|
|
// transactions. Mirrors resolver-web.js `orderByDependency` — change both.
|
|
function orderByDependency(history, txs) {
|
|
const inSet = new Set(history.map((h) => h.tx_hash));
|
|
const deps = new Map(
|
|
history.map((h) => {
|
|
const spent = new Set();
|
|
for (const vin of txs.get(h.tx_hash)?.vin ?? []) {
|
|
if (vin.txid && vin.txid !== h.tx_hash && inSet.has(vin.txid)) spent.add(vin.txid);
|
|
}
|
|
return [h.tx_hash, spent];
|
|
}),
|
|
);
|
|
const emitted = new Set();
|
|
const ordered = [];
|
|
const pending = [...history];
|
|
while (pending.length) {
|
|
let i = pending.findIndex((h) => [...deps.get(h.tx_hash)].every((d) => emitted.has(d)));
|
|
if (i < 0) i = 0; // unreachable on a real chain; guarantees termination
|
|
const [next] = pending.splice(i, 1);
|
|
emitted.add(next.tx_hash);
|
|
ordered.push(next);
|
|
}
|
|
return ordered;
|
|
}
|
|
|
|
// Extract the TLD of a raw on-chain name: "hello" -> "bch" (legacy .bch),
|
|
// "foo.x" -> "x". Same rule as normalizeName's TLD inference.
|
|
function tldOf(rawName) {
|
|
const i = rawName.lastIndexOf(".");
|
|
return i < 0 ? "bch" : rawName.slice(i + 1);
|
|
}
|
|
|
|
// Walk the TLD-beacon history to a Map<tld, {category, regHeight, regTxid}>.
|
|
// Same first-TREG-wins / commitment-must-match rules as buildIndex uses for
|
|
// names — kept inline (rather than importing tld-index.js) to avoid the
|
|
// circular import bns.js -> tld-index.js -> bns.js. Any change here must
|
|
// match the equivalent block in resolver-web.js (memory rule).
|
|
async function buildTldMap(provider) {
|
|
const history = await provider.getHistory(TLD_BEACON);
|
|
history.sort(byHeightThenTxid);
|
|
const txs = new Map();
|
|
for (const { tx_hash } of history) {
|
|
try { txs.set(tx_hash, await provider.getRawTransactionObject(tx_hash)); }
|
|
catch { /* skip */ }
|
|
}
|
|
const tlds = new Map();
|
|
for (const { tx_hash, height } of orderByDependency(history.filter((h) => txs.has(h.tx_hash)), txs)) {
|
|
const tx = txs.get(tx_hash);
|
|
const payload = onlyPayload(tx.vout);
|
|
if (!payload) continue;
|
|
if (typeof payload.n !== "string" || !/^[a-z0-9-]{1,16}$/.test(payload.n)) continue;
|
|
const tld = payload.n;
|
|
if (payload.o === "TREG") {
|
|
if (tlds.has(tld)) continue; // first valid TREG wins
|
|
const commitment = Buffer.from(tld, "utf8").toString("hex");
|
|
const mint = tx.vout.find((v) => v.tokenData?.nft?.commitment === commitment);
|
|
if (!mint) continue;
|
|
if (!soleCertificate(tx, mint)) continue; // one definition, in resolver-web.js
|
|
tlds.set(tld, {
|
|
category: mint.tokenData.category, regHeight: height, regTxid: tx_hash,
|
|
records: payload.r ?? {},
|
|
updates: [{ height, txid: tx_hash, records: payload.r ?? {} }],
|
|
});
|
|
} else if (payload.o === "TUPD") {
|
|
// Authoritative only if the tx re-issues the TLD NFT (same as tld-index.js).
|
|
const t = tlds.get(tld);
|
|
if (!t) continue;
|
|
if (!tx.vout.some((v) => v.tokenData && v.tokenData.category === t.category)) continue;
|
|
t.records = payload.r ?? {};
|
|
t.updates.push({ height, txid: tx_hash, records: t.records });
|
|
}
|
|
}
|
|
return tlds;
|
|
}
|
|
|
|
// Policy in force at `height` (0 = mempool → latest). KEEP IN LOCKSTEP with
|
|
// resolver-web.js tldRecordsAt / cosignRequired / carriesTldCertificate.
|
|
export function tldRecordsAt(tldEntry, height) {
|
|
if (!tldEntry) return {};
|
|
const ups = tldEntry.updates || [];
|
|
if (!ups.length) return tldEntry.records || {};
|
|
let rec = {};
|
|
for (const u of ups) {
|
|
if (height > 0 && (u.height <= 0 || u.height > height)) continue;
|
|
rec = u.records || {};
|
|
}
|
|
return rec;
|
|
}
|
|
// Co-sign rule: a REG under a TLD that is private (`hidden: 1`) or has
|
|
// `policy: "cosign"` at that height is admitted only if the same transaction
|
|
// carries the TLD's certificate in an output — proof the TLD owner signed.
|
|
export function cosignRequired(tldEntry, height) {
|
|
const r = tldRecordsAt(tldEntry, height);
|
|
return r.policy === "cosign" || r.hidden === 1 || r.hidden === true || r.hidden === "1";
|
|
}
|
|
export function carriesTldCertificate(vouts, tldEntry, tld) {
|
|
const commitment = Buffer.from(tld, "utf8").toString("hex");
|
|
return (vouts || []).some((v) => v.tokenData && v.tokenData.category === tldEntry.category && v.tokenData.nft?.commitment === commitment);
|
|
}
|
|
|
|
// The gate the design specifies (Decentralized.DNS/DESIGN-tld-registry.md §4):
|
|
// a name REG is admitted iff its TLD has a live TLD certificate on the TLD
|
|
// beacon at a height ≤ the REG's height. Mempool-friendly: if either side is
|
|
// unconfirmed, admit — the beacon is the source of truth for "does this TLD
|
|
// exist now", and time-ordering across mempool entries is fuzzy anyway.
|
|
function tldAdmits(tld, regHeight, tldMap) {
|
|
const t = tldMap.get(tld);
|
|
if (!t) return false;
|
|
if (t.regHeight > 0 && regHeight > 0) return t.regHeight <= regHeight;
|
|
return true;
|
|
}
|
|
|
|
// Builds { name -> { name, category, records, txid, height, updatedTxid } }.
|
|
//
|
|
// `enforceTld: true` turns the TLD registry into a consensus gate — see §4 of
|
|
// the design doc. Off by default while the mainnet migration and grandfathering
|
|
// story lands; a chipnet deployment can flip it on immediately to prove the
|
|
// path end-to-end.
|
|
// ---- REL: voluntary release (Decentralized.DNS/PROTOCOL-ADDENDUM-release.md) ----
|
|
// KEEP IN LOCKSTEP with resolver-web.js burnsCertificate / mayReRegister —
|
|
// Argus/test/release.test.mjs runs both over the same fixtures.
|
|
function spentOutput(vin, txs) {
|
|
const prev = vin && vin.txid ? txs.get(vin.txid) : null;
|
|
if (!prev || !Array.isArray(prev.vout)) return null;
|
|
return prev.vout.find((o) => o && o.n === vin.vout) ?? prev.vout[vin.vout] ?? null;
|
|
}
|
|
// Valid release: no output carries the name's category, and one input spends
|
|
// an output of an earlier beacon transaction that carried it as an NFT.
|
|
export function burnsCertificate(tx, entry, txs) {
|
|
if (!tx || !entry || !entry.category) return false;
|
|
if ((tx.vout || []).some((v) => v.tokenData && v.tokenData.category === entry.category)) return false;
|
|
return (tx.vin || []).some((vin) => {
|
|
const out = spentOutput(vin, txs);
|
|
return !!(out && out.tokenData && out.tokenData.nft && out.tokenData.category === entry.category);
|
|
});
|
|
}
|
|
// One-block gap: a released name is registrable again only by a REG in a later
|
|
// block than a CONFIRMED release (an unconfirmed REG qualifies).
|
|
export function mayReRegister(releasedAt, height) {
|
|
if (releasedAt === undefined) return true;
|
|
if (!(releasedAt > 0)) return false;
|
|
return !(height > 0) || height > releasedAt;
|
|
}
|
|
|
|
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;
|
|
if (enforceTld || cosignGate) {
|
|
try { tldMap = await buildTldMap(provider); }
|
|
catch (e) { if (enforceTld) throw e; tldMap = null; }
|
|
}
|
|
|
|
const history = await provider.getHistory(BEACON);
|
|
history.sort(byHeightThenTxid);
|
|
|
|
const txs = new Map();
|
|
for (const { tx_hash } of history) {
|
|
try { txs.set(tx_hash, await provider.getRawTransactionObject(tx_hash)); }
|
|
catch { /* unreadable tx — skip it entirely */ }
|
|
}
|
|
|
|
const names = new Map();
|
|
const byCategory = new Map();
|
|
const released = new Map(); // name -> height of its release
|
|
|
|
for (const { tx_hash, height } of orderByDependency(history.filter((h) => txs.has(h.tx_hash)), txs)) {
|
|
const tx = txs.get(tx_hash);
|
|
|
|
const payload = onlyPayload(tx.vout);
|
|
if (!payload) continue;
|
|
|
|
const name = payload.n;
|
|
if (!RAWNAME_RE.test(name)) continue;
|
|
|
|
if (payload.o === "REG") {
|
|
if (names.has(name)) continue; // first valid REG wins
|
|
if (!mayReRegister(released.get(name), height)) continue; // one-block gap after a release
|
|
// TLD gate — see design §4 / tldAdmits above. Only applies to REG; UPDs
|
|
// are transitions on names already admitted, so no gate needed there.
|
|
if (enforceTld && !tldAdmits(tldOf(name), height, tldMap)) continue;
|
|
if (cosignGate && tldMap) {
|
|
const tldEntry = tldMap.get(tldOf(name));
|
|
if (tldEntry && cosignRequired(tldEntry, height) && !carriesTldCertificate(tx.vout, tldEntry, tldOf(name))) continue;
|
|
}
|
|
// the same tx must mint a certificate whose commitment is the name
|
|
const commitment = Buffer.from(name, "utf8").toString("hex");
|
|
const mint = tx.vout.find((v) => v.tokenData?.nft?.commitment === commitment);
|
|
if (!mint) continue;
|
|
// the certificate must be the only token of its category (resolver-web.js)
|
|
if (!soleCertificate(tx, mint)) continue;
|
|
const entry = {
|
|
name, category: mint.tokenData.category, records: payload.r ?? {},
|
|
txid: tx_hash, height, updatedTxid: null,
|
|
};
|
|
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);
|
|
if (!entry) continue;
|
|
if (!burnsCertificate(tx, entry, txs)) continue;
|
|
names.delete(name);
|
|
byCategory.delete(entry.category);
|
|
released.set(name, height);
|
|
} else if (payload.o === "UPD") {
|
|
const entry = names.get(name);
|
|
if (!entry) continue;
|
|
// ownership proof: some output carries the name's category
|
|
// (consensus: impossible unless an input held that category)
|
|
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;
|
|
}
|
|
|
|
// ---- blocklists (PROTOCOL-ADDENDUM-blocklist.md) ----
|
|
// The rules live in ONE place, resolver-web.js reduceBlocklist; this is only
|
|
// the mainnet-js-provider way of feeding it a list's history.
|
|
export async function buildBlocklist(provider, { address, source = null }) {
|
|
const history = await provider.getHistory(address);
|
|
const txs = new Map();
|
|
for (const { tx_hash } of history) {
|
|
try { txs.set(tx_hash, await provider.getRawTransactionObject(tx_hash)); }
|
|
catch { /* unreadable tx — skip it entirely */ }
|
|
}
|
|
return reduceBlocklist({ history, txs, address, source });
|
|
}
|
|
|
|
export async function resolveName(provider, input) {
|
|
const name = normalizeName(input);
|
|
const index = await buildIndex(provider);
|
|
return index.get(name) ?? null;
|
|
}
|
|
|
|
// ---------- tx builders ----------
|
|
|
|
// CashTokens genesis must spend an outpoint with index 0; self-send to make one if needed.
|
|
async function ensureGenesisInput(wallet) {
|
|
const utxos = await wallet.getUtxos();
|
|
if (utxos.some((u) => u.vout === 0 && !u.token)) return;
|
|
await wallet.send([{ cashaddr: wallet.cashaddr, value: 10000n, unit: "sat" }]);
|
|
}
|
|
|
|
export async function registerName(wallet, input, records) {
|
|
const name = normalizeName(input);
|
|
const index = await buildIndex(wallet.provider);
|
|
if (index.has(name)) throw new Error(`"${name}${'.bch'}" is already registered`);
|
|
await ensureGenesisInput(wallet);
|
|
const payload = encodePayload("REG", name, records);
|
|
const result = await wallet.tokenGenesis(
|
|
{
|
|
cashaddr: wallet.tokenaddr,
|
|
nft: {
|
|
capability: NFTCapability.none,
|
|
commitment: Buffer.from(name, "utf8").toString("hex"),
|
|
},
|
|
value: 1000n,
|
|
},
|
|
[
|
|
OpReturnData.fromUint8Array(payload),
|
|
{ cashaddr: BEACON, value: BEACON_DUST_SAT, unit: "sat" },
|
|
],
|
|
);
|
|
return { name, txid: result.txId, category: result.tokenIds?.[0] };
|
|
}
|
|
|
|
export async function updateName(wallet, input, records) {
|
|
const name = normalizeName(input);
|
|
const index = await buildIndex(wallet.provider);
|
|
const entry = index.get(name);
|
|
if (!entry) throw new Error(`"${name}.bch" is not registered`);
|
|
const payload = encodePayload("UPD", name, records);
|
|
const result = await wallet.send([
|
|
new TokenSendRequest({
|
|
cashaddr: wallet.tokenaddr,
|
|
category: entry.category,
|
|
nft: {
|
|
capability: NFTCapability.none,
|
|
commitment: Buffer.from(name, "utf8").toString("hex"),
|
|
},
|
|
}),
|
|
OpReturnData.fromUint8Array(payload),
|
|
{ cashaddr: BEACON, value: BEACON_DUST_SAT, unit: "sat" },
|
|
]);
|
|
return { name, txid: result.txId, category: entry.category };
|
|
}
|
|
|
|
// ---------- TLD certificate tx builders ----------
|
|
// Mirror registerName / updateName but for the per-TLD registry
|
|
// (Decentralized.DNS/DESIGN-tld-registry.md §3):
|
|
// • payload opcode is TREG/TUPD
|
|
// • the NFT commitment is the TLD label (e.g. "x") — not a name
|
|
// • dust flows to TLD_BEACON, not the name BEACON
|
|
// Ownership + spend rules are otherwise identical to the name path, so the
|
|
// signing / covenant / renewal work carries over unchanged.
|
|
|
|
export function normalizeTld(input) {
|
|
const tld = String(input).toLowerCase().trim().replace(/^\.+/, "").replace(/\.+$/, "");
|
|
if (!TLD_RE.test(tld)) throw new Error(`invalid TLD "${input}" (allowed: [a-z0-9-]{1,16})`);
|
|
return tld;
|
|
}
|
|
|
|
export async function registerTld(wallet, input, records = {}) {
|
|
const tld = normalizeTld(input);
|
|
await ensureGenesisInput(wallet);
|
|
const payload = encodePayload("TREG", tld, records);
|
|
const result = await wallet.tokenGenesis(
|
|
{
|
|
cashaddr: wallet.tokenaddr,
|
|
nft: {
|
|
capability: NFTCapability.none,
|
|
commitment: Buffer.from(tld, "utf8").toString("hex"),
|
|
},
|
|
value: 1000n,
|
|
},
|
|
[
|
|
OpReturnData.fromUint8Array(payload),
|
|
{ cashaddr: TLD_BEACON, value: BEACON_DUST_SAT, unit: "sat" },
|
|
],
|
|
);
|
|
return { tld, txid: result.txId, category: result.tokenIds?.[0] };
|
|
}
|
|
|
|
export async function updateTld(wallet, input, records, category) {
|
|
const tld = normalizeTld(input);
|
|
if (!category) throw new Error("updateTld requires the TLD certificate's category id");
|
|
const payload = encodePayload("TUPD", tld, records);
|
|
const result = await wallet.send([
|
|
new TokenSendRequest({
|
|
cashaddr: wallet.tokenaddr,
|
|
category,
|
|
nft: {
|
|
capability: NFTCapability.none,
|
|
commitment: Buffer.from(tld, "utf8").toString("hex"),
|
|
},
|
|
}),
|
|
OpReturnData.fromUint8Array(payload),
|
|
{ cashaddr: TLD_BEACON, value: BEACON_DUST_SAT, unit: "sat" },
|
|
]);
|
|
return { tld, txid: result.txId, category };
|
|
}
|