From 271ccde5c7c11cee759e7bb42d140b49264eb8f8 Mon Sep 17 00:00:00 2001 From: Kevin Ingersoll Date: Tue, 14 Jul 2026 23:46:11 +0100 Subject: [PATCH 1/5] rotate ENS lookups across a self-refreshing pool of free RPCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings #10 (rotation) and #11 (chainlist pool + /rpcs) forward onto the v2 monorepo API worker. - ethereumTransport: viem `fallback` across the free RPCs in random order, with the paid endpoint (ETHEREUM_RPC_URL) last. viem advances on any non-user error, so a 429 rolls over transparently — keeping paid usage, and cost, to a minimum. Shuffling spreads load (a stateless Worker can't round-robin). - getRpcPool/getHealthyRpcs: the known-good pool, cached in the Cache API and refreshed in the background via waitUntil (chainlist candidates + the committed seed list, health-checked). Cold/stale cache returns the seed immediately, so requests never block on a health-check pass. - GET /rpcs exposes the pool (and rides the worker's response cache). - The resolver now builds its client on the rotating transport. Verified on a preview stage: /rpcs cold returns the 7 seeds, then the background pass checked 41 candidates and kept 16 healthy; resolution works through the pool. 15/15 tests pass. Co-Authored-By: Claude Opus 4.8 --- apps/api/scripts/verify-rpcs.ts | 16 ++++++ apps/api/src/checkRpc.test.ts | 16 ++++++ apps/api/src/checkRpc.ts | 23 ++++++++ apps/api/src/ethereumTransport.test.ts | 60 ++++++++++++++++++++ apps/api/src/ethereumTransport.ts | 30 ++++++++++ apps/api/src/fetchChainlistRpcs.test.ts | 27 +++++++++ apps/api/src/fetchChainlistRpcs.ts | 28 +++++++++ apps/api/src/getHealthyRpcs.test.ts | 75 +++++++++++++++++++++++++ apps/api/src/getHealthyRpcs.ts | 69 +++++++++++++++++++++++ apps/api/src/router.ts | 19 ++++++- apps/api/src/rpcUrls.ts | 19 +++++++ 11 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 apps/api/scripts/verify-rpcs.ts create mode 100644 apps/api/src/checkRpc.test.ts create mode 100644 apps/api/src/checkRpc.ts create mode 100644 apps/api/src/ethereumTransport.test.ts create mode 100644 apps/api/src/ethereumTransport.ts create mode 100644 apps/api/src/fetchChainlistRpcs.test.ts create mode 100644 apps/api/src/fetchChainlistRpcs.ts create mode 100644 apps/api/src/getHealthyRpcs.test.ts create mode 100644 apps/api/src/getHealthyRpcs.ts create mode 100644 apps/api/src/rpcUrls.ts diff --git a/apps/api/scripts/verify-rpcs.ts b/apps/api/scripts/verify-rpcs.ts new file mode 100644 index 0000000..de16b83 --- /dev/null +++ b/apps/api/scripts/verify-rpcs.ts @@ -0,0 +1,16 @@ +// 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 results = await Promise.all( + rpcUrls.map(async (url) => { + const start = Date.now(); + const ok = await checkRpc(url, 8000); + return `${ok ? "OK " : "BAD "}${Date.now() - start}ms ${url}`; + }) +); + +console.log(results.join("\n")); +if (results.some((line) => !line.startsWith("OK"))) process.exitCode = 1; diff --git a/apps/api/src/checkRpc.test.ts b/apps/api/src/checkRpc.test.ts new file mode 100644 index 0000000..fb88178 --- /dev/null +++ b/apps/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/apps/api/src/checkRpc.ts b/apps/api/src/checkRpc.ts new file mode 100644 index 0000000..bdc2fc7 --- /dev/null +++ b/apps/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/apps/api/src/ethereumTransport.test.ts b/apps/api/src/ethereumTransport.test.ts new file mode 100644 index 0000000..f531298 --- /dev/null +++ b/apps/api/src/ethereumTransport.test.ts @@ -0,0 +1,60 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createClient, numberToHex } from "viem"; +import { mainnet } from "viem/chains"; +import { ethereumTransport } from "./ethereumTransport"; +import { rpcUrls } from "./rpcUrls"; + +const PAID_RPC_URL = "https://paid.mock/"; +const paidHost = new URL(PAID_RPC_URL).host; +const freeHosts = rpcUrls.map((url) => new URL(url).host); + +afterEach(() => vi.unstubAllGlobals()); + +// Stub global fetch, recording the host of every RPC request and delegating the +// response to `respond(host)`. The JSON-RPC id is echoed so viem accepts it. +function stubFetch(respond: (host: string) => { status: number; result?: unknown }) { + const calls: string[] = []; + vi.stubGlobal("fetch", async (input: string | Request, init?: RequestInit) => { + const url = new URL(typeof input === "string" ? input : input.url); + calls.push(url.host); + const id = JSON.parse(String(init?.body ?? "{}")).id ?? 1; + const { status, result } = respond(url.host); + if (status !== 200) return new Response("rate limited", { status }); + return new Response(JSON.stringify({ jsonrpc: "2.0", id, result }), { + headers: { "content-type": "application/json" }, + }); + }); + return calls; +} + +const blockNumber = numberToHex(123n); + +function requestBlockNumber() { + const client = createClient({ + chain: mainnet, + transport: ethereumTransport(rpcUrls, PAID_RPC_URL), + }); + return client.request({ method: "eth_blockNumber" }); +} + +it("serves from a free RPC and never touches the paid endpoint when free RPCs work", async () => { + const calls = stubFetch((host) => + host === paidHost ? { status: 429 } : { status: 200, result: blockNumber } + ); + + expect(await requestBlockNumber()).toBe(blockNumber); + expect(calls).not.toContain(paidHost); + expect(freeHosts).toContain(calls[0]); + expect(calls).toHaveLength(1); // first (random) free RPC answered — no rotation needed +}); + +it("falls back to the paid endpoint only after every free RPC is rate-limited", async () => { + const calls = stubFetch((host) => + host === paidHost ? { status: 200, result: blockNumber } : { status: 429 } + ); + + expect(await requestBlockNumber()).toBe(blockNumber); + // every free RPC was attempted, then the paid endpoint last + expect(new Set(calls.slice(0, -1))).toEqual(new Set(freeHosts)); + expect(calls.at(-1)).toBe(paidHost); +}); diff --git a/apps/api/src/ethereumTransport.ts b/apps/api/src/ethereumTransport.ts new file mode 100644 index 0000000..804f571 --- /dev/null +++ b/apps/api/src/ethereumTransport.ts @@ -0,0 +1,30 @@ +import { fallback, http, type Transport } from "viem"; + +/** + * 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( + rpcUrls: readonly string[], + paidRpcUrl?: string +): Transport { + const free = shuffle(rpcUrls).map((url) => http(url)); + return fallback(paidRpcUrl ? [...free, http(paidRpcUrl)] : free); +} + +function shuffle(items: readonly value[]): value[] { + const result = [...items]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} diff --git a/apps/api/src/fetchChainlistRpcs.test.ts b/apps/api/src/fetchChainlistRpcs.test.ts new file mode 100644 index 0000000..9ed61fc --- /dev/null +++ b/apps/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/apps/api/src/fetchChainlistRpcs.ts b/apps/api/src/fetchChainlistRpcs.ts new file mode 100644 index 0000000..95165bc --- /dev/null +++ b/apps/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/apps/api/src/getHealthyRpcs.test.ts b/apps/api/src/getHealthyRpcs.test.ts new file mode 100644 index 0000000..573a625 --- /dev/null +++ b/apps/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/apps/api/src/getHealthyRpcs.ts b/apps/api/src/getHealthyRpcs.ts new file mode 100644 index 0000000..4c51ddd --- /dev/null +++ b/apps/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/apps/api/src/router.ts b/apps/api/src/router.ts index e06c836..1f50615 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -1,6 +1,8 @@ import { AutoRouter, IRequestStrict, status, json, cors } from "itty-router"; -import { createClient, http, isAddress } from "viem"; +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"; @@ -17,10 +19,21 @@ 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: http(env.ETHEREUM_RPC_URL), + transport: ethereumTransport( + await getHealthyRpcs(ctx), + env.ETHEREUM_RPC_URL + ), }); const lowercaseAddress = params.address.toLowerCase(); diff --git a/apps/api/src/rpcUrls.ts b/apps/api/src/rpcUrls.ts new file mode 100644 index 0000000..5e5dba8 --- /dev/null +++ b/apps/api/src/rpcUrls.ts @@ -0,0 +1,19 @@ +/** + * Free public Ethereum mainnet RPC endpoints, tried (in random order) ahead of + * the paid endpoint so it's only used when every free RPC is failing or + * rate-limited. Edit freely to tune the pool. + * + * Every URL here was verified to resolve ENS forward + reverse (`vitalik.eth`) + * in under 400ms. Re-check with `pnpm --filter ./api run verify:rpcs` if you add + * more — many well-known public RPCs are down, rate-limited, or revert on the + * universal-resolver call (llamarpc, cloudflare-eth, 1rpc, ankr all failed). + */ +export const rpcUrls = [ + "https://ethereum-rpc.publicnode.com", + "https://eth.drpc.org", + "https://eth-mainnet.public.blastapi.io", + "https://eth.rpc.blxrbdn.com", + "https://rpc.mevblocker.io", + "https://eth-pokt.nodies.app", + "https://gateway.tenderly.co/public/mainnet", +] as const; From d09a9be3c5f07eff306137d05736f1ad7516f835 Mon Sep 17 00:00:00 2001 From: Kevin Ingersoll Date: Wed, 15 Jul 2026 09:35:26 +0100 Subject: [PATCH 2/5] let the cache own RPC pool freshness; drop the /rpcs endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool tracked its own freshness (generatedAt + a 5min FRESH_MS check) on top of a cache entry with max-age=86400 — two competing TTLs, with the hand-rolled one doing the real work. Collapse it to one signal: cache the health-checked list with a max-age, and let `cache.match` missing be what triggers a refresh. Drops the Pool type, generatedAt/checked metadata, and the staleness comparison. The /rpcs endpoint existed only to get the list cached; the cached method does that directly, without a subrequest per resolve, so the endpoint goes too. A worker *can* fetch its own hostname, but it costs a subrequest, adds latency, and re-enters the worker on a miss — the Cache API gives the same caching in-process. Behavior note: on expiry the next request now gets the committed seed list (not the previous list) while the refresh runs. The seeds are verified-good and the transport falls through anyway. Co-Authored-By: Claude Opus 4.8 --- apps/api/src/getHealthyRpcs.test.ts | 38 +++++++++---------- apps/api/src/getHealthyRpcs.ts | 58 +++++++++++------------------ apps/api/src/router.ts | 10 +---- 3 files changed, 41 insertions(+), 65 deletions(-) diff --git a/apps/api/src/getHealthyRpcs.test.ts b/apps/api/src/getHealthyRpcs.test.ts index 573a625..f42a51e 100644 --- a/apps/api/src/getHealthyRpcs.test.ts +++ b/apps/api/src/getHealthyRpcs.test.ts @@ -37,7 +37,9 @@ 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) + async (url) => + url === "https://good.example" || + (rpcUrls as readonly string[]).includes(url) ); const { ctx, tasks } = makeCtx(); @@ -45,31 +47,27 @@ it("returns the seed list on a cold cache, then caches the health-checked surviv 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"); + const healthy: string[] = await store!.json(); + expect(healthy).toContain("https://good.example"); + expect(healthy).not.toContain("https://bad.example"); }); -it("serves a fresh cached pool without refreshing", async () => { - store = Response.json({ generatedAt: Date.now(), rpcs: ["https://cached.example"] }); +it("lets the cache own freshness via max-age", async () => { + mockChainlist.mockResolvedValue([]); + mockCheck.mockResolvedValue(true); const { ctx, tasks } = makeCtx(); - expect(await getHealthyRpcs(ctx)).toEqual(["https://cached.example"]); - expect(tasks).toHaveLength(0); - expect(mockChainlist).not.toHaveBeenCalled(); + await getHealthyRpcs(ctx); + await Promise.all(tasks); + + expect(store!.headers.get("Cache-Control")).toMatch(/max-age=\d+/); }); -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); +it("serves the cached list without refreshing", async () => { + store = Response.json(["https://cached.example"]); 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(); + expect(await getHealthyRpcs(ctx)).toEqual(["https://cached.example"]); + expect(tasks).toHaveLength(0); + expect(mockChainlist).not.toHaveBeenCalled(); }); diff --git a/apps/api/src/getHealthyRpcs.ts b/apps/api/src/getHealthyRpcs.ts index 4c51ddd..58d4254 100644 --- a/apps/api/src/getHealthyRpcs.ts +++ b/apps/api/src/getHealthyRpcs.ts @@ -2,43 +2,32 @@ 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; +const CACHE_KEY = "https://ens-ideas.internal/healthy-rpcs"; -export type Pool = { generatedAt: number; checked: number; rpcs: string[] }; - -const seedPool = (): Pool => ({ generatedAt: 0, checked: 0, rpcs: [...rpcUrls] }); +/** + * How long a health-checked list stays good. This is the only freshness + * signal: once the entry expires, `cache.match` misses and we refresh. Health + * doesn't need tracking any tighter — the transport already falls through a + * dead RPC to the next one. + */ +const MAX_AGE_SECONDS = 60 * 60; 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. + * The known-good ENS RPCs, health-checked and cached. A cold or expired cache + * serves the committed seed list immediately and refreshes in the background, + * so callers never block on a 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; + const cache = caches.default; + const cached = await cache.match(CACHE_KEY); + if (cached) return await cached.json(); + + ctx.waitUntil(refresh(cache)); + return rpcUrls; } /** Health-check chainlist candidates + the seed list and cache the survivors. */ @@ -49,18 +38,15 @@ async function refresh(cache: Cache): Promise { 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))) + 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" }, + Response.json(healthy.length ? healthy : [...rpcUrls], { + headers: { "Cache-Control": `public, max-age=${MAX_AGE_SECONDS}` }, }) ); } finally { diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 1f50615..b3252e9 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -2,7 +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 { getHealthyRpcs } from "./getHealthyRpcs"; import { resolveAddress } from "./resolveAddress"; import { resolveName } from "./resolveName"; import { resolveUrl } from "./resolveUrl"; @@ -19,14 +19,6 @@ export const router = AutoRouter< finally: [corsify], }); -// 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, From b5113244f97060071aa716d7d2df469c4d5d66f3 Mon Sep 17 00:00:00 2001 From: Kevin Ingersoll Date: Wed, 15 Jul 2026 09:44:49 +0100 Subject: [PATCH 3/5] serve the RPC list stale while revalidating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expiring the cache entry at the freshness threshold meant every request in the refresh window fell back to the 7 committed seeds — a narrower pool, so more 429 fallthrough and more paid RPC hits, once an hour. Cloudflare's Cache API ignores `stale-while-revalidate` (measured: an entry is dropped the moment max-age passes) but does stamp `Age` on every hit. So keep the entry alive for 24h and use the cache's own Age to decide when to revalidate: past an hour we refresh in the background and still serve the cached list. Requests now only see the seed list on a genuinely cold cache, and never wait on a health-check pass. Co-Authored-By: Claude Opus 4.8 --- apps/api/src/getHealthyRpcs.test.ts | 24 ++++++++++++++++---- apps/api/src/getHealthyRpcs.ts | 34 +++++++++++++++++++---------- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/apps/api/src/getHealthyRpcs.test.ts b/apps/api/src/getHealthyRpcs.test.ts index f42a51e..8514a6d 100644 --- a/apps/api/src/getHealthyRpcs.test.ts +++ b/apps/api/src/getHealthyRpcs.test.ts @@ -52,7 +52,7 @@ it("returns the seed list on a cold cache, then caches the health-checked surviv expect(healthy).not.toContain("https://bad.example"); }); -it("lets the cache own freshness via max-age", async () => { +it("caches the list with a max-age that outlives the refresh threshold", async () => { mockChainlist.mockResolvedValue([]); mockCheck.mockResolvedValue(true); const { ctx, tasks } = makeCtx(); @@ -60,14 +60,30 @@ it("lets the cache own freshness via max-age", async () => { await getHealthyRpcs(ctx); await Promise.all(tasks); - expect(store!.headers.get("Cache-Control")).toMatch(/max-age=\d+/); + expect(store!.headers.get("Cache-Control")).toMatch(/max-age=86400/); }); -it("serves the cached list without refreshing", async () => { - store = Response.json(["https://cached.example"]); +it("serves a fresh cached list without refreshing", async () => { + store = Response.json(["https://cached.example"], { headers: { Age: "60" } }); const { ctx, tasks } = makeCtx(); expect(await getHealthyRpcs(ctx)).toEqual(["https://cached.example"]); expect(tasks).toHaveLength(0); expect(mockChainlist).not.toHaveBeenCalled(); }); + +it("serves an aged list immediately and revalidates in the background", async () => { + store = Response.json(["https://stale.example"], { + headers: { Age: String(2 * 60 * 60) }, + }); + mockChainlist.mockResolvedValue([]); + mockCheck.mockResolvedValue(true); + const { ctx, tasks } = makeCtx(); + + // stale list served now — no request waits on the health-check pass + expect(await getHealthyRpcs(ctx)).toEqual(["https://stale.example"]); + expect(tasks.length).toBeGreaterThan(0); + + await Promise.all(tasks); + expect(cache.put).toHaveBeenCalled(); +}); diff --git a/apps/api/src/getHealthyRpcs.ts b/apps/api/src/getHealthyRpcs.ts index 58d4254..eba1c76 100644 --- a/apps/api/src/getHealthyRpcs.ts +++ b/apps/api/src/getHealthyRpcs.ts @@ -4,30 +4,42 @@ import { rpcUrls } from "./rpcUrls"; const CACHE_KEY = "https://ens-ideas.internal/healthy-rpcs"; +/** How long a health-checked list is treated as fresh. */ +const REFRESH_AFTER_SECONDS = 60 * 60; + /** - * How long a health-checked list stays good. This is the only freshness - * signal: once the entry expires, `cache.match` misses and we refresh. Health - * doesn't need tracking any tighter — the transport already falls through a - * dead RPC to the next one. + * How long the entry survives at all — the stale-while-revalidate window. + * It lives in `max-age` because the Cache API drops an entry the moment + * `max-age` passes and ignores the `stale-while-revalidate` directive, so a + * short `max-age` would leave us serving the seed list during every refresh. */ -const MAX_AGE_SECONDS = 60 * 60; +const MAX_AGE_SECONDS = 24 * 60 * 60; let refreshing = false; /** - * The known-good ENS RPCs, health-checked and cached. A cold or expired cache - * serves the committed seed list immediately and refreshes in the background, - * so callers never block on a health-check pass. + * The known-good ENS RPCs, health-checked and cached. Always serves the cached + * list immediately, kicking off a background refresh once the entry ages past + * {@link REFRESH_AFTER_SECONDS}, so no request ever pays for a health-check + * pass. Falls back to the committed seed list only when nothing is cached. */ export async function getHealthyRpcs( ctx: ExecutionContext ): Promise { const cache = caches.default; const cached = await cache.match(CACHE_KEY); - if (cached) return await cached.json(); - ctx.waitUntil(refresh(cache)); - return rpcUrls; + if (!cached) { + ctx.waitUntil(refresh(cache)); + return rpcUrls; + } + + // The cache stamps `Age` on every hit, so it already tracks freshness for us. + const age = Number(cached.headers.get("Age") ?? 0); + if (age >= REFRESH_AFTER_SECONDS) { + ctx.waitUntil(refresh(cache)); + } + return await cached.json(); } /** Health-check chainlist candidates + the seed list and cache the survivors. */ From 4e68bbeff093e89ebafe3f119b362c3232073975 Mon Sep 17 00:00:00 2001 From: Kevin Ingersoll Date: Wed, 15 Jul 2026 10:08:24 +0100 Subject: [PATCH 4/5] refresh the RPC list on a cron into KV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool was refreshed lazily on the request path into the Cache API, which is per-colo: every data center re-ran the whole 41-endpoint health-check pass hourly (~2k check requests/hour) — hammering the free RPCs the rotation exists to lean on. A cron does the pass once for the fleet (~41/hour), but a cron runs in one colo, so the list has to live somewhere global: KV. Falling back to the paid endpoint on a cold KV removes the seed list, and with it most of the machinery. The request path is now a KV read; the transport already routes an empty free list straight to the paid RPC. Deleted: rpcUrls.ts (seed list), getHealthyRpcs.ts (cache/refresh/staleness logic), scripts/verify-rpcs.ts, and a dead alchemy.run esbuild stub in the tests. Gone with them: waitUntil, the Age check, the refreshing flag, and the two TTLs. Verified on a preview stage: cron registered (0 * * * *), per-stage KV namespace created, a triggered refresh wrote the health-checked list to KV, cold KV resolves via the paid RPC, and warm resolves use the list. Co-Authored-By: Claude Opus 4.8 --- alchemy.run.ts | 8 +++ apps/api/scripts/verify-rpcs.ts | 16 ----- apps/api/src/common.ts | 11 +++- apps/api/src/ethereumTransport.test.ts | 19 ++++-- apps/api/src/getHealthyRpcs.test.ts | 89 -------------------------- apps/api/src/getHealthyRpcs.ts | 67 ------------------- apps/api/src/refreshRpcs.test.ts | 43 +++++++++++++ apps/api/src/refreshRpcs.ts | 20 ++++++ apps/api/src/router.ts | 12 ++-- apps/api/src/rpcUrls.ts | 19 ------ apps/api/src/worker.test.ts | 20 +----- apps/api/src/worker.ts | 9 +++ 12 files changed, 111 insertions(+), 222 deletions(-) delete mode 100644 apps/api/scripts/verify-rpcs.ts delete mode 100644 apps/api/src/getHealthyRpcs.test.ts delete mode 100644 apps/api/src/getHealthyRpcs.ts create mode 100644 apps/api/src/refreshRpcs.test.ts create mode 100644 apps/api/src/refreshRpcs.ts delete mode 100644 apps/api/src/rpcUrls.ts diff --git a/alchemy.run.ts b/alchemy.run.ts index 9e6faf3..de58b18 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -61,16 +61,24 @@ export default Alchemy.Stack( // api.ensideas.com / api.instantens.com cutover is a deliberate follow-up // (those hostnames currently serve the live `instant-ens-api` worker, // managed from a separate repo). + // Holds the health-checked RPC list. KV rather than the Cache API because + // the cron that refreshes it runs in one colo, and the Cache API is + // per-colo — every other colo would never see it. + const rpcs = yield* Cloudflare.KV.Namespace("rpcs"); + const api = yield* Cloudflare.Worker("api", { name: `ens-ideas-api-${stage}`, main: "apps/api/src/worker.ts", compatibility: { flags: ["nodejs_compat"], date: "2025-11-17" }, + // Hourly health-check pass, once for the whole fleet. + crons: ["0 * * * *"], env: { ETHEREUM_RPC_URL: process.env.ETHEREUM_RPC_URL!, RATE_LIMITER: Cloudflare.RateLimit("RATE_LIMITER", { namespaceId: 1001, simple: { limit: 1000, period: 60 }, }), + RPCS: rpcs, }, url: true, }); diff --git a/apps/api/scripts/verify-rpcs.ts b/apps/api/scripts/verify-rpcs.ts deleted file mode 100644 index de16b83..0000000 --- a/apps/api/scripts/verify-rpcs.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 results = await Promise.all( - rpcUrls.map(async (url) => { - const start = Date.now(); - const ok = await checkRpc(url, 8000); - return `${ok ? "OK " : "BAD "}${Date.now() - start}ms ${url}`; - }) -); - -console.log(results.join("\n")); -if (results.some((line) => !line.startsWith("OK"))) process.exitCode = 1; diff --git a/apps/api/src/common.ts b/apps/api/src/common.ts index 8e36d7f..40ff3fe 100644 --- a/apps/api/src/common.ts +++ b/apps/api/src/common.ts @@ -6,13 +6,18 @@ export type ResolveResult = { error?: string; }; +/** KV key holding the health-checked RPC list the cron writes. */ +export const RPCS_KEY = "healthy"; + /** * Runtime bindings the worker receives. `RATE_LIMITER` is the native - * Cloudflare Rate Limiting binding; `ETHEREUM_RPC_URL` is a plain env string. - * Declared explicitly so runtime code stays decoupled from the Alchemy stack - * config (the deploy graph in the root `alchemy.run.ts`). + * Cloudflare Rate Limiting binding, `RPCS` holds the cron-refreshed RPC list, + * and `ETHEREUM_RPC_URL` is a plain env string. Declared explicitly so runtime + * code stays decoupled from the Alchemy stack config (the deploy graph in the + * root `alchemy.run.ts`). */ export interface Env { ETHEREUM_RPC_URL: string; RATE_LIMITER: RateLimit; + RPCS: KVNamespace; } diff --git a/apps/api/src/ethereumTransport.test.ts b/apps/api/src/ethereumTransport.test.ts index f531298..a6f7f2f 100644 --- a/apps/api/src/ethereumTransport.test.ts +++ b/apps/api/src/ethereumTransport.test.ts @@ -2,11 +2,15 @@ import { afterEach, expect, it, vi } from "vitest"; import { createClient, numberToHex } from "viem"; import { mainnet } from "viem/chains"; import { ethereumTransport } from "./ethereumTransport"; -import { rpcUrls } from "./rpcUrls"; const PAID_RPC_URL = "https://paid.mock/"; const paidHost = new URL(PAID_RPC_URL).host; -const freeHosts = rpcUrls.map((url) => new URL(url).host); +const freeRpcUrls = [ + "https://free-a.mock/", + "https://free-b.mock/", + "https://free-c.mock/", +]; +const freeHosts = freeRpcUrls.map((url) => new URL(url).host); afterEach(() => vi.unstubAllGlobals()); @@ -29,10 +33,10 @@ function stubFetch(respond: (host: string) => { status: number; result?: unknown const blockNumber = numberToHex(123n); -function requestBlockNumber() { +function requestBlockNumber(rpcs: readonly string[] = freeRpcUrls) { const client = createClient({ chain: mainnet, - transport: ethereumTransport(rpcUrls, PAID_RPC_URL), + transport: ethereumTransport(rpcs, PAID_RPC_URL), }); return client.request({ method: "eth_blockNumber" }); } @@ -58,3 +62,10 @@ it("falls back to the paid endpoint only after every free RPC is rate-limited", expect(new Set(calls.slice(0, -1))).toEqual(new Set(freeHosts)); expect(calls.at(-1)).toBe(paidHost); }); + +it("goes straight to the paid endpoint when there are no free RPCs (cold KV)", async () => { + const calls = stubFetch(() => ({ status: 200, result: blockNumber })); + + expect(await requestBlockNumber([])).toBe(blockNumber); + expect(calls).toEqual([paidHost]); +}); diff --git a/apps/api/src/getHealthyRpcs.test.ts b/apps/api/src/getHealthyRpcs.test.ts deleted file mode 100644 index 8514a6d..0000000 --- a/apps/api/src/getHealthyRpcs.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -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 healthy: string[] = await store!.json(); - expect(healthy).toContain("https://good.example"); - expect(healthy).not.toContain("https://bad.example"); -}); - -it("caches the list with a max-age that outlives the refresh threshold", async () => { - mockChainlist.mockResolvedValue([]); - mockCheck.mockResolvedValue(true); - const { ctx, tasks } = makeCtx(); - - await getHealthyRpcs(ctx); - await Promise.all(tasks); - - expect(store!.headers.get("Cache-Control")).toMatch(/max-age=86400/); -}); - -it("serves a fresh cached list without refreshing", async () => { - store = Response.json(["https://cached.example"], { headers: { Age: "60" } }); - const { ctx, tasks } = makeCtx(); - - expect(await getHealthyRpcs(ctx)).toEqual(["https://cached.example"]); - expect(tasks).toHaveLength(0); - expect(mockChainlist).not.toHaveBeenCalled(); -}); - -it("serves an aged list immediately and revalidates in the background", async () => { - store = Response.json(["https://stale.example"], { - headers: { Age: String(2 * 60 * 60) }, - }); - mockChainlist.mockResolvedValue([]); - mockCheck.mockResolvedValue(true); - const { ctx, tasks } = makeCtx(); - - // stale list served now — no request waits on the health-check pass - expect(await getHealthyRpcs(ctx)).toEqual(["https://stale.example"]); - expect(tasks.length).toBeGreaterThan(0); - - await Promise.all(tasks); - expect(cache.put).toHaveBeenCalled(); -}); diff --git a/apps/api/src/getHealthyRpcs.ts b/apps/api/src/getHealthyRpcs.ts deleted file mode 100644 index eba1c76..0000000 --- a/apps/api/src/getHealthyRpcs.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { checkRpc } from "./checkRpc"; -import { fetchChainlistRpcs } from "./fetchChainlistRpcs"; -import { rpcUrls } from "./rpcUrls"; - -const CACHE_KEY = "https://ens-ideas.internal/healthy-rpcs"; - -/** How long a health-checked list is treated as fresh. */ -const REFRESH_AFTER_SECONDS = 60 * 60; - -/** - * How long the entry survives at all — the stale-while-revalidate window. - * It lives in `max-age` because the Cache API drops an entry the moment - * `max-age` passes and ignores the `stale-while-revalidate` directive, so a - * short `max-age` would leave us serving the seed list during every refresh. - */ -const MAX_AGE_SECONDS = 24 * 60 * 60; - -let refreshing = false; - -/** - * The known-good ENS RPCs, health-checked and cached. Always serves the cached - * list immediately, kicking off a background refresh once the entry ages past - * {@link REFRESH_AFTER_SECONDS}, so no request ever pays for a health-check - * pass. Falls back to the committed seed list only when nothing is cached. - */ -export async function getHealthyRpcs( - ctx: ExecutionContext -): Promise { - const cache = caches.default; - const cached = await cache.match(CACHE_KEY); - - if (!cached) { - ctx.waitUntil(refresh(cache)); - return rpcUrls; - } - - // The cache stamps `Age` on every hit, so it already tracks freshness for us. - const age = Number(cached.headers.get("Age") ?? 0); - if (age >= REFRESH_AFTER_SECONDS) { - ctx.waitUntil(refresh(cache)); - } - return await cached.json(); -} - -/** 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); - - await cache.put( - CACHE_KEY, - Response.json(healthy.length ? healthy : [...rpcUrls], { - headers: { "Cache-Control": `public, max-age=${MAX_AGE_SECONDS}` }, - }) - ); - } finally { - refreshing = false; - } -} diff --git a/apps/api/src/refreshRpcs.test.ts b/apps/api/src/refreshRpcs.test.ts new file mode 100644 index 0000000..c3dafe5 --- /dev/null +++ b/apps/api/src/refreshRpcs.test.ts @@ -0,0 +1,43 @@ +import { expect, it, vi } from "vitest"; + +vi.mock("./checkRpc", () => ({ checkRpc: vi.fn() })); +vi.mock("./fetchChainlistRpcs", () => ({ fetchChainlistRpcs: vi.fn() })); + +import { checkRpc } from "./checkRpc"; +import { fetchChainlistRpcs } from "./fetchChainlistRpcs"; +import { refreshRpcs } from "./refreshRpcs"; +import { RPCS_KEY, type Env } from "./common"; + +function makeEnv() { + const put = vi.fn(); + return { env: { RPCS: { put } } as unknown as Env, put }; +} + +it("stores only the candidates that pass the health check", async () => { + vi.mocked(fetchChainlistRpcs).mockResolvedValue([ + "https://good.example", + "https://bad.example", + "https://good.example", // chainlist can repeat an endpoint + ]); + vi.mocked(checkRpc).mockImplementation( + async (url) => url === "https://good.example" + ); + const { env, put } = makeEnv(); + + await refreshRpcs(env); + + expect(put).toHaveBeenCalledWith( + RPCS_KEY, + JSON.stringify(["https://good.example"]) + ); +}); + +it("stores an empty list when nothing is healthy, so the resolver uses the paid RPC", async () => { + vi.mocked(fetchChainlistRpcs).mockResolvedValue(["https://bad.example"]); + vi.mocked(checkRpc).mockResolvedValue(false); + const { env, put } = makeEnv(); + + await refreshRpcs(env); + + expect(put).toHaveBeenCalledWith(RPCS_KEY, JSON.stringify([])); +}); diff --git a/apps/api/src/refreshRpcs.ts b/apps/api/src/refreshRpcs.ts new file mode 100644 index 0000000..c81dc47 --- /dev/null +++ b/apps/api/src/refreshRpcs.ts @@ -0,0 +1,20 @@ +import { checkRpc } from "./checkRpc"; +import { fetchChainlistRpcs } from "./fetchChainlistRpcs"; +import { RPCS_KEY, type Env } from "./common"; + +/** + * Health-check every chainlist candidate and store the survivors for the + * resolver to rotate through. Runs on a cron so the pass happens once for the + * whole fleet — the Cache API is per-colo, so doing this on the request path + * would re-run it in every data center. + */ +export async function refreshRpcs(env: Env): Promise { + const candidates = [...new Set(await fetchChainlistRpcs())]; + const healthy = ( + await Promise.all( + candidates.map(async (url) => ((await checkRpc(url)) ? url : null)) + ) + ).filter((url): url is string => url !== null); + + await env.RPCS.put(RPCS_KEY, JSON.stringify(healthy)); +} diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index b3252e9..b98d80f 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -2,11 +2,10 @@ 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 } from "./getHealthyRpcs"; import { resolveAddress } from "./resolveAddress"; import { resolveName } from "./resolveName"; import { resolveUrl } from "./resolveUrl"; -import type { Env } from "./common"; +import { RPCS_KEY, type Env } from "./common"; const { preflight, corsify } = cors(); @@ -19,13 +18,12 @@ export const router = AutoRouter< finally: [corsify], }); -router.get("/ens/resolve/:address", async ({ url, params }, env, ctx) => { +router.get("/ens/resolve/:address", async ({ url, params }, env) => { + // Kept fresh by the cron. Empty (cold KV) just means straight to the paid RPC. + const healthy = (await env.RPCS.get(RPCS_KEY, "json")) ?? []; const client = createClient({ chain: mainnet, - transport: ethereumTransport( - await getHealthyRpcs(ctx), - env.ETHEREUM_RPC_URL - ), + transport: ethereumTransport(healthy, env.ETHEREUM_RPC_URL), }); const lowercaseAddress = params.address.toLowerCase(); diff --git a/apps/api/src/rpcUrls.ts b/apps/api/src/rpcUrls.ts deleted file mode 100644 index 5e5dba8..0000000 --- a/apps/api/src/rpcUrls.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Free public Ethereum mainnet RPC endpoints, tried (in random order) ahead of - * the paid endpoint so it's only used when every free RPC is failing or - * rate-limited. Edit freely to tune the pool. - * - * Every URL here was verified to resolve ENS forward + reverse (`vitalik.eth`) - * in under 400ms. Re-check with `pnpm --filter ./api run verify:rpcs` if you add - * more — many well-known public RPCs are down, rate-limited, or revert on the - * universal-resolver call (llamarpc, cloudflare-eth, 1rpc, ankr all failed). - */ -export const rpcUrls = [ - "https://ethereum-rpc.publicnode.com", - "https://eth.drpc.org", - "https://eth-mainnet.public.blastapi.io", - "https://eth.rpc.blxrbdn.com", - "https://rpc.mevblocker.io", - "https://eth-pokt.nodies.app", - "https://gateway.tenderly.co/public/mainnet", -] as const; diff --git a/apps/api/src/worker.test.ts b/apps/api/src/worker.test.ts index c91b4bb..5d2b9d4 100644 --- a/apps/api/src/worker.test.ts +++ b/apps/api/src/worker.test.ts @@ -14,29 +14,12 @@ const testDir = dirname(fileURLToPath(import.meta.url)); let mf: Miniflare; beforeAll(async () => { - // alchemy.run.ts is only imported for types, and executes a deployment if - // actually run — stub it out of the bundle. const bundle = await build({ entryPoints: [join(testDir, "worker.ts")], bundle: true, format: "esm", write: false, conditions: ["workerd", "worker", "browser"], - plugins: [ - { - name: "stub-alchemy-run", - setup(build) { - build.onResolve({ filter: /alchemy\.run(\.ts)?$/ }, () => ({ - path: "alchemy-run", - namespace: "stub", - })); - build.onLoad({ filter: /.*/, namespace: "stub" }, () => ({ - contents: "export const worker = {};", - loader: "js" as const, - })); - }, - }, - ], }); // Every eth_call reverse-resolves to vitalik.eth. @@ -59,6 +42,9 @@ beforeAll(async () => { }, ], bindings: { ETHEREUM_RPC_URL: "https://rpc.mock/" }, + // Left empty: a cold RPC list is exactly the case where the resolver should + // go straight to the paid endpoint (the mocked RPC below). + kvNamespaces: ["RPCS"], ratelimits: { RATE_LIMITER: { namespace_id: "1001", simple: { limit: RATE_LIMIT, period: 60 } }, }, diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index acca666..8596fa3 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -1,3 +1,4 @@ +import { refreshRpcs } from "./refreshRpcs.ts"; import { router } from "./router.ts"; import type { Env } from "./common.ts"; @@ -39,4 +40,12 @@ export default { return res; }, + + async scheduled( + _controller: ScheduledController, + env: Env, + _ctx: ExecutionContext + ): Promise { + await refreshRpcs(env); + }, }; From 7cb0ef0c4dccef9cf418c4d66d3d7e1fc0c2b069 Mon Sep 17 00:00:00 2001 From: Kevin Ingersoll Date: Wed, 15 Jul 2026 10:17:19 +0100 Subject: [PATCH 5/5] infer the api worker's env from its bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Env interface duplicated the binding list by hand — adding RPCS meant editing the config and the interface. Alchemy infers it: hoist the api worker's definition to module scope so there's a `typeof` to feed Cloudflare.InferEnv, and derive Env from that. Verified the inference is real, not vacuous: a non-existent binding and a wrong-typed binding both fail typecheck, while ETHEREUM_RPC_URL (string), RATE_LIMITER and RPCS resolve to their proper types. The type import erases — the worker bundle contains no config code — so no esbuild stub is needed. Co-Authored-By: Claude Opus 4.8 --- alchemy.run.ts | 52 ++++++++++++++++++++++++------------------ apps/api/src/common.ts | 16 ++++--------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/alchemy.run.ts b/alchemy.run.ts index de58b18..4b7806b 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -12,6 +12,35 @@ import * as Effect from "effect/Effect"; // `alchemy dev` runs Waku's own dev server (Command.Dev) and serves from it. // Run under bun (`bun run deploy` / `bun run dev`) so the CLI loads this .ts // config natively. +// ENS resolver API. v2 bundles the TS entry with rolldown, so no build step +// (unlike the Waku site). Stays on workers.dev for now — the api.ensideas.com / +// api.instantens.com cutover is a deliberate follow-up (those hostnames +// currently serve the live `instant-ens-api` worker, managed from a separate +// repo). +// +// Declared out here (rather than inline in the stack) so the worker's runtime +// env type can be inferred from these bindings — see {@link ApiEnv}. +const apiWorker = (rpcs: Cloudflare.KV.Namespace, stage: string) => + Cloudflare.Worker("api", { + name: `ens-ideas-api-${stage}`, + main: "apps/api/src/worker.ts", + compatibility: { flags: ["nodejs_compat"], date: "2025-11-17" }, + // Hourly health-check pass, once for the whole fleet. + crons: ["0 * * * *"], + env: { + ETHEREUM_RPC_URL: process.env.ETHEREUM_RPC_URL!, + RATE_LIMITER: Cloudflare.RateLimit("RATE_LIMITER", { + namespaceId: 1001, + simple: { limit: 1000, period: 60 }, + }), + RPCS: rpcs, + }, + url: true, + }); + +/** The api worker's runtime bindings, inferred from the config above. */ +export type ApiEnv = Cloudflare.InferEnv>; + export default Alchemy.Stack( "ens-ideas", { @@ -56,32 +85,11 @@ export default Alchemy.Stack( url: true, }); - // ENS resolver API. v2 bundles the TS entry with rolldown, so no build - // step (unlike the Waku site). Stays on workers.dev for now — the - // api.ensideas.com / api.instantens.com cutover is a deliberate follow-up - // (those hostnames currently serve the live `instant-ens-api` worker, - // managed from a separate repo). // Holds the health-checked RPC list. KV rather than the Cache API because // the cron that refreshes it runs in one colo, and the Cache API is // per-colo — every other colo would never see it. const rpcs = yield* Cloudflare.KV.Namespace("rpcs"); - - const api = yield* Cloudflare.Worker("api", { - name: `ens-ideas-api-${stage}`, - main: "apps/api/src/worker.ts", - compatibility: { flags: ["nodejs_compat"], date: "2025-11-17" }, - // Hourly health-check pass, once for the whole fleet. - crons: ["0 * * * *"], - env: { - ETHEREUM_RPC_URL: process.env.ETHEREUM_RPC_URL!, - RATE_LIMITER: Cloudflare.RateLimit("RATE_LIMITER", { - namespaceId: 1001, - simple: { limit: 1000, period: 60 }, - }), - RPCS: rpcs, - }, - url: true, - }); + const api = yield* apiWorker(rpcs, stage); return { site: site.url, api: api.url }; }) diff --git a/apps/api/src/common.ts b/apps/api/src/common.ts index 40ff3fe..1397fee 100644 --- a/apps/api/src/common.ts +++ b/apps/api/src/common.ts @@ -1,3 +1,5 @@ +import type { ApiEnv } from "../../../alchemy.run.ts"; + export type ResolveResult = { address: string | null; name: string | null; @@ -9,15 +11,5 @@ export type ResolveResult = { /** KV key holding the health-checked RPC list the cron writes. */ export const RPCS_KEY = "healthy"; -/** - * Runtime bindings the worker receives. `RATE_LIMITER` is the native - * Cloudflare Rate Limiting binding, `RPCS` holds the cron-refreshed RPC list, - * and `ETHEREUM_RPC_URL` is a plain env string. Declared explicitly so runtime - * code stays decoupled from the Alchemy stack config (the deploy graph in the - * root `alchemy.run.ts`). - */ -export interface Env { - ETHEREUM_RPC_URL: string; - RATE_LIMITER: RateLimit; - RPCS: KVNamespace; -} +/** Runtime bindings the worker receives, inferred from the stack config. */ +export type Env = ApiEnv;