Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 6 additions & 26 deletions api/scripts/verify-rpcs.ts
Original file line number Diff line number Diff line change
@@ -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}`;
})
);

Expand Down
16 changes: 16 additions & 0 deletions api/src/checkRpc.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
23 changes: 23 additions & 0 deletions api/src/checkRpc.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
}
2 changes: 1 addition & 1 deletion api/src/ethereumTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
Expand Down
16 changes: 9 additions & 7 deletions api/src/ethereumTransport.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Expand Down
27 changes: 27 additions & 0 deletions api/src/fetchChainlistRpcs.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
28 changes: 28 additions & 0 deletions api/src/fetchChainlistRpcs.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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);
}
75 changes: 75 additions & 0 deletions api/src/getHealthyRpcs.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>[] = [];
const ctx = {
waitUntil: (p: Promise<unknown>) => 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();
});
69 changes: 69 additions & 0 deletions api/src/getHealthyRpcs.ts
Original file line number Diff line number Diff line change
@@ -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<Pool> {
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<readonly string[]> {
return (await getRpcPool(ctx)).rpcs;
}

/** Health-check chainlist candidates + the seed list and cache the survivors. */
async function refresh(cache: Cache): Promise<void> {
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;
}
}
13 changes: 11 additions & 2 deletions api/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down
Loading