"use strict"; // ENS resolver — turns a .eth hostname into an HTTP gateway URL by reading // EIP-137 (namehash) and EIP-1577 (contenthash) from an Ethereum JSON-RPC // endpoint. Used by main.js's navigateTab when the user types a .eth name // AND the Names & Certificates toggle for ENS is on. // // Flow: // 1. namehash(name) (EIP-137) // 2. resolver(bytes32 node) on ENS registry → address of the resolver // 3. contenthash(bytes32 node) on that resolver → bytes (EIP-1577) // 4. decode: → ipfs | ipns | swarm → gateway URL // // Negative/positive answers cache for 10 min to avoid hammering the RPC on // retries; the cache clears when Theseus restarts (no on-disk cache). const { keccak_256 } = require("@noble/hashes/sha3.js"); const ENS_REGISTRY = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"; const SEL_RESOLVER = "0x0178b8bf"; // resolver(bytes32) const SEL_CONTENTHASH = "0xbc1c58d1"; // contenthash(bytes32) const POS_TTL_MS = 10 * 60 * 1000; // 10 min const NEG_TTL_MS = 60 * 1000; // 1 min — unresolved names shouldn't block for long const RPC_TIMEOUT_MS = 5000; const CACHE = new Map(); // host → { result, exp } function hex(bytes) { return "0x" + Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); } function unhex(h) { const s = String(h || "").replace(/^0x/i, ""); if (s.length % 2) throw new Error("bad hex length"); const out = new Uint8Array(s.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); return out; } // EIP-137 namehash. "foo.eth" → keccak256(namehash("eth") || keccak256("foo")). function namehash(name) { let node = new Uint8Array(32); if (!name) return node; const labels = String(name).toLowerCase().split("."); for (let i = labels.length - 1; i >= 0; i--) { const label = labels[i]; if (!label) continue; const labelHash = keccak_256(new TextEncoder().encode(label.normalize("NFC"))); const buf = new Uint8Array(64); buf.set(node, 0); buf.set(labelHash, 32); node = keccak_256(buf); } return node; } async function ethCall(rpc, to, data) { const body = { jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to, data }, "latest"] }; const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(new Error("ENS RPC timeout")), RPC_TIMEOUT_MS); try { const res = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: ctrl.signal, }); if (!res.ok) throw new Error(`ENS RPC HTTP ${res.status}`); const json = await res.json(); if (json.error) throw new Error(`ENS RPC error: ${json.error.message || JSON.stringify(json.error)}`); return json.result; // 0x… } finally { clearTimeout(t); } } // Solidity returns an address as a 32-byte word with the 20-byte address in // the low bytes. Returns the lowercase 0x… string or null if zero/unset. function decodeAddress(resultHex) { const b = unhex(resultHex); if (b.length < 32) return null; const addr = hex(b.slice(12, 32)); if (/^0x0+$/.test(addr)) return null; return addr; } // Solidity dynamic bytes: 32B offset + 32B length + length bytes padded to 32. function decodeBytes(resultHex) { const b = unhex(resultHex); if (b.length < 64) return null; let len = 0; for (let i = 32; i < 64; i++) len = (len * 256) + b[i]; // length fits in a u32 for any realistic contenthash if (!len || b.length < 64 + len) return null; return b.slice(64, 64 + len); } // Protobuf-style varint: little-endian base-128, top bit = continue. function readVarint(bytes, offset) { let value = 0, shift = 0, i = offset; while (i < bytes.length) { const byte = bytes[i++]; value |= (byte & 0x7f) << shift; if ((byte & 0x80) === 0) return [value, i - offset]; shift += 7; if (shift > 28) throw new Error("varint too long"); } throw new Error("varint truncated"); } // Base58 (Bitcoin alphabet) — CIDv0 encoding. const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; function base58encode(bytes) { if (bytes.length === 0) return ""; const digits = [0]; for (const byte of bytes) { let carry = byte; for (let j = 0; j < digits.length; j++) { carry += digits[j] << 8; digits[j] = carry % 58; carry = (carry / 58) | 0; } while (carry) { digits.push(carry % 58); carry = (carry / 58) | 0; } } let out = ""; for (let i = 0; i < bytes.length && bytes[i] === 0; i++) out += "1"; for (let i = digits.length - 1; i >= 0; i--) out += B58[digits[i]]; return out; } // Base32 (lowercase, no padding, RFC 4648) — CIDv1 encoding; prefix with "b". const B32 = "abcdefghijklmnopqrstuvwxyz234567"; function base32encode(bytes) { let bits = 0, value = 0, out = ""; for (const byte of bytes) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) out += B32[(value << (5 - bits)) & 31]; return out; } // Encode a CID (bytes after the EIP-1577 protocol prefix). The gateway // accepts either CIDv0 (base58 Qm…) or CIDv1 (base32 bafy…). function encodeCID(bytes) { if (bytes.length === 34 && bytes[0] === 0x12 && bytes[1] === 0x20) return base58encode(bytes); // CIDv0 return "b" + base32encode(bytes); // CIDv1 base32 } // Decode an EIP-1577 contenthash blob → { protocol, cid, uri }. function decodeContentHash(bytes) { if (!bytes || bytes.length < 2) return null; let proto, consumed; try { [proto, consumed] = readVarint(bytes, 0); } catch { return null; } const rest = bytes.slice(consumed); // EIP-1577 protocol codes: // 0xe3 = ipfs-ns, 0xe4 = swarm-ns, 0xe5 = ipns-ns if (proto === 0xe3) { const cid = encodeCID(rest); return { protocol: "ipfs", cid, uri: `ipfs://${cid}` }; } if (proto === 0xe5) { const cid = encodeCID(rest); return { protocol: "ipns", cid, uri: `ipns://${cid}` }; } if (proto === 0xe4) { const cid = Array.from(rest).map((b) => b.toString(16).padStart(2, "0")).join(""); return { protocol: "swarm", cid, uri: `bzz://${cid}` }; } return null; } // Resolve a hostname to an HTTP(S) URL on the configured gateway, or return // null (hostname isn't ours) / { error } (something broke). async function resolve(host, { rpc, gateway } = {}) { if (!host) return null; const name = String(host).toLowerCase(); if (!name.endsWith(".eth") || name === ".eth" || name === "eth") return null; const cached = CACHE.get(name); if (cached && cached.exp > Date.now()) return cached.result; // publicnode.com is a free, no-key, no-telemetry public RPC that has held // up in testing where Cloudflare's eth gateway kept returning "Internal // error". Users can swap it out in Settings › Names & Certificates. const RPC = rpc || "https://ethereum-rpc.publicnode.com"; // ipfs.io is Protocol Labs' public gateway; Cloudflare's IPFS gateway was // retired in 2024. dweb.link / nftstorage.link / 4everland.io are drop-in // alternatives if ipfs.io gets rate-limited. const GW = (gateway || "https://ipfs.io").replace(/\/+$/, ""); try { const node = hex(namehash(name)).slice(2); // strip 0x const resolverAddr = decodeAddress(await ethCall(RPC, ENS_REGISTRY, SEL_RESOLVER + node)); if (!resolverAddr) throw new Error("no resolver set"); const chash = decodeBytes(await ethCall(RPC, resolverAddr, SEL_CONTENTHASH + node)); if (!chash) throw new Error("no content hash"); const decoded = decodeContentHash(chash); if (!decoded) throw new Error("unsupported contenthash protocol"); let url = null; if (decoded.protocol === "ipfs") url = `${GW}/ipfs/${decoded.cid}`; else if (decoded.protocol === "ipns") url = `${GW}/ipns/${decoded.cid}`; // Swarm needs its own gateway; omit until we add a swarm-gateway option. const result = url ? { url, protocol: decoded.protocol, cid: decoded.cid, uri: decoded.uri } : { error: `${decoded.protocol} not yet supported` }; CACHE.set(name, { result, exp: Date.now() + (url ? POS_TTL_MS : NEG_TTL_MS) }); return result; } catch (err) { const result = { error: String(err?.message || err) }; CACHE.set(name, { result, exp: Date.now() + NEG_TTL_MS }); return result; } } function clearCache() { CACHE.clear(); } module.exports = { resolve, clearCache, namehash, decodeContentHash, encodeCID };