diff --git a/api/scripts/verify-rpcs.ts b/api/scripts/verify-rpcs.ts index 73a312d..de16b83 100644 --- a/api/scripts/verify-rpcs.ts +++ b/api/scripts/verify-rpcs.ts @@ -1,34 +1,14 @@ -// Checks each RPC in src/rpcUrls.ts against a real ENS resolution (forward + -// reverse for vitalik.eth) and prints latency. Run: `pnpm run verify:rpcs`. -import { createClient, http } from "viem"; -import { mainnet } from "viem/chains"; -import { getEnsAddress, getEnsName, normalize } from "viem/ens"; +// Health-checks each RPC in src/rpcUrls.ts with the same check the worker uses +// at runtime (ENS forward-resolve of vitalik.eth) and prints latency. +// Run: `pnpm run verify:rpcs`. +import { checkRpc } from "../src/checkRpc.ts"; import { rpcUrls } from "../src/rpcUrls.ts"; -const VITALIK = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"; - const results = await Promise.all( rpcUrls.map(async (url) => { - const client = createClient({ - chain: mainnet, - transport: http(url, { retryCount: 0, timeout: 8000 }), - }); const start = Date.now(); - try { - const [forward, reverse] = await Promise.all([ - getEnsAddress(client, { name: normalize("vitalik.eth") }), - getEnsName(client, { address: VITALIK }), - ]); - const ok = forward?.toLowerCase() === VITALIK && reverse === "vitalik.eth"; - return `${ok ? "OK " : "BAD "}${Date.now() - start}ms ${url}`; - } catch (error) { - const message = String( - (error as { shortMessage?: string }).shortMessage ?? - (error as Error).message ?? - error - ).split("\n")[0]; - return `FAIL ${Date.now() - start}ms ${url} ${message.slice(0, 60)}`; - } + const ok = await checkRpc(url, 8000); + return `${ok ? "OK " : "BAD "}${Date.now() - start}ms ${url}`; }) ); diff --git a/api/src/checkRpc.test.ts b/api/src/checkRpc.test.ts new file mode 100644 index 0000000..fb88178 --- /dev/null +++ b/api/src/checkRpc.test.ts @@ -0,0 +1,16 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { checkRpc } from "./checkRpc"; + +afterEach(() => vi.unstubAllGlobals()); + +it("returns false when the RPC is rate-limited", async () => { + vi.stubGlobal("fetch", async () => new Response("rate limited", { status: 429 })); + expect(await checkRpc("https://rate-limited.example", 1000)).toBe(false); +}); + +it("returns false when the RPC network call fails", async () => { + vi.stubGlobal("fetch", async () => { + throw new Error("boom"); + }); + expect(await checkRpc("https://down.example", 1000)).toBe(false); +}); diff --git a/api/src/checkRpc.ts b/api/src/checkRpc.ts new file mode 100644 index 0000000..bdc2fc7 --- /dev/null +++ b/api/src/checkRpc.ts @@ -0,0 +1,23 @@ +import { createClient, http } from "viem"; +import { mainnet } from "viem/chains"; +import { getEnsAddress, normalize } from "viem/ens"; + +const VITALIK = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"; + +/** + * True if the RPC is reachable and correctly serves the ENS universal-resolver + * `eth_call` (forward-resolving `vitalik.eth`). This is the bar for a usable + * endpoint — plenty of public RPCs are up but revert or lack ENS support. + */ +export async function checkRpc(url: string, timeout = 5000): Promise { + try { + const client = createClient({ + chain: mainnet, + transport: http(url, { retryCount: 0, timeout }), + }); + const address = await getEnsAddress(client, { name: normalize("vitalik.eth") }); + return address?.toLowerCase() === VITALIK; + } catch { + return false; + } +} diff --git a/api/src/ethereumTransport.test.ts b/api/src/ethereumTransport.test.ts index 566f4d2..f531298 100644 --- a/api/src/ethereumTransport.test.ts +++ b/api/src/ethereumTransport.test.ts @@ -32,7 +32,7 @@ const blockNumber = numberToHex(123n); function requestBlockNumber() { const client = createClient({ chain: mainnet, - transport: ethereumTransport(PAID_RPC_URL), + transport: ethereumTransport(rpcUrls, PAID_RPC_URL), }); return client.request({ method: "eth_blockNumber" }); } diff --git a/api/src/ethereumTransport.ts b/api/src/ethereumTransport.ts index 6bbc3c5..804f571 100644 --- a/api/src/ethereumTransport.ts +++ b/api/src/ethereumTransport.ts @@ -1,19 +1,21 @@ import { fallback, http, type Transport } from "viem"; -import { rpcUrls } from "./rpcUrls"; /** - * viem transport that spreads ENS lookups across the free public RPCs in a - * random order, only falling back to the paid endpoint when every free RPC - * fails (e.g. rate-limited). viem's `fallback` already retries each transport - * zero times and advances to the next on any non-user error, so a 429 rolls - * over transparently — keeping paid RPC usage, and cost, to a minimum. + * viem transport that spreads ENS lookups across the given free public RPCs in a + * random order, only falling back to the paid endpoint when every free RPC fails + * (e.g. rate-limited). viem's `fallback` already retries each transport zero + * times and advances to the next on any non-user error, so a 429 rolls over + * transparently — keeping paid RPC usage, and cost, to a minimum. * * The random shuffle spreads load across the pool: without it `fallback` always * hits the first URL first and would hammer it into rate limits. A stateless * Worker can't do true round-robin (no shared counter), so per-request * randomization is the pragmatic equivalent. */ -export function ethereumTransport(paidRpcUrl?: string): Transport { +export function ethereumTransport( + rpcUrls: readonly string[], + paidRpcUrl?: string +): Transport { const free = shuffle(rpcUrls).map((url) => http(url)); return fallback(paidRpcUrl ? [...free, http(paidRpcUrl)] : free); } diff --git a/api/src/fetchChainlistRpcs.test.ts b/api/src/fetchChainlistRpcs.test.ts new file mode 100644 index 0000000..9ed61fc --- /dev/null +++ b/api/src/fetchChainlistRpcs.test.ts @@ -0,0 +1,27 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { fetchChainlistRpcs } from "./fetchChainlistRpcs"; + +afterEach(() => vi.unstubAllGlobals()); + +it("keeps only mainnet https URLs without api-key placeholders", async () => { + const chains = [ + { + chainId: 1, + rpc: [ + { url: "https://good.example" }, + { url: "https://keyed.example/${API_KEY}" }, + { url: "http://insecure.example" }, + { url: "wss://ws.example" }, + ], + }, + { chainId: 10, rpc: [{ url: "https://optimism.example" }] }, + ]; + vi.stubGlobal("fetch", async () => Response.json(chains)); + + expect(await fetchChainlistRpcs()).toEqual(["https://good.example"]); +}); + +it("throws on a non-ok chainlist response", async () => { + vi.stubGlobal("fetch", async () => new Response("nope", { status: 500 })); + await expect(fetchChainlistRpcs()).rejects.toThrow(); +}); diff --git a/api/src/fetchChainlistRpcs.ts b/api/src/fetchChainlistRpcs.ts new file mode 100644 index 0000000..95165bc --- /dev/null +++ b/api/src/fetchChainlistRpcs.ts @@ -0,0 +1,28 @@ +const CHAINLIST_URL = "https://chainlist.org/rpcs.json"; +const MAINNET_CHAIN_ID = 1; + +// Cap how many candidates we test so the health-check pass stays well under the +// Workers per-invocation subrequest limit. +const MAX_CANDIDATES = 40; + +type ChainlistChain = { chainId: number; rpc: { url: string }[] }; + +/** + * Fetches chainlist's Ethereum mainnet RPCs, keeping usable public HTTPS URLs + * (dropping API-key-templated ones). These are candidates to be health-checked + * before use — chainlist lists many that are down or don't support ENS. + */ +export async function fetchChainlistRpcs(): Promise { + const response = await fetch(CHAINLIST_URL, { + headers: { accept: "application/json" }, + }); + if (!response.ok) throw new Error(`chainlist ${response.status}`); + + const chains: ChainlistChain[] = await response.json(); + const mainnet = chains.find((chain) => chain.chainId === MAINNET_CHAIN_ID); + + return (mainnet?.rpc ?? []) + .map((entry) => entry.url) + .filter((url) => url.startsWith("https://") && !url.includes("${")) + .slice(0, MAX_CANDIDATES); +} diff --git a/api/src/getHealthyRpcs.test.ts b/api/src/getHealthyRpcs.test.ts new file mode 100644 index 0000000..573a625 --- /dev/null +++ b/api/src/getHealthyRpcs.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { rpcUrls } from "./rpcUrls"; + +vi.mock("./checkRpc", () => ({ checkRpc: vi.fn() })); +vi.mock("./fetchChainlistRpcs", () => ({ fetchChainlistRpcs: vi.fn() })); + +import { checkRpc } from "./checkRpc"; +import { fetchChainlistRpcs } from "./fetchChainlistRpcs"; +import { getHealthyRpcs } from "./getHealthyRpcs"; + +const mockCheck = vi.mocked(checkRpc); +const mockChainlist = vi.mocked(fetchChainlistRpcs); + +let store: Response | undefined; +const cache = { + match: vi.fn(async () => store), + put: vi.fn(async (_key: unknown, res: Response) => { + store = res; + }), +}; + +function makeCtx() { + const tasks: Promise[] = []; + const ctx = { + waitUntil: (p: Promise) => tasks.push(p), + } as unknown as ExecutionContext; + return { ctx, tasks }; +} + +beforeEach(() => { + store = undefined; + vi.clearAllMocks(); + vi.stubGlobal("caches", { default: cache }); +}); +afterEach(() => vi.unstubAllGlobals()); + +it("returns the seed list on a cold cache, then caches the health-checked survivors", async () => { + mockChainlist.mockResolvedValue(["https://good.example", "https://bad.example"]); + mockCheck.mockImplementation( + async (url) => url === "https://good.example" || (rpcUrls as readonly string[]).includes(url) + ); + const { ctx, tasks } = makeCtx(); + + expect(await getHealthyRpcs(ctx)).toEqual(rpcUrls); // seed served immediately + + await Promise.all(tasks); // let the background refresh finish + expect(cache.put).toHaveBeenCalledOnce(); + const pool = (await store!.json()) as { rpcs: string[] }; + expect(pool.rpcs).toContain("https://good.example"); + expect(pool.rpcs).not.toContain("https://bad.example"); +}); + +it("serves a fresh cached pool without refreshing", async () => { + store = Response.json({ generatedAt: Date.now(), rpcs: ["https://cached.example"] }); + const { ctx, tasks } = makeCtx(); + + expect(await getHealthyRpcs(ctx)).toEqual(["https://cached.example"]); + expect(tasks).toHaveLength(0); + expect(mockChainlist).not.toHaveBeenCalled(); +}); + +it("serves a stale cached pool immediately and refreshes in the background", async () => { + store = Response.json({ + generatedAt: Date.now() - 10 * 60 * 1000, + rpcs: ["https://stale.example"], + }); + mockChainlist.mockResolvedValue([]); + mockCheck.mockResolvedValue(true); + const { ctx, tasks } = makeCtx(); + + expect(await getHealthyRpcs(ctx)).toEqual(["https://stale.example"]); // stale served now + expect(tasks.length).toBeGreaterThan(0); + await Promise.all(tasks); + expect(cache.put).toHaveBeenCalled(); +}); diff --git a/api/src/getHealthyRpcs.ts b/api/src/getHealthyRpcs.ts new file mode 100644 index 0000000..4c51ddd --- /dev/null +++ b/api/src/getHealthyRpcs.ts @@ -0,0 +1,69 @@ +import { checkRpc } from "./checkRpc"; +import { fetchChainlistRpcs } from "./fetchChainlistRpcs"; +import { rpcUrls } from "./rpcUrls"; + +const CACHE_KEY = "https://ens-ideas.internal/rpc-pool"; +const FRESH_MS = 5 * 60 * 1000; + +export type Pool = { generatedAt: number; checked: number; rpcs: string[] }; + +const seedPool = (): Pool => ({ generatedAt: 0, checked: 0, rpcs: [...rpcUrls] }); + +let refreshing = false; + +/** + * The current pool of known-good ENS RPCs plus freshness metadata. Reads a + * cached, periodically-refreshed health-checked list from the Cache API; on a + * cold or stale cache it returns the committed seed list immediately (with + * `generatedAt: 0`) and refreshes in the background (via `waitUntil`), so callers + * never block on the health-check pass. + */ +export async function getRpcPool(ctx: ExecutionContext): Promise { + const cache = caches.default; + const cached = await cache.match(CACHE_KEY); + + if (!cached) { + ctx.waitUntil(refresh(cache)); + return seedPool(); + } + + const pool: Pool = await cached.json(); + if (Date.now() - pool.generatedAt >= FRESH_MS) { + ctx.waitUntil(refresh(cache)); + } + return pool.rpcs.length ? pool : seedPool(); +} + +/** Just the RPC URLs from {@link getRpcPool}. */ +export async function getHealthyRpcs( + ctx: ExecutionContext +): Promise { + return (await getRpcPool(ctx)).rpcs; +} + +/** Health-check chainlist candidates + the seed list and cache the survivors. */ +async function refresh(cache: Cache): Promise { + if (refreshing) return; + refreshing = true; + try { + const candidates = await fetchChainlistRpcs().catch(() => []); + const unique = [...new Set([...candidates, ...rpcUrls])]; + const healthy = ( + await Promise.all(unique.map(async (url) => ((await checkRpc(url)) ? url : null))) + ).filter((url): url is string => url !== null); + + const pool: Pool = { + generatedAt: Date.now(), + checked: unique.length, + rpcs: healthy.length ? healthy : [...rpcUrls], + }; + await cache.put( + CACHE_KEY, + Response.json(pool, { + headers: { "Cache-Control": "public, max-age=86400" }, + }) + ); + } finally { + refreshing = false; + } +} diff --git a/api/src/router.ts b/api/src/router.ts index ff0d0f8..6679412 100644 --- a/api/src/router.ts +++ b/api/src/router.ts @@ -2,6 +2,7 @@ import { AutoRouter, IRequestStrict, status, json, cors } from "itty-router"; import { createClient, isAddress } from "viem"; import { mainnet } from "viem/chains"; import { ethereumTransport } from "./ethereumTransport"; +import { getHealthyRpcs, getRpcPool } from "./getHealthyRpcs"; import { resolveAddress } from "./resolveAddress"; import { resolveName } from "./resolveName"; import { resolveUrl } from "./resolveUrl"; @@ -18,10 +19,18 @@ export const router = AutoRouter< finally: [corsify], }); -router.get("/ens/resolve/:address", async ({ url, params }, env) => { +// Current pool of known-good ENS RPCs (health-checked, cached). Handy for +// debugging and reused by the resolver below. +router.get("/rpcs", async (_request, _env, ctx) => { + return json(await getRpcPool(ctx), { + headers: { "Cache-Control": "public, max-age=60" }, + }); +}); + +router.get("/ens/resolve/:address", async ({ url, params }, env, ctx) => { const client = createClient({ chain: mainnet, - transport: ethereumTransport(env.ETHEREUM_RPC_URL), + transport: ethereumTransport(await getHealthyRpcs(ctx), env.ETHEREUM_RPC_URL), }); const lowercaseAddress = params.address.toLowerCase();