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
3 changes: 2 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"build": "tsc -b",
"test": "vitest run"
"test": "vitest run",
"verify:rpcs": "node --experimental-strip-types scripts/verify-rpcs.ts"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20251014.0",
Expand Down
36 changes: 36 additions & 0 deletions api/scripts/verify-rpcs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// 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";
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)}`;
}
})
);

console.log(results.join("\n"));
if (results.some((line) => !line.startsWith("OK"))) process.exitCode = 1;
60 changes: 60 additions & 0 deletions api/src/ethereumTransport.test.ts
Original file line number Diff line number Diff line change
@@ -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(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);
});
28 changes: 28 additions & 0 deletions api/src/ethereumTransport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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.
*
* 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 {
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;
}
5 changes: 3 additions & 2 deletions api/src/router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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";
Expand All @@ -20,7 +21,7 @@ export const router = AutoRouter<
router.get("/ens/resolve/:address", async ({ url, params }, env) => {
const client = createClient({
chain: mainnet,
transport: http(env.ETHEREUM_RPC_URL),
transport: ethereumTransport(env.ETHEREUM_RPC_URL),
});

const lowercaseAddress = params.address.toLowerCase();
Expand Down
19 changes: 19 additions & 0 deletions api/src/rpcUrls.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading