The CLI resolver reduces the beacon history on its own, so a rule that lives only in resolver-web.js would be a consensus split between the tools that register names and the clients that resolve them. release.test.mjs feeds the same synthetic histories to both reducers and requires identical results: valid and invalid burns, the unanchored certificate, same-block ordering, the one-block gap in and out of the mempool, and that nothing of a released registration carries over.
409 lines
17 KiB
JavaScript
409 lines
17 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";
|
|
|
|
// ---------- 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)
|
|
// KEEP IN LOCKSTEP with resolver-web.js parseOpReturnHex — memory rule.
|
|
if (KNOWN_OPS.has(p.o) && typeof p.n === "string") return p;
|
|
} catch { /* not ours */ }
|
|
return null;
|
|
}
|
|
|
|
// 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"]);
|
|
|
|
// ---------- 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);
|
|
let payload = null;
|
|
for (const vout of tx.vout) payload = parseOpReturn(vout.scriptPubKey?.hex) ?? payload;
|
|
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;
|
|
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 } = {}) {
|
|
// 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);
|
|
|
|
let payload = null;
|
|
for (const vout of tx.vout) {
|
|
payload = parseOpReturn(vout.scriptPubKey?.hex) ?? payload;
|
|
}
|
|
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;
|
|
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);
|
|
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 proves = tx.vout.some((v) => v.tokenData?.category === entry.category);
|
|
if (!proves) continue;
|
|
entry.records = payload.r ?? {};
|
|
entry.updatedTxid = tx_hash;
|
|
}
|
|
}
|
|
Object.defineProperty(names, "released", { value: released, enumerable: false });
|
|
return names;
|
|
}
|
|
|
|
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 };
|
|
}
|