Skip to content
Merged
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
52 changes: 34 additions & 18 deletions alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof apiWorker>>;

export default Alchemy.Stack(
"ens-ideas",
{
Expand Down Expand Up @@ -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 };
})
Expand Down
16 changes: 16 additions & 0 deletions apps/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 apps/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;
}
}
17 changes: 7 additions & 10 deletions apps/api/src/common.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ApiEnv } from "../../../alchemy.run.ts";

export type ResolveResult = {
address: string | null;
name: string | null;
Expand All @@ -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;
71 changes: 71 additions & 0 deletions apps/api/src/ethereumTransport.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
30 changes: 30 additions & 0 deletions apps/api/src/ethereumTransport.ts
Original file line number Diff line number Diff line change
@@ -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<value>(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;
}
27 changes: 27 additions & 0 deletions apps/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 apps/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);
}
43 changes: 43 additions & 0 deletions apps/api/src/refreshRpcs.test.ts
Original file line number Diff line number Diff line change
@@ -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([]));
});
20 changes: 20 additions & 0 deletions apps/api/src/refreshRpcs.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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));
}
9 changes: 6 additions & 3 deletions apps/api/src/router.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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<string[]>(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();
Expand Down
Loading
Loading