Skip to content
ShiningRayPublic

About

Ethereum JSON-RPC reverse proxy: multi-upstream failover, sync-aware health checks and tiered caching

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

ethproxy

中文文档

A reverse proxy for Ethereum JSON-RPC: multi-upstream weighted load balancing with failover, sync-aware health checking, consistency-preserving latest handling, and tiered (nature-aware) response caching.

Features

  • Multiple upstreams: weighted round-robin; automatic failover to the next healthy node on transport errors (side-effecting methods like eth_sendRawTransaction are never retried)
  • Health & sync checks: background polling of eth_syncing + eth_blockNumber + eth_chainId; nodes that are syncing, exceed the consecutive-failure threshold, or lag the pool head by more than maxBlockLag are removed and rejoin automatically once recovered
  • Per-upstream method allow/deny lists: an upstream can declare which RPC methods it may serve (upstreams[].methods). Requests whose method the upstream cannot serve skip it entirely and route to one that can, so provider-specific restrictions (e.g. publicnode's free tier rejecting eth_getTransactionReceipt) cost nothing at runtime instead of surfacing as 403s
  • Upstream pacing & rate-limit backoff: optional per-upstream token buckets (upstreams[].rateLimit) cap the request rate this proxy sends to each upstream — requests prefer upstreams whose bucket has a token ready and wait only when none does, while health polls skip the round instead. When an upstream still rejects with a rate-limit signal (HTTP 429, honoring Retry-After, or a rate-limit JSON-RPC error), it is parked (upstreamCooldown.defaultMs, capped at maxMs; per-upstream cooldownMs overrides): routing skips it and health polls pause until the cooldown clears. Retryable requests that hit a 429 fail over to the next upstream
  • newHeads head tracking: when an upstream has a working WS endpoint, the pool keeps a persistent eth_subscribe("newHeads") connection and updates the observed chain head from each notification (reconnects with backoff); when WS is unavailable or drops, head tracking falls back to the HTTP health poll automatically. Can be turned off with health.wsHeads: false (heads then come from HTTP polling only, WS availability via per-poll probe)
  • Reorg detection: every distinct announced head is checked for parentHash continuity against a sliding window of recent headers (reorg.windowSize, default 128). Conflicts are arbitrated before alarming — a divergent head only becomes a confirmed reorg when the chain builds on top of it or a second distinct upstream reports the same hash, so a briefly-disagreeing node produces no false positives. Confirmed reorgs are logged with depth and fork range, counted in ethproxy_reorgs_detected_total / ethproxy_reorg_depth, and fanned out via pool.onReorg. Requires health.wsHeads (HTTP polling sees block numbers only)
  • Head block cache warming: every newHeads payload is warmed into the response cache as if eth_getBlockByNumber/eth_getBlockByHash had just been served for the new head (header-only notifications trigger one background eth_getBlockByHash fetch against the announcing upstream; payloads carrying transactions are cached directly). Entries use the short head TTL so a reorged head expires quickly, and duplicate announcements across upstreams are deduped by block hash
  • Chain consistency guard: with chainId configured, upstreams reporting a different chain id are excluded outright (protects against misconfigured nodes on other chains); without it, the majority chain id is adopted as the reference
  • Tiered caching (not blind caching):
    • Permanent: hash-addressed immutable data (blocks, transactions, mined receipts), queries at finalized depth (block ≤ head − finalityDepth), chain constants (eth_chainId etc., 1h TTL)
    • Short TTL (default 2s): head-dependent data such as eth_gasPrice
    • Never cached: write methods like eth_sendRawTransaction, pending tags, future blocks, admin namespaces (admin_*/debug_*/…), unrecognized methods (fail-safe)
  • latest consistency: latest tags — including the implicit latest when the block param is omitted — are translated to the locally observed pool head H before caching/forwarding. All clients read the same height within a poll window and cache keys stay stable as (method, H). Translated requests route only to nodes with blockNumber >= H; if none qualify, the original latest request is forwarded without caching its response. eth_blockNumber is answered directly from the local head with zero upstream calls
  • Pluggable cache backends: in-memory LRU (default), on-disk (filesystem) or Redis; implement the CacheBackend interface to add your own
  • Batch requests: each element goes through the cache pipeline individually; misses are split by cacheability (cacheable items via single-flight, the rest merged into one upstream batch)
  • Sticky filter routing: eth_newFilter/eth_newPendingTransactionFilter responses get a proxy-issued globally unique id (node-local ids collide across nodes); eth_getFilterChanges/eth_getFilterLogs/eth_uninstallFilter are rewritten back and pinned to the owning upstream. Mappings expire after filters.stickyTtlMs idle (default 5 min, matching geth's filter timeout) and refresh on every poll. When head tracking is active (health.wsHeads), eth_newBlockFilter is answered entirely locally — the proxy issues the id and serves changes from its observed-head buffer with zero upstream calls
  • Single-flight: concurrent misses for the same cache key share one upstream request, preventing the thundering herd when short-TTL entries expire
  • Public-RPC hardening: admin_*/debug_*/personal_*-style namespaces are rejected by default; batch size, request body size and eth_getLogs block span are limited
  • WebSocket split handling: regular JSON-RPC over WS goes through the same proxy pipeline as HTTP (caching, load balancing, retries); eth_subscribe("newHeads") is served locally — the proxy issues subscription ids and fans out heads observed by the pool's own upstream subscriptions (deduped by block hash), so N clients no longer pin N upstream connections; with txpool.mirror: true the same applies to eth_subscribe("newPendingTransactions") (hashes fanned out from the pool's own feed, deduped), and with syncing.mirror: true to eth_subscribe("syncing") (aggregated: syncing while ANY upstream is syncing, answered immediately on subscribe, then changes only); other eth_subscribe/eth_unsubscribe calls pass through over a per-client pinned upstream WS connection, with notifications relayed as-is; if the pinned connection dies the client connection is closed (clients should reconnect and re-subscribe)
  • Ops endpoints: GET / landing page (live chain height, per-upstream health/WS/latency/forwarded-call counts, locally served answer counts), GET /healthz (503 when no healthy upstream), GET /status (JSON status incl. per-upstream forwarded call counts, cache hit/miss stats and local-answer breakdown), GET /metrics (Prometheus: per-method request counts & duration histograms, per-upstream forwarded calls, upstream health/height/latency, cache stats, ethproxy_local_responses_total by kind)

Quick start

npm install
cp config.example.yaml config.yaml   # edit upstreams to match your nodes
npm run dev -- config.yaml           # development
# or
npm run build && npm start -- config.yaml

Point your clients at http://127.0.0.1:8545.

Docker

docker build -t ethproxy .
docker run -p 8545:8545 -v "$PWD/config.yaml:/app/config.yaml:ro" ethproxy

The config file is injected via mount; a custom path can be passed: docker run ... ethproxy /etc/ethproxy/prod.yaml.

Configuration

See config.example.yaml. Key options:

Option Description Default
upstreams[].url / weight / wsUrl Upstream HTTP endpoint, weight, and WS endpoint (derived from url when unset) —
upstreams[].rateLimit Client-side pacing (token bucket) capping the request rate sent to this upstream: requestsPerSecond + optional burst (default ceil of the rate). An empty bucket prefers other upstreams — wait only when none is ready. One token per HTTP request (a batch counts once) — (unlimited)
upstreams[].cooldownMs Per-upstream override of upstreamCooldown.defaultMs —
upstreams[].headers Extra HTTP headers sent with every request to this upstream — JSON-RPC POSTs (including health polls) and the WebSocket handshake. Names are case-insensitive; values may carry API keys (keep the config out of VCS) —
upstreams[].methods.allow / .deny Method-level routing: the upstream is not selected for a request whose method it may not serve, so a known provider restriction reroutes instead of failing (publicnode's free tier 403s eth_getTransactionReceipt). Entries are exact names or namespace globs (debug_*); a batch is filtered by the union of its methods; deny wins over allow — (unrestricted)
upstreamCooldown.defaultMs / maxMs Cooldown after a rate-limit response (HTTP 429 or rate-limit RPC error): routing and health polls skip the upstream until it expires; maxMs caps Retry-After-derived values 15000 / 300000
statusPagePath Path of the HTML status page; false disables the page entirely (/status JSON is unaffected) /
chainId Expected chain id (e.g. 1 = mainnet); majority wins when unset auto-detect
health.pollIntervalMs Health poll interval 5000
health.maxBlockLag Blocks behind the pool head before a node is removed 5
health.failureThreshold Consecutive failures before removal 3
health.maxRetries Upstreams tried per request 2
health.retryBaseDelayMs / retryMaxDelayMs Exponential retry backoff: base * 2^(n-1), capped at max 100 / 1000
health.wsHeads Track chain heads via a persistent eth_subscribe("newHeads") WS connection per upstream (HTTP poll fallback while WS is down); false = HTTP-only heads + per-poll WS probe true
health.wsPingIntervalMs Client-side keepalive ping interval on the upstream WS connection; no pong for two intervals = dead link, terminate and reconnect. Guards against provider gateways idle-dropping silent connections (close 1006); 0 disables 30000
reorg.enabled / reorg.windowSize Reorg detection from upstream newHeads: parentHash continuity against a sliding header window, arbitrated across upstreams (a conflict confirms only when the chain builds on it or a second upstream reports it); confirmed reorgs are logged, counted (ethproxy_reorgs_detected_total, ethproxy_reorg_depth) and fanned out via pool.onReorg. Requires health.wsHeads true / 128
filters.stickyTtlMs Idle TTL for sticky filter-id mappings; refreshed on every poll 300000
txpool.mirror Mirror pending transactions: the pool keeps an eth_subscribe("newPendingTransactions") feed per WS-capable upstream and serves client subscriptions locally (deduped hashes); false = pass through false
syncing.mirror Mirror sync status: serve client eth_subscribe("syncing") locally from the aggregated pool view — syncing (progress object) while ANY upstream is syncing, false once none are (immediate answer on subscribe, then changes only); false = pass through false
cache.backend memory, filesystem or redis memory
cache.enabled Master switch; when false every request bypasses the cache (and Redis is never connected) true
cache.shortTtlMs TTL for head-dependent data (ceiling/fallback when dynamicTtl is on) 2000
cache.unfinalizedTtlMs Fallback TTL for the seven reorg-validated methods' entries below finalityDepth (correctness comes from read-time reorg validation) 900000
cache.dynamicTtl Derive short TTL from the observed block interval (interval/4, clamped to [minTtlMs, shortTtlMs]) true
cache.finalityDepth Depth below which blocks are treated as immutable 64
cache.redis.url / keyPrefix Redis connection and key prefix —
cache.filesystem.dir Directory for cache files (backend: filesystem); created on demand, contents are plain files (one JSON envelope per entry). Expiring entries live in time buckets (t/), permanent ones in 256 shards (p/), and short-TTL entries stay in a bounded in-memory tier ./cache
cache.filesystem.sweepIntervalMs Background sweep period: deletes whole expired buckets and enforces maxBytes; 0 disables (expired entries still drop lazily on read) 60000
cache.filesystem.maxBytes Soft disk budget; when a sweep finds the total above it, oldest buckets and oldest-written permanent files are evicted first 1073741824
cache.filesystem.inlineTtlMs Entries with a TTL at or below this are kept in a bounded in-memory LRU instead of on disk — high-cardinality short-TTL keys written one-file-per-entry would otherwise churn the kernel's dentry/inode caches, which cgroup accounting reports as memory growth; 0 disables 60000
cache.filesystem.inlineMaxEntries Max entries held by the in-memory tier 10000
cache.filesystem.bucketMs Time-bucket width for on-disk expiring entries; the sweep removes whole expired buckets without stat-ing or reading individual files 3600000
security.blockedNamespaces RPC namespaces rejected outright admin, personal, debug, trace, miner, txpool
security.maxBatchSize / maxBodyBytes / maxLogsRange Batch element limit, body size limit, eth_getLogs span limit 100 / 1MB / 10000
rateLimit.enabled Per-client-IP rate limiting (HTTP 429 / WS error -32005) true
rateLimit.requestsPerSecond / burst HTTP rate and burst (batches cost their element count) 50 / 100
rateLimit.wsMessagesPerSecond / wsBurst WS message rate and burst 20 / 40
rateLimit.maxSubscriptionsPerIp Max concurrent subscriptions per IP (across connections; freed on unsubscribe/disconnect) 20
cors.enabled / cors.origin CORS settings; * allows any origin, comma-separate multiple origins true / *

Caching details

The decision logic lives in src/cache-rules.ts: requestPolicy(method, params, ctx) decides cacheability at request time, and responseTtl() refines it based on the response (e.g. eth_getTransactionReceipt is cached permanently only when it carries a blockHash; a null result gets a very short TTL). Cache keys are method + sha256(normalized params), except the seven reorg-validated methods below.

Reorg-validated entries (eth_getBlockByNumber, eth_getBlockTransactionCountByNumber, eth_getTransactionByBlockNumberAndIndex, eth_getUncleByBlockNumberAndIndex, eth_getUncleCountByBlockNumber, eth_getTransactionByHash, eth_getTransactionReceipt): these use plain-text keys — method:normalized-params with quantities canonicalized to minimal lowercase hex — so external tooling can build keys to read or invalidate entries directly. Entries below cache.finalityDepth are stored with cache.unfinalizedTtlMs instead of the short TTL, stamped with the canonical block hash observed at write time; on every read the stamp is checked against the reorg detector's header window, so a reorged entry turns into a miss (and is deleted) the next time it is read — invalidation is driven by reorg detection, not by TTL. Entries that cannot be validated (window gap, detection disabled) fall back to plain TTL semantics. Requires health.wsHeads and reorg.enabled; reorg.windowSize must be ≥ cache.finalityDepth (enforced at config load).

Extending cache backends

Implement the interface from src/cache/types.ts and register it in the factory in src/cache/index.ts:

interface CacheBackend {
  get(key: string): Promise<string | null>;
  set(key: string, value: string, ttlMs: number | null): Promise<void>; // null = no expiry
  delete(key: string): Promise<void>;
  close(): Promise<void>;
}

Backend failures degrade gracefully to cache misses; proxying is never blocked by the cache.

Deploying behind Cloudflare (DDoS mitigation)

ethproxy itself covers the application layer (method blocklist, request shape limits, cache absorption, single-flight). Full protection should be layered, with Cloudflare and infrastructure taking the outer layers:

Cloudflare (highest payoff, do first):

  • L3/L4 volumetric attacks (SYN floods, UDP amplification) are absorbed by CF's proxied mode, invisible to the origin
  • Enable managed WAF rules, Rate Limiting (e.g. N requests/s per IP), Bot Fight Mode
  • Origin lockdown: allow ports 80/443 only from Cloudflare's official IP ranges in your security group/firewall, so attackers cannot bypass CF and hit the origin directly — without this, CF protection is moot
  • Prefer SSL mode Full (Strict) + a Cloudflare Origin Certificate

nginx / gateway:

  • limit_req_zone per-IP rate limiting, limit_conn connection caps
  • WebSocket upgrade header mapping and long timeouts
  • Forward the real client IP (X-Forwarded-For / CF's CF-Connecting-IP) for downstream rate limiting

ethproxy (implemented):

  • Dangerous namespaces rejected by default (debug_traceTransaction alone can pin a node's CPU for seconds — a classic application-layer attack)
  • Batch size, body size and eth_getLogs span limits
  • Tiered caching + single-flight: hot read paths barely touch upstreams
  • Health checking + failover: fail fast instead of piling up requests when upstreams degrade

Reference topology (this project's production setup):

clients → Cloudflare (WAF/rate limits/TLS) → nginx (WS upgrade/limits) → ethproxy (127.0.0.1:8545) → upstream nodes

Development

npm test          # vitest (unit + integration, mocked upstreams)
npm run typecheck # tsc --noEmit

Out of scope (for now)

  • Automatic re-subscription for WebSocket subscriptions (when the pinned upstream connection dies the client connection is closed; clients own reconnect + re-subscribe)
  • Authentication

About

Ethereum JSON-RPC reverse proxy: multi-upstream failover, sync-aware health checks and tiered caching

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages