Parallel session was right — the previously-shipped f94834b5 pre-dated:
1. the ASCII em-dash sweep in setup/install.ps1 (Inno's [Run] uses
powershell.exe / PS 5.1; em-dashes in the file crashed the parser
under UAC, so the installer copied files but never registered the
scheduled task or NRPT rule)
2. the multi-TLD NRPT expansion (added .p2p .bit .nav alongside .bch)
3. the VPS-primary electrum server list — resolver-web.js now dials
wss://coinspectrum.duckdns.org:50011 first (Silent Mode's own
BCHN-backed BNS-only indexer), public servers as fallback
Result: a real user installing 3438d558 on a fresh Windows box now gets
a working system-wide resolver with no manual steps.
Deployed to dl.silentmode.st and mirrored to Sia bns/silentmode/.
367 lines
16 KiB
JavaScript
367 lines
16 KiB
JavaScript
// 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);
|
|
}
|