From 3985772ba3199d38a413dbbdf1af004380506dd5 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 21 Oct 2026 02:20:23 +0300 Subject: [PATCH] =?UTF-8?q?Theseus=200.3.97:=20ENS=20adapter=20=E2=80=94?= =?UTF-8?q?=20.eth=20names=20resolve=20to=20IPFS/IPNS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real resolver behind the Names & Certificates toggles. When the ENS switch is on, typing a .eth name in the address bar runs: 1. namehash(name) (EIP-137 keccak256 recursion) 2. eth_call resolver(bytes32) on the ENS registry 3. eth_call contenthash(bytes32) on the returned resolver address 4. decode EIP-1577 (varint protocol + CID) → ipfs|ipns|swarm 5. navigate to /ipfs/ (or /ipns/) The adapter lives in lib/resolvers/ens.cjs; main.js's navigateTab calls it through tryExtraResolvers after the BNS check and before the clearnet fallback. Defaults: publicnode's free Ethereum RPC (Cloudflare's eth-RPC returns "Internal error" for ENS calls in testing) and ipfs.io as the gateway (Cloudflare retired their IPFS gateway in 2024). Both are user-overridable in Settings › Names & Certificates › Advanced. CIDv0 (Qm… base58) and CIDv1 (bafy… base32) both encode; swarm names cache a "not yet supported" negative result for one minute until we add a swarm gateway option. Positive answers cache for 10 min, negatives for 1 min. --- lib/resolvers/ens.cjs | 201 ++++++++++++++++++++++++++++++++++++++++++ main.js | 43 ++++++++- package.json | 2 +- settings.html | 6 +- 4 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 lib/resolvers/ens.cjs diff --git a/lib/resolvers/ens.cjs b/lib/resolvers/ens.cjs new file mode 100644 index 00000000..b2685e71 --- /dev/null +++ b/lib/resolvers/ens.cjs @@ -0,0 +1,201 @@ +"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 }; diff --git a/main.js b/main.js index d4353724..f9d2a12d 100644 --- a/main.js +++ b/main.js @@ -886,7 +886,7 @@ const extraResolversFile = () => path.join(app.getPath("userData"), "extra-resol const DEFAULT_EXTRA_RESOLVERS = { version: 1, resolvers: { - ens: { enabled: false, rpc: "https://cloudflare-eth.com", gateway: "https://cloudflare-ipfs.com" }, + ens: { enabled: false, rpc: "https://ethereum-rpc.publicnode.com", gateway: "https://ipfs.io" }, namecoin: { enabled: false, rpc: "" }, handshake: { enabled: false, endpoint: "https://hnsrecursive.easyhandshake.com", collision_policy: "icann-wins" }, sns: { enabled: false, rpc: "https://api.mainnet-beta.solana.com" }, @@ -5761,6 +5761,11 @@ async function navigateTab(id, input) { // on NXDOMAIN or resolver failure, so clearnet still works. if (isBnsHost(host)) return loadBns(t, id, host, rest, tldOf(host)); + // Extra resolvers (ENS, …) run after BNS and before the clearnet fallback. + // Each adapter checks its own toggle in extra-resolvers.json; if the TLD + // isn't theirs, or the toggle is off, they return null and we fall through. + if (await tryExtraResolvers(t, id, host, rest)) return; + // Non-BNS-eligible input: raw IP, localhost, single-label — load direct. t.url = q.includes("://") ? q : "https://" + q; await t.view.webContents.loadURL(t.url); @@ -5769,6 +5774,42 @@ async function navigateTab(id, input) { emitTabs(); } +// Route a hostname through the opt-in extra resolvers (Names & Certificates +// settings). First match wins; on error or no match we return false and the +// caller falls through to the normal clearnet load. Adapters live in +// lib/resolvers/.cjs and are only loaded when their toggle is on, so +// an off adapter costs nothing at startup. +async function tryExtraResolvers(t, id, host, rest) { + let cfg; try { cfg = loadExtraResolvers(); } catch { return false; } + const resolvers = cfg?.resolvers || {}; + const h = String(host || "").toLowerCase(); + + // ENS — .eth → ENS registry → content hash → IPFS/IPNS gateway. + if (h.endsWith(".eth") && resolvers.ens?.enabled) { + try { + const ens = require("./lib/resolvers/ens.cjs"); + const result = await ens.resolve(h, { + rpc: resolvers.ens.rpc, + gateway: resolvers.ens.gateway, + }); + if (result && result.url) { + const path = rest && rest !== "/" ? rest : "/"; + // Path-based IPFS gateways accept /ipfs//. Keep the query + // string intact if the caller carried one through `rest`. + const target = result.url + (path === "/" ? "" : path); + t.url = target; + t.prov = { host: h, kind: "ens", protocol: result.protocol, cid: result.cid, uri: result.uri }; + if (isActiveId(id)) pushNav(t.prov); + emitTabs(); + await t.view.webContents.loadURL(target).catch(() => {}); + return true; + } + } catch (e) { console.warn("[ens] resolve failed:", e?.message); } + } + + return false; +} + // Open a local file (file:// URL) in a tab. A missing file surfaces through // did-fail-load → loadErrorPage like any other failed navigation. async function loadLocalFile(t, id, fileUrl) { diff --git a/package.json b/package.json index d242b6c7..6c5b9ba8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.96", + "version": "0.3.97", "license": "MPL-2.0", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode", diff --git a/settings.html b/settings.html index 6f7970eb..9f5454a8 100644 --- a/settings.html +++ b/settings.html @@ -481,10 +481,10 @@
Ethereum Name Service (ENS)
-
.eth → content hash from the ENS registry on Ethereum, served via an IPFS/Arweave gateway. Not wired yet — coming in 0.3.96.
+
.eth → content hash from the ENS registry on Ethereum, served via an IPFS/IPNS gateway.
Advanced - - + +