diff --git a/alchemy.run.ts b/alchemy.run.ts index 9e6faf3..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,24 +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). - 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" }, - env: { - ETHEREUM_RPC_URL: process.env.ETHEREUM_RPC_URL!, - RATE_LIMITER: Cloudflare.RateLimit("RATE_LIMITER", { - namespaceId: 1001, - simple: { limit: 1000, period: 60 }, - }), - }, - url: true, - }); + // 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* apiWorker(rpcs, stage); return { site: site.url, api: api.url }; }) 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/common.ts b/apps/api/src/common.ts index 8e36d7f..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; @@ -6,13 +8,8 @@ export type ResolveResult = { error?: string; }; -/** - * 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`). - */ -export interface Env { - ETHEREUM_RPC_URL: string; - RATE_LIMITER: RateLimit; -} +/** KV key holding the health-checked RPC list the cron writes. */ +export const RPCS_KEY = "healthy"; + +/** Runtime bindings the worker receives, inferred from the stack config. */ +export type Env = ApiEnv; diff --git a/apps/api/src/ethereumTransport.test.ts b/apps/api/src/ethereumTransport.test.ts new file mode 100644 index 0000000..a6f7f2f --- /dev/null +++ b/apps/api/src/ethereumTransport.test.ts @@ -0,0 +1,71 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createClient, numberToHex } from "viem"; +import { mainnet } from "viem/chains"; +import { ethereumTransport } from "./ethereumTransport"; + +const PAID_RPC_URL = "https://paid.mock/"; +const paidHost = new URL(PAID_RPC_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()); + +// 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(rpcs: readonly string[] = freeRpcUrls) { + const client = createClient({ + chain: mainnet, + transport: ethereumTransport(rpcs, 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); +}); + +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/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/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 e06c836..b98d80f 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -1,10 +1,11 @@ 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 { 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(); @@ -18,9 +19,11 @@ export const router = AutoRouter< }); 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: http(env.ETHEREUM_RPC_URL), + transport: ethereumTransport(healthy, env.ETHEREUM_RPC_URL), }); const lowercaseAddress = params.address.toLowerCase(); 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); + }, };