Theseus 0.3.97: ENS adapter — .eth names resolve to IPFS/IPNS
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 <gateway>/ipfs/<cid> (or /ipns/<cid>) 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.
This commit is contained in:
parent
4a7667c06e
commit
3985772ba3
4 changed files with 247 additions and 5 deletions
201
lib/resolvers/ens.cjs
Normal file
201
lib/resolvers/ens.cjs
Normal file
|
|
@ -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: <varint protocol><cid> → 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 };
|
||||
43
main.js
43
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/<name>.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/<cid>/<path>. 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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -481,10 +481,10 @@
|
|||
<div class="row">
|
||||
<div class="txt">
|
||||
<div class="t">Ethereum Name Service (ENS)</div>
|
||||
<div class="d"><i>.eth</i> → content hash from the ENS registry on Ethereum, served via an IPFS/Arweave gateway. <span class="muted" id="rvNotWiredEns">Not wired yet — coming in 0.3.96.</span></div>
|
||||
<div class="d"><i>.eth</i> → content hash from the ENS registry on Ethereum, served via an IPFS/IPNS gateway.</div>
|
||||
<div class="acts"><details class="adv"><summary>Advanced</summary>
|
||||
<label>Ethereum RPC URL</label><input type="text" id="rvEnsRpc" placeholder="https://cloudflare-eth.com">
|
||||
<label>IPFS gateway</label><input type="text" id="rvEnsGateway" placeholder="https://cloudflare-ipfs.com">
|
||||
<label>Ethereum RPC URL</label><input type="text" id="rvEnsRpc" placeholder="https://ethereum-rpc.publicnode.com">
|
||||
<label>IPFS gateway</label><input type="text" id="rvEnsGateway" placeholder="https://ipfs.io">
|
||||
</details></div>
|
||||
</div>
|
||||
<label class="sw"><input type="checkbox" id="rvEnsOn"><span class="track"><span class="knob"></span></span></label>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue