Unified RPC proxy backed by chainlist: set chain IDs in env, get one stable JSON-RPC endpoint per chain.
POST http://localhost:9563/1 # Ethereum, by chain id
POST http://localhost:9563/ethereum # same chain, by slug
POST http://localhost:9563/56 # BNB Smart Chain
POST http://localhost:9563/bnb # short names work too
POST http://localhost:9563/solana # exception: served from a static list
POST http://localhost:9563/1/archive # archive-verified upstreams only
ws://localhost:9563/1 # subscriptions (eth_subscribe, logs, …)
ws://localhost:9563/solana # slotSubscribe, accountSubscribe, …
- 🔗 One endpoint per chain — write
CHAIN_IDS=1,56and let chainlist supply the rest. - 🧭 Smart routing — health probes, latency-aware P2C selection, automatic failover, circuit breaker.
- 🕰️ Stale-data guard — nodes lagging behind the pool median are pulled out of rotation.
- 🔌 WebSocket too — the same URL upgrades, so subscriptions work without a second config.
- 🗄️ Archive pool —
/{chain}/archiveonly ever routes to verified archive nodes. - ◎ Solana exception — chainlist is EVM-only, so
/solanaruns off a built-in list. - 📦 Single static binary, no dependencies, ready for Docker/Dokploy.
flowchart LR
C["Client / bot"] -->|POST /1| H["rpchub :9563"]
subgraph rpchub
H --> R["registry<br/>(chainlist + cache)"]
H --> P["pool<br/>(health, P2C, breaker)"]
end
P -->|best healthy endpoint| U1["public RPC #1"]
P -.->|failover| U2["public RPC #2"]
P -.->|failover| U3["public RPC #N"]
Public RPCs from chainlist are individually unreliable — some are dead, some rate-limited, some lagging behind the chain. rpchub puts all of them behind a single endpoint per chain:
- Source:
https://chainlist.org/rpcs.jsonis fetched at boot, refreshed everyREFRESH_INTERVAL, and cached on disk so the service still starts when chainlist is unreachable. URLs are aggressively sanitized:${API_KEY}placeholders, garbage records and invisible characters are all dropped, and each surviving entry is sorted into the HTTP or the WebSocket pool by scheme. - Health: every endpoint is probed periodically (EVM:
eth_blockNumber, Solana:getSlot). Chain identity is verified on first contact (eth_chainId/getGenesisHash) — an endpoint answering for a different chain is excluded permanently. Endpoints more thanMAX_BLOCK_LAGbehind the pool median are pulled out of rotation (stale-data guard). - Selection & failover: power-of-two-choices among healthy endpoints (two random candidates, the lower-latency one wins). Timeouts, connection errors, 429/5xx and non-JSON bodies fail over to the next endpoint (
MAX_RETRIESattempts). JSON-RPC-level errors are the upstream's own answer and pass through verbatim. An endpoint that keeps failing enters an exponential cooldown. - Solana exception: chainlist is EVM-only, so
/solanais served from a built-in public list plusSOLANA_RPCS(mainnet-beta only; the genesis hash is verified). - WebSocket: chainlist's
wss://entries form a second pool per chain, probed and scored exactly like the HTTP one. Connecting a WebSocket client tows://host/{chain}(or/{chain}/ws) picks a healthy upstream and relays the connection, soeth_subscribe,logsSubscribeand Solana'sslotSubscribework through the same URL as ordinary requests. - Archive detection: every healthy EVM endpoint is periodically asked for
eth_getBalance(0x0, block 0x1)— a pruned node fails with a state error, an archive node answers. The verdict is refreshed hourly, because public endpoints often sit behind load balancers mixing archive and pruned nodes.POST /{chain}/archiveroutes only to endpoints positively verified as archive-capable; undetermined ones never receive archive traffic.
CHAIN_IDS=1,56 SOLANA_ENABLED=true go run ./cmd/rpchub
curl -X POST localhost:9563/1 \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}'
curl -X POST localhost:9563/solana \
-d '{"jsonrpc":"2.0","id":1,"method":"getSlot"}'
# subscriptions over the same URL
wscat -c ws://localhost:9563/1 \
-x '{"jsonrpc":"2.0","id":1,"method":"eth_subscribe","params":["newHeads"]}'See .env.example for every knob. The ones that matter most:
| Env | Default | What it does |
|---|---|---|
CHAIN_IDS |
— | Enabled EVM chains (e.g. 1,56,137). The only required setting. |
SOLANA_ENABLED / SOLANA_RPCS |
false |
Enables the /solana path; setting SOLANA_RPCS turns it on automatically. |
EXTRA_RPCS_<id> / EXTRA_RPCS_SOLANA |
— | Your own nodes; they go to the front of the list at the highest priority. |
ALIASES |
— | Extra path tokens: bsc:56 → POST /bsc. |
MAX_BLOCK_LAG |
10 |
Endpoints this many blocks behind the median are benched (×20 slots on Solana). |
FILTER_TRACKING |
false |
true: only use RPCs marked tracking: none in chainlist. |
WS_ENABLED / MAX_WS_CONNS |
true / 256 |
WebSocket relaying and the cap on concurrent relayed connections. |
| Endpoint | Description |
|---|---|
POST /{chain} |
JSON-RPC proxy. {chain} = chain id, slug, short name or alias. Batch requests supported. |
POST /{chain}/archive |
The same proxy, but restricted to endpoints verified as archive-capable. For deep eth_getLogs, historical eth_call / eth_getBalance and similar. Returns 503 until an archive endpoint has been discovered; not available for Solana (404). |
GET /{chain} (with Upgrade: websocket) |
Relays the connection to a healthy wss:// upstream for subscriptions. A plain GET returns a usage hint instead. |
GET /{chain}/ws |
Same relay on an explicit path, for clients that prefer one. |
GET /chains |
Enabled chains, their tokens, healthy/archive/ws/total endpoint counts and the reference height. |
GET /health |
200 when every chain has ≥1 healthy endpoint; warming during boot warm-up; 503 otherwise. |
GET /{chain}/health |
Per-endpoint status/latency/height (URL paths are redacted so API keys cannot leak). |
docker compose up -d --buildOn Dokploy: add the repo as a Dockerfile application, set the env vars in the panel, and point the healthcheck at /health. A volume is defined for CACHE_DIR (/data), so restarts come up from cache even when chainlist is unreachable.
- WebSocket failover only happens while connecting. Subscription ids belong to the node that issued them, so rpchub never switches upstream mid-session — that would silently drop your subscriptions. If the upstream dies, your connection closes and your client should reconnect (it will land on a healthy endpoint).
- No client auth, client rate-limiting or response caching.
- The
/archivepool is bounded by the archive endpoints that can actually be detected: some chains have very few public archive RPCs, in which case the route honestly returns 503. Non-standard methods such astrace_*anddebug_*may be disabled even on an archive node, and that upstream error passes through unchanged.
Apache-2.0 © luen-eth
RPC endpoints are derived from chainlist.org data; rpchub only reads and health-checks that list.