theseus/lib/resolvers/ens.cjs
Local Dev 3985772ba3 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.
2026-10-21 02:20:23 +03:00

201 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 };