Skip to content

Self-refreshing known-good RPC pool from chainlist (cached) - #11

Closed
frolic wants to merge 2 commits into
rpc-rotationfrom
rpc-list-endpoint
Closed

Self-refreshing known-good RPC pool from chainlist (cached)#11
frolic wants to merge 2 commits into
rpc-rotationfrom
rpc-list-endpoint

Conversation

@frolic

@frolic frolic commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Stacked on #10. Makes the free-RPC pool self-refreshing instead of a fixed list: the resolver pulls from a cached, health-checked "known-good RPCs" list sourced from chainlist.

Design — no separate worker, no cron/KV

Per the discussion, this lives in the same worker as a shared function rather than a separate service (the resolver calls it in-process, no service-binding round-trip):

  • getHealthyRpcs(ctx) — reads a Cache-API-backed pool; on a cold/stale cache it returns the committed seed list immediately and refreshes in the background via ctx.waitUntil (stale-while-revalidate), so requests never block on the health check.
  • refresh() — fetches chainlist.org/rpcs.json, filters mainnet https URLs (no api-key placeholders, capped for the subrequest limit), health-checks each via checkRpc (ENS forward-resolve of vitalik.eth), and caches the survivors.
  • GET /ens/rpcs — exposes the current pool + freshness (generatedAt, checked, rpcs) for visibility.

The seed list from #10 is the instant cold-start value and the fallback if chainlist/the check is unreachable — no hard dependency.

Verified live (pr-test deploy)

  • /ens/rpcschecked: 41, rpcs: 16 — the pool grew from the 7 seed to 16 chainlist-verified RPCs.
  • generatedAt advances as the background refresh runs; /ens/resolve/vitalik.eth → 200.
  • Live api.ensideas.com untouched.

Tests (15 total, +7)

fetchChainlistRpcs (filtering), checkRpc (429/network-fail → false), getHealthyRpcs (cold→seed+refresh, fresh→no refresh, stale→serve+refresh via mocked Cache API + deps). verify-rpcs now shares checkRpc.

Notes

  • Health-checks run from Cloudflare's egress, so the pool is "RPCs that work from the worker" — some public RPCs rate-limit CF IPs and get filtered out (that's the point).
  • The resolver still edge-caches resolutions 24h, so getHealthyRpcs only runs on cache misses; the refresh only runs on its own ~5-min cache miss, in the background.

🤖 Generated with Claude Code

Instead of a fixed list, the resolver now pulls from getHealthyRpcs():
a Cache-API-backed pool that health-checks chainlist's mainnet RPCs
(+ the committed seed) and caches the survivors, refreshing in the
background (stale-while-revalidate via waitUntil) so requests never
block on the check. Same worker, no cron/KV — the seed list is the
instant cold-start + fallback. Adds a GET /ens/rpcs endpoint exposing
the current pool + freshness. Live check: 41 candidates tested, 16
healthy (pool grew 7 -> 16). verify-rpcs now shares the checkRpc logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ens-ideas Error Error Jul 9, 2026 9:52pm

Request Review

It's a meta/diagnostic endpoint, not part of the /ens API surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@frolic

frolic commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #21, which brings this forward onto the v2 monorepo API worker (apps/api). This branch was built on the pre-restructure api/src layout; #21 ports the same design — the rotating fallback transport, the chainlist-backed self-refreshing pool, and the /rpcs endpoint — and validates it on a preview stage (41 candidates checked → 16 healthy). The work isn't lost, just rebuilt on the current structure.

@frolic frolic closed this Jul 14, 2026
frolic added a commit that referenced this pull request Jul 15, 2026
Brings **#10** (RPC rotation) and **#11** (chainlist-backed healthy
list) forward onto the v2 monorepo API worker, rebuilt around a cron +
KV.

## How it works
- **`ethereumTransport`** — viem `fallback` across the free RPCs in
random order, paid endpoint (`ETHEREUM_RPC_URL`) last. viem retries each
transport zero times and advances on any non-user error, so a **429
rolls over transparently** — keeping paid usage, and cost, minimal.
Shuffling spreads load (a stateless Worker can't round-robin).
- **Cron (`0 * * * *`)** — health-checks the chainlist candidates and
writes the survivors to **KV**, once for the whole fleet.
- **Request path** — one KV read:
  ```ts
const healthy = (await env.RPCS.get<string[]>(RPCS_KEY, "json")) ?? [];
  transport: ethereumTransport(healthy, env.ETHEREUM_RPC_URL)
  ```
A cold/empty list just means straight to the paid RPC — the transport
already handles that.

## Why cron + KV rather than refreshing on the request path
`caches.default` is **per-colo**. Refreshing lazily meant *every* data
center re-ran the full 41-endpoint health-check pass hourly — roughly
**2k check requests/hour** against the free RPCs the rotation exists to
lean on, which is how you get rate-limited by them. The cron does
**~41/hour**, once. And because a cron runs in a single colo, the list
has to live somewhere global — hence KV, not the Cache API.

## What that deleted
Falling back to the paid RPC on a cold KV removed the seed list, and
with it most of the machinery:
- `rpcUrls.ts` (7-endpoint seed list), `getHealthyRpcs.ts` (cache +
refresh + staleness), `scripts/verify-rpcs.ts`, and a dead `alchemy.run`
esbuild stub in the tests.
- Gone as concepts: `waitUntil`, the `Age` check, the `refreshing`
dedupe flag, and the two competing TTLs.

## Verified on a preview stage
- Cron registered: `['0 * * * *']`; per-stage KV namespace created.
- A triggered refresh wrote the health-checked list to KV (nodereal,
publicnode, mevblocker, regional blxrbdn, …).
- **Cold KV** → resolves via the paid RPC. **Warm** → resolves via the
KV list.
- Typecheck clean; **15/15 tests**, including the cold-KV→paid path in
both the transport unit test and the miniflare integration test (which
now runs with an empty KV).

**Note:** the deploy token needed `Workers KV Storage: Edit` added.

Supersedes #10 and #11.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant