202 lines
7.6 KiB
JavaScript
202 lines
7.6 KiB
JavaScript
|
|
// 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 };
|
||
|
|
}
|