argus/src/lib/bns.js

202 lines
7.6 KiB
JavaScript
Raw Normal View History

// BNS1 core: payload codec, chain indexer/resolver, and tx builders.
import { OpReturnData, TokenSendRequest, NFTCapability } from "mainnet-js";
import { 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));
if ((p.o === "REG" || p.o === "UPD") && typeof p.n === "string") return p;
} catch { /* not ours */ }
return null;
}
// ---------- 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;
}
// Builds { name -> { name, category, records, txid, height, updatedTxid } }
export async function buildIndex(provider) {
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();
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
// 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);
} 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;
}
}
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 };
}