// BNS public gateway (the "remote relay", convenience tier). // Runs on the VPS. Resolves .bch from the chain (zero-dep resolver) and serves // the content over plain HTTPS so ANY device's browser can view .bch sites with // no install. Two URL forms: // path form: https://navigate.st/bns/.bch/... ( injected) // subdomain form: https://.bch.navigate.st/ (own origin, no ) // where dots in a multi-level name become dashes: coinspectrum.deviant.bch // -> coinspectrum-deviant.bch.navigate.st // // Honest disclosure (convenience tier, not the trustless one): the operator can // see which .bch sites you request. No logs — but that's trust. Install the // resolver or Theseus for trustless resolution; use Tor for IP privacy. import http from "http"; import https from "https"; import tls from "tls"; import { AwsClient } from "aws4fetch"; import { createHash } from "node:crypto"; import { gunzipSync } from "node:zlib"; // libauth is present so the next drop's signature-verify path is a // contained edit (no dep install between now and then). Only utf8ToBin // and sha256 are used today; the rest are named here so the imports // are visible from the top of the file. import { utf8ToBin, base64ToBin, sha256, ripemd160, secp256k1, encodeCashAddress, cashAddressToLockingBytecode, hexToBin, binToHex, } from "@bitauth/libauth"; import WebSocket from "ws"; // resolver-web.js lives at ../lib/, not alongside this file — the previous // `./resolver-web.js` import was broken (file was moved into lib/ but this // import wasn't updated). Fix while touching the file. import { resolveName, buildIndex, normalizeName, connectAny, CHIPNET_ELECTRUM, TLD_BEACON_SCRIPTHASH } from "./resolver-web.js"; import { pickRecord, isSubdomainOf } from "./record-picker.js"; import { buildTldSnapshot } from "./tld-index.js"; import { verifyListing } from "./market-tx.js"; import { verifyUsdListing, oracleMessage, signOracleMessage, oraclePubkey, usdListingLock } from "./market-usd.js"; import { readFileSync as readFileSyncOracle } from "node:fs"; // TLD-beacon snapshot cache. Rebuilds by walking TLD_BEACON via electrum, // verbose-fetching every tx, and passing history+txs to buildTldSnapshot. // This is the AUTHORITATIVE source for the per-TLD-NFT registry — the old // `tlds.bch` root NFT is legacy and no longer authoritative (see // Decentralized.DNS/DESIGN-tld-registry.md §9). Cached 30s. let _tldSnap = { at: 0, snap: null }; async function getTldSnapshot() { if (_tldSnap.snap && Date.now() - _tldSnap.at < 30000) return _tldSnap.snap; const BNS_INDEXER_URLS = /silentmode\.st\/electrum|coinspectrum\.duckdns\.org:50011/; const candidates = CHIPNET_ELECTRUM .map((s) => (typeof s === "string" ? { url: s } : s)) .filter((s) => !BNS_INDEXER_URLS.test(s.url)); let el; for (const s of candidates) { try { el = await connectAny([s], WebSocket); break; } catch { /* try next */ } } if (!el) throw new Error("no full electrum reachable"); try { const history = typeof el.unionHistory === "function" ? (await el.unionHistory(TLD_BEACON_SCRIPTHASH)).history : await el.call("blockchain.scripthash.get_history", [TLD_BEACON_SCRIPTHASH]); 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 — skip */ } } const snap = buildTldSnapshot({ history, txs }); _tldSnap = { at: Date.now(), snap }; return snap; } finally { try { el.close?.(); } catch {} } } // Full-index cache (rebuilt every 30s) for fast availability lookups. let _idx = { at: 0, index: null }; async function getIndex() { if (!_idx.index || Date.now() - _idx.at > 30000) { _idx = { at: Date.now(), index: await buildIndex({ WebSocket }) }; } return _idx.index; } const MOUNT = process.env.BNS_MOUNT || "/bns"; const PORT = Number(process.env.BNS_PORT || 8700); // Names hidden from /api/search/ results — direct navigation still works. const HIDDEN_FROM_SEARCH = new Set(["syskypo.de"]); // TLDs the operator has flagged "hidden" — kept off public /api/tlds // listings so name-search UIs stop offering them. On-chain registrations // under a hidden TLD still exist; this is a UX filter, not enforcement. // Persisted to a JSON file next to this script so it survives restarts. import { readFileSync as _readFile, writeFileSync as _writeFile, existsSync as _existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname as _dirname, join as _join } from "node:path"; const HIDDEN_TLDS_FILE = process.env.BNS_HIDDEN_TLDS_FILE || _join(_dirname(fileURLToPath(import.meta.url)), "hidden-tlds.json"); // Operator gate — POSTs to /api/tld-visibility require a matching bearer. // Set BNS_OPERATOR_TOKEN in the service env; if unset, the endpoint refuses // every write (safer than allowing anonymous writes to a public gateway). const OPERATOR_TOKEN = process.env.BNS_OPERATOR_TOKEN || null; function readHiddenTlds() { try { if (!_existsSync(HIDDEN_TLDS_FILE)) return new Set(); const raw = JSON.parse(_readFile(HIDDEN_TLDS_FILE, "utf8")); return new Set(Array.isArray(raw?.hidden) ? raw.hidden : []); } catch { return new Set(); } } function writeHiddenTlds(set) { _writeFile(HIDDEN_TLDS_FILE, JSON.stringify({ hidden: [...set].sort() }, null, 2) + "\n", "utf8"); } let HIDDEN_TLDS = readHiddenTlds(); // ---------- marketplace + co-sign storage and helpers ---------- const MARKET_FILE = process.env.BNS_MARKET_FILE || _join(_dirname(fileURLToPath(import.meta.url)), "market.json"); const COSIGN_FILE = process.env.BNS_COSIGN_FILE || _join(_dirname(fileURLToPath(import.meta.url)), "cosign.json"); function readJsonList(file) { try { if (!_existsSync(file)) return []; const j = JSON.parse(_readFile(file, "utf8")); return Array.isArray(j) ? j : []; } catch { return []; } } const readMarket = () => readJsonList(MARKET_FILE); const writeMarket = (list) => _writeFile(MARKET_FILE, JSON.stringify(list, null, 2) + "\n", "utf8"); const readCosign = () => readJsonList(COSIGN_FILE); const writeCosign = (list) => _writeFile(COSIGN_FILE, JSON.stringify(list, null, 2) + "\n", "utf8"); function lockingHexOf(cashaddr) { const r = cashAddressToLockingBytecode(cashaddr); if (typeof r === "string") throw new Error(`bad address ${cashaddr}: ${r}`); return binToHex(r.bytecode); } const scripthashOfLock = (lockHex) => binToHex(sha256.hash(hexToBin(lockHex)).slice().reverse()); // Recover the signer of a BCH message signature over sha256(msg) and return // it as a CashAddress with the same prefix as `likeAddress`. function recoverSigner(sigB64, msg, likeAddress) { try { const sigBytes = base64ToBin(sigB64); if (sigBytes.length !== 65) return null; const digest = sha256.hash(utf8ToBin(msg)); const pub = secp256k1.recoverPublicKeyCompressed(sigBytes.slice(1), (sigBytes[0] - 27) & 3, digest); if (typeof pub === "string") return null; const prefix = String(likeAddress || "bchtest:").split(":")[0] || "bchtest"; const enc = encodeCashAddress({ prefix, type: "p2pkh", payload: ripemd160.hash(sha256.hash(pub)) }); return typeof enc === "string" ? enc : (enc?.address ?? null); } catch { return null; } } // One full-electrum call (beacon-only indexers don't serve listunspent). async function withFullElectrum(fn) { const BNS_INDEXER_URLS = /silentmode\.st\/electrum|coinspectrum\.duckdns\.org:50011/; const candidates = CHIPNET_ELECTRUM.map((s) => (typeof s === "string" ? { url: s } : s)).filter((s) => !BNS_INDEXER_URLS.test(s.url)); let el = null, lastErr = null; for (const s of candidates) { try { el = await connectAny([s], WebSocket); break; } catch (e) { lastErr = e; } } if (!el) throw new Error("no full electrum reachable: " + String(lastErr?.message || lastErr)); try { return await fn(el); } finally { try { el.close(); } catch {} } } async function listUnspentForLock(lockHex) { return withFullElectrum((el) => el.call("blockchain.scripthash.listunspent", [scripthashOfLock(lockHex), "include_tokens"])); } async function outpointUnspent(lockHex, txid, vout) { const list = await listUnspentForLock(lockHex).catch(() => null); if (!list) return true; // electrum blip: don't reject; the buyer's broadcast is the final check return list.some((u) => u.tx_hash === txid && u.tx_pos === vout); } async function findTokenUtxo(lockHex, category, commitmentHex) { const list = await listUnspentForLock(lockHex); const u = list.find((x) => x.token_data?.category === category && x.token_data?.nft?.commitment === commitmentHex); if (!u) return null; return { txid: u.tx_hash, vout: u.tx_pos, satoshis: String(u.value), lockingBytecode: lockHex, token: { category: u.token_data.category, amount: String(u.token_data.amount ?? 0), nft: { capability: u.token_data.nft.capability, commitment: u.token_data.nft.commitment } } }; } // Listings whose certificate is still where the seller left it. Cached 30 s. let _marketCheck = { at: 0, list: null }; // ---- BCH/USD price: median of public exchange tickers ---- const PRICE_SOURCES = [ { name: "coinbase", url: "https://api.coinbase.com/v2/prices/BCH-USD/spot", pick: (j) => Number(j?.data?.amount) }, { name: "kraken", url: "https://api.kraken.com/0/public/Ticker?pair=BCHUSD", pick: (j) => Number(Object.values(j?.result || {})[0]?.c?.[0]) }, { name: "bitstamp", url: "https://www.bitstamp.net/api/v2/ticker/bchusd/", pick: (j) => Number(j?.last) }, { name: "binance", url: "https://api.binance.com/api/v3/ticker/price?symbol=BCHUSDT", pick: (j) => Number(j?.price) }, { name: "coingecko", url: "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin-cash&vs_currencies=usd", pick: (j) => Number(j?.["bitcoin-cash"]?.usd) }, ]; let priceCache = { at: 0, body: null }; async function getPrice() { if (priceCache.body && Date.now() - priceCache.at < 60_000) return priceCache.body; const results = await Promise.allSettled(PRICE_SOURCES.map(async (src) => { const r = await fetch(src.url, { signal: AbortSignal.timeout(5000), headers: { "user-agent": "bns-gateway/1", accept: "application/json" } }); if (!r.ok) throw new Error(String(r.status)); const usd = src.pick(await r.json()); if (!Number.isFinite(usd) || usd <= 0) throw new Error("no price"); return { name: src.name, usd: Math.round(usd * 100) / 100 }; })); const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value).sort((a, b) => a.usd - b.usd); if (ok.length) { const mid = Math.floor(ok.length / 2); const usd = ok.length % 2 ? ok[mid].usd : Math.round((ok[mid - 1].usd + ok[mid].usd) * 50) / 100; priceCache = { at: Date.now(), body: { usd, sats_per_usd: Math.round(1e8 / usd), sources: ok, median_of: ok.length, at: new Date().toISOString(), stale: false } }; return priceCache.body; } if (priceCache.body) return { ...priceCache.body, stale: true }; return { usd: null, sats_per_usd: null, sources: [], median_of: 0, at: new Date().toISOString(), stale: true, error: results.map((r) => r.reason?.message).join("; ") }; } // ---- price oracle: the gateway signs its median so covenants can read it ---- // Key: 32-byte hex in BNS_ORACLE_KEY_FILE (default ./oracle.key next to this // file), created once with `openssl rand -hex 32`; never in the repo. let oracleKey = null; function getOracleKey() { if (oracleKey) return oracleKey; const file = process.env.BNS_ORACLE_KEY_FILE || "./oracle.key"; try { const hex = readFileSyncOracle(file, "utf8").trim(); if (!/^[0-9a-f]{64}$/i.test(hex)) throw new Error("oracle key file must hold 32 bytes of hex"); oracleKey = hexToBin(hex); return oracleKey; } catch (e) { throw new Error(`oracle not configured: ${e.message}`); } } const oraclePkHex = () => binToHex(oraclePubkey(getOracleKey())); function usdListingLockHex(l) { return binToHex(usdListingLock(hexToBin(l.redeem_hex))); } async function activeListings() { if (_marketCheck.list && Date.now() - _marketCheck.at < 30000) return _marketCheck.list; const stored = readMarket(); if (!stored.length) { _marketCheck = { at: Date.now(), list: [] }; return []; } const idx = await getIndex(); const keep = []; const byLock = new Map(); for (const l of stored) { const entry = idx.get(l.name); if (l.kind === "usd") { // Alive while the certificate still sits in the covenant. if (!entry || entry.category !== l.category) continue; const clock = usdListingLockHex(l); if (!byLock.has(clock)) byLock.set(clock, await listUnspentForLock(clock).catch(() => null)); const clist = byLock.get(clock); if (clist && !clist.some((u) => u.tx_hash === l.outpoint.txid && u.tx_pos === l.outpoint.vout)) continue; // bought or cancelled keep.push({ ...l, records: entry.records ?? {} }); continue; } if (!entry || entry.owner !== l.seller || entry.category !== l.category) continue; // sold or moved const lock = lockingHexOf(l.seller); if (!byLock.has(lock)) byLock.set(lock, await listUnspentForLock(lock).catch(() => null)); const list = byLock.get(lock); if (list && !list.some((u) => u.tx_hash === l.source_output.txid && u.tx_pos === l.source_output.vout)) continue; // spent keep.push({ ...l, records: entry.records ?? {} }); } if (keep.length !== stored.length) writeMarket(keep); _marketCheck = { at: Date.now(), list: keep }; return keep; } const S3_ENDPOINT = process.env.BNS_S3 || "http://127.0.0.1:8500"; const SUB_BASE = (process.env.BNS_SUB_BASE || "bch.navigate.st").toLowerCase(); // subdomain suffix const aws = new AwsClient({ accessKeyId: process.env.AK, secretAccessKey: process.env.SK, service: "s3", region: "us-east-1", }); // In-memory highest-seen seq per name, used by both the POST (records // write) and GET (dns read) sides to reject rollback of a signed // manifest. Loses state on gateway restart — that's acceptable in v1 // since Sia's newer-writer-wins covers the fresh state, and a restart // window is a narrow attack surface. A future v2 can persist this to // disk alongside the beacon snapshot. const _dnsSeqCache = new Map(); // ---------- signed records manifest helpers ---------- // canonicalize(): recursively sort object keys, drop null/undefined so // signable bytes are reproducible cross-implementation. Arrays keep order. function canonicalize(x) { if (x === null || x === undefined) return undefined; if (Array.isArray(x)) return x.map(canonicalize).filter((v) => v !== undefined); if (typeof x === "object") { const out = {}; for (const k of Object.keys(x).sort()) { const v = canonicalize(x[k]); if (v !== undefined) out[k] = v; } return out; } return x; } function withoutKey(obj, k) { const { [k]: _drop, ...rest } = obj; return rest; } const cache = new Map(); async function resolve(host) { const c = cache.get(host); if (c && Date.now() - c.at < 30000) return c.entry; const entry = await resolveName(host, { WebSocket }); // resolveName normalizes any .