argus/src/lib/resolver-web.js

368 lines
16 KiB
JavaScript
Raw Normal View History

// Portable BNS resolver — the shared keystone for all clients (browser
// extension, mobile webview, Electron). ZERO dependencies: only a WebSocket
// (native in browsers; inject Node's `ws`). Reads names straight from the
// Bitcoin Cash chain via a chipnet electrum (Fulcrum) server, using verbose
// transactions so CashToken data arrives as plain JSON — no tx-decoding lib.
//
// Contract (identical semantics to Argus/src/lib/bns.js):
// buildIndex() -> Map<name, Entry>
// resolveName("x.bch") -> Entry | null
// normalizeName(input) -> string (throws on invalid)
export const BEACON = "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl";
// electrum scripthash of BEACON (sha256(lockingBytecode) reversed, hex).
// Constant — the beacon never changes.
export const BEACON_SCRIPTHASH =
"e1cdbba719436f49acb4bf245b44df875c6d6b405eda46a9d3e736d138102f3b";
export const PREFIX = "BNS1";
export const TLD = ".bch";
export const DEFAULT_TLD = "bch";
const LABEL_RE = /^[a-z0-9-]{1,32}$/; // the registered label
const TLD_RE = /^[a-z0-9-]{1,16}$/; // the TLD suffix
// an on-chain name: "<label>" (legacy = .bch) or "<label>.<tld>"
const RAWNAME_RE = /^[a-z0-9-]{1,32}(\.[a-z0-9-]{1,16})?$/;
// Canonical index key for an on-chain name: always "<label>.<tld>".
// Legacy bare names (no dot) are treated as .bch for backward compatibility.
function canonicalKey(raw) {
return raw.includes(".") ? raw : raw + "." + DEFAULT_TLD;
}
// Each server carries a hostname `url` (used everywhere; TLS "just works") and an
// optional pinned `ip`. DNS-independent clients (Theseus desktop, via directIP)
// dial the ip directly when the hostname can't be resolved — so a dead system
// resolver no longer stops Theseus from reaching the chain. The ip is only a
// best-effort fallback: when DNS works the fresh hostname is always used, and the
// authoritative, updatable server list lives on-chain (see ELECTRUM_LIST_NAME).
export const CHIPNET_ELECTRUM = [
// Silent Mode's own BCHN-backed BNS-only indexer (Argus/src/indexer/bns-indexer.js).
// Tried first so we prefer our own infrastructure; the public servers below
// remain as fallbacks and cross-checks against a single-server view.
{ url: "wss://coinspectrum.duckdns.org:50011", ip: "195.184.247.106" },
{ url: "wss://chipnet.bch.ninja:50004", ip: "135.148.236.244" },
{ url: "wss://chipnet.imaginary.cash:50004", ip: "66.29.152.199" },
{ url: "wss://tbch4.loping.net:62002", ip: "183.89.202.207" },
];
// A well-known BCNR name whose record carries the current electrum server pool.
// Resolved AFTER bootstrap (you need a server to read it) to refresh the list
// without shipping a new build — the decentralized, ICANN-free source of truth.
export const ELECTRUM_LIST_NAME = "electrum.silentmode.bch";
const hexToBytes = (h) => {
const a = new Uint8Array(h.length / 2);
for (let i = 0; i < a.length; i++) a[i] = parseInt(h.substr(i * 2, 2), 16);
return a;
};
const strToHex = (s) =>
[...new TextEncoder().encode(s)].map((x) => x.toString(16).padStart(2, "0")).join("");
// Parse a full address ([<sub>.]<label>.<tld> or bare <label>) to the canonical
// registered-name key "<label>.<tld>". The rightmost label is the TLD (registry).
export function normalizeName(input) {
const s = String(input).toLowerCase().trim().replace(/^[a-z]+:\/\//, "").split("/")[0].replace(/\.$/, "");
const parts = s.split(".").filter(Boolean);
if (parts.length === 0) throw new Error(`invalid name "${input}"`);
let label, tld;
if (parts.length === 1) { label = parts[0]; tld = DEFAULT_TLD; }
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 `${label}.${tld}`;
}
// ---- electrum over a WebSocket (JSON-RPC; Fulcrum-WS framing tolerant) ----
class Electrum {
constructor(url, WebSocketImpl, options) {
this.url = url;
this.WS = WebSocketImpl || globalThis.WebSocket;
this.options = options; // Node `ws` options (e.g. { servername }); omitted in browsers
this.id = 0;
this.pending = new Map();
this.buf = "";
}
connect() {
return new Promise((resolve, reject) => {
// Only pass an options arg when we have one (Node `ws`). In a browser the
// 2nd arg is `protocols`, so we must never pass options there — the ip path
// that sets options is gated behind directIP, which browsers don't enable.
const ws = this.options ? new this.WS(this.url, this.options) : new this.WS(this.url);
this.ws = ws;
const onOpen = async () => {
try { await this.call("server.version", ["bns-web", "1.4.2"]); resolve(this); }
catch (e) { reject(e); }
};
const onErr = (e) => reject(new Error("electrum ws error: " + (e?.message || this.url)));
const onMsg = (ev) => this._onData(typeof ev.data === "string" ? ev.data : ev.data.toString());
if (ws.addEventListener) {
ws.addEventListener("open", onOpen);
ws.addEventListener("error", onErr);
ws.addEventListener("message", onMsg);
} else { ws.onopen = onOpen; ws.onerror = onErr; ws.onmessage = onMsg; }
});
}
_handle(msg) {
const p = this.pending.get(msg.id);
if (!p) return;
this.pending.delete(msg.id);
msg.error ? p.reject(new Error(JSON.stringify(msg.error))) : p.resolve(msg.result);
}
_onData(chunk) {
this.buf += chunk;
let nl;
while ((nl = this.buf.indexOf("\n")) >= 0) {
const line = this.buf.slice(0, nl).trim();
this.buf = this.buf.slice(nl + 1);
if (line) { try { this._handle(JSON.parse(line)); } catch {} }
}
const rest = this.buf.trim();
if (rest) { try { const m = JSON.parse(rest); this._handle(m); this.buf = ""; } catch {} }
}
call(method, params = []) {
const id = ++this.id;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }) + "\n");
setTimeout(() => {
if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`electrum timeout: ${method}`)); }
}, 20000);
});
}
close() { try { this.ws.close(); } catch {} }
}
// A server is either a bare "wss://host:port" string or { url, ip }.
const asServer = (s) => (typeof s === "string" ? { url: s } : s);
async function connectAny(servers, WebSocketImpl, { directIP = false } = {}) {
let lastErr;
for (const raw of servers) {
const s = asServer(raw);
// 1) By hostname — works in every environment, needs working DNS.
try { return await new Electrum(s.url, WebSocketImpl).connect(); }
catch (e) { lastErr = e; }
// 2) DNS-independent fallback: dial the pinned IP directly, presenting the
// hostname as TLS SNI so the certificate still validates. Node/Electron
// only (browser WebSocket can't set SNI), enabled by directIP.
if (directIP && s.ip) {
try {
const u = new URL(s.url);
const ipUrl = `${u.protocol}//${s.ip}:${u.port}`;
return await new Electrum(ipUrl, WebSocketImpl, { servername: u.hostname }).connect();
} catch (e) { lastErr = e; }
}
}
throw lastErr ?? new Error("no electrum server reachable");
}
// ---- OP_RETURN payload parsing (from scriptPubKey hex) ----
function parseOpReturnHex(hex) {
if (!hex || !hex.startsWith("6a")) return null; // OP_RETURN
const s = hexToBytes(hex);
let i = 1;
const pushes = [];
while (i < s.length) {
let op = s[i++], len;
if (op <= 0x4b) len = op;
else if (op === 0x4c) len = s[i++];
else if (op === 0x4d) { len = s[i] | (s[i + 1] << 8); i += 2; }
else return null;
pushes.push(s.slice(i, i + len)); i += len;
}
const data = new TextDecoder().decode(pushes[0] ?? new Uint8Array());
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 {}
return null;
}
// ---- ordering ----
// Baseline order: block height, then txid. Unconfirmed (height <= 0) last.
// Electrum reports height 0 for a mempool tx whose parents are all confirmed
// and -1 for one with unconfirmed parents; both are simply "not in a block".
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);
}
// Causal order. The (height, txid) baseline is arbitrary within a block and
// within the mempool, which breaks the most ordinary sequence there is:
// register a name and immediately point it somewhere. If the UPD's txid sorts
// below its REG's, the UPD is evaluated against an index that does not contain
// the name yet and is silently dropped — permanently, if both land in the same
// block. So: keep the baseline order, but never emit a transaction before one
// it spends from. Independent transactions keep the deterministic txid tiebreak,
// which is what "first valid REG wins" relies on.
//
// O(n^2) over the beacon history, which is small and read once per index build.
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]; // already in baseline order
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;
}
// ---- the resolver ----
// Pass `client` to reuse an already-open Electrum connection (the registrar
// does this); in that case the caller owns it and we leave it open.
export async function buildIndex({ electrum = CHIPNET_ELECTRUM, WebSocket: WS, client, directIP = false } = {}) {
const el = client ?? (await connectAny(electrum, WS, { directIP }));
try {
// With a pool, take the UNION of every server's beacon history. A single
// server can silently omit a registration, and because "first valid REG
// wins" that makes a taken name look free — the buyer pays for a duplicate
// every other resolver rejects. Union, not quorum: omission is the attack,
// so more history is more truth, and every entry still has to pass the
// validity rules below before it can claim a name.
let integrity = null;
let history;
if (typeof el.unionHistory === "function") {
const merged = await el.unionHistory(BEACON_SCRIPTHASH);
history = merged.history;
integrity = {
servers: merged.servers,
unreachable: merged.unreachable,
disagreements: merged.disagreements,
};
} else {
history = await el.call("blockchain.scripthash.get_history", [BEACON_SCRIPTHASH]);
}
history.sort(byHeightThenTxid);
// Fetch first, then order. An UPD is only valid once its REG has been seen,
// so a transaction must never be indexed before one it spends from.
const txs = new Map();
for (const { tx_hash } of history) {
try { txs.set(tx_hash, await el.call("blockchain.transaction.get", [tx_hash, true])); }
catch { /* unreadable tx — skip it entirely */ }
}
const names = 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 vouts = tx.vout || [];
let payload = null;
for (const v of vouts) payload = parseOpReturnHex(v.scriptPubKey?.hex) ?? payload;
if (!payload) continue;
const rawName = payload.n;
if (!RAWNAME_RE.test(rawName)) continue;
const key = canonicalKey(rawName); // "<label>.<tld>"
if (payload.o === "REG") {
if (names.has(key)) continue; // first valid REG wins
const commitHex = strToHex(rawName); // commitment matches the raw on-chain name
const mint = vouts.find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex);
if (!mint) continue;
names.set(key, {
name: key, category: mint.tokenData.category, records: payload.r ?? {},
txid: tx_hash, height, updatedTxid: null,
});
} else if (payload.o === "UPD") {
const entry = names.get(key);
if (!entry) continue;
const proves = vouts.some((v) => v.tokenData && v.tokenData.category === entry.category);
if (!proves) continue;
entry.records = payload.r ?? {};
entry.updatedTxid = tx_hash;
}
}
// 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.
if (integrity) Object.defineProperty(names, "integrity", { value: integrity, enumerable: false });
return names;
} finally {
if (!client) el.close();
}
}
export async function resolveName(input, opts = {}) {
const name = normalizeName(input);
const index = await buildIndex(opts);
return index.get(name) ?? null;
}
// ---- on-chain electrum discovery (step 2) --------------------------------
// The name ELECTRUM_LIST_NAME carries the current server pool in an "el" record:
// a space-separated list of tokens, each "wss://host:port" or "wss://host:port|ip"
// (the pinned ip is optional, and lets a discovered server also be DNS-independent).
// Read AFTER bootstrap and used to refresh the pool without shipping a new build.
export function parseElectrumRecord(records) {
const raw = records && records.el;
if (!raw || typeof raw !== "string") return [];
return raw.split(/\s+/).filter(Boolean).map((tok) => {
const [url, ip] = tok.split("|");
return ip ? { url, ip } : { url };
}).filter((s) => /^wss?:\/\//i.test(s.url));
}
// Pull the server pool out of an already-built index (no extra round-trip).
export function getElectrumServers(index) {
const entry = index && typeof index.get === "function" ? index.get(ELECTRUM_LIST_NAME) : null;
return entry ? parseElectrumRecord(entry.records) : [];
}
// Convenience: connect, read the on-chain list, return it (empty if unpublished).
export async function fetchElectrumServers(opts = {}) {
return getElectrumServers(await buildIndex(opts));
}
// ---- on-chain TLD registry (root TLD certificate) ------------------------
// The name ROOT_TLD_NAME carries the activated BCNR-native TLD set in a "tlds"
// record: a space-separated list of TLD suffixes, e.g. "bch p2p deviant sia".
// This is BCNR's IANA-root equivalent — the authoritative list of TLDs the
// network considers native, discovered from the chain so adding a TLD is one
// on-chain UPDATE (no software update, no reinstall). See
// SilentMode/Argus/DESIGN-collision-modes.md for how clients use it (collision
// detection, per-TLD defaults, preempting the soft "Open with…" prompt).
export const ROOT_TLD_NAME = "tlds.bch";
// Shipped bootstrap set — used before the on-chain list is fetched, and as a
// last-resort fallback if the root cert is briefly unreachable. Deliberately
// small: just the TLD you're guaranteed to have.
export const BOOTSTRAP_TLDS = ["bch"];
export function parseTldsRecord(records) {
const raw = records && records.tlds;
if (!raw || typeof raw !== "string") return [];
return raw.split(/\s+/).filter(Boolean)
.map((t) => t.toLowerCase())
.filter((t) => TLD_RE.test(t));
}
// Pull the activated TLDs out of an already-built index (no extra round-trip).
export function getBcnrTlds(index) {
const entry = index && typeof index.get === "function" ? index.get(ROOT_TLD_NAME) : null;
const list = entry ? parseTldsRecord(entry.records) : [];
return list.length ? list : BOOTSTRAP_TLDS.slice();
}
// Convenience: build the index, return the activated TLD set. Empty root
// certificate or unreachable chain -> BOOTSTRAP_TLDS.
export async function fetchBcnrTlds(opts = {}) {
return getBcnrTlds(await buildIndex(opts));
}
// Is this TLD activated on-chain? Case-insensitive.
export function isBcnrNativeTld(tldOrList, tld) {
const list = Array.isArray(tldOrList) ? tldOrList : [tldOrList];
const t = String(tld || "").toLowerCase();
return list.includes(t);
}