Read-only JSON-RPC switch across a pruned node and verified archive endpoints
  • Go 94.1%
  • Solidity 4.9%
  • Dockerfile 0.5%
  • Makefile 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
dev 43f01ad546
All checks were successful
CI / Go (push) Successful in 4m22s
Keep head reads alive across slow probe rounds
Otterscan showed "It does not seem to be an ETH node" at random: its startup
probe fails when eth_blockNumber does, and a head read was returning
-32603 "no healthy endpoint" roughly 1% of the time.

Selection admitted an endpoint for a head read only while its head observation
was under 3 probe intervals old, and a round refreshed no observation until
every endpoint had been probed AND every due retention verification had run,
one after another. Four endpoints re-verifying together every ten minutes, or
one upstream sitting on the probe timeout, pushed the whole round past nine
seconds, so every endpoint aged out at once and nothing was left to serve.

A head read is answered from the endpoint's own head, so how recently the
prober polled it is not evidence about its ability to answer. When staleness
alone empties the candidate list, selection now retries against endpoints that
were in step when last seen, up to one full round's budget, and reports the
decision as stale_head; beyond that the prober is broken and the read still
fails. Rounds also apply their outcomes concurrently and stamp each head with
the time the endpoint answered rather than the time the round started.
2026-09-02 19:27:14 -04:00
.forgejo/workflows Initial commit 2026-09-02 15:28:41 -04:00
cmd/icu-rpc-switch Initial commit 2026-09-02 15:28:41 -04:00
config Initial commit 2026-09-02 15:28:41 -04:00
contracts Initial commit 2026-09-02 15:28:41 -04:00
internal Keep head reads alive across slow probe rounds 2026-09-02 19:27:14 -04:00
.dockerignore Keep head reads alive across slow probe rounds 2026-09-02 19:27:14 -04:00
.env.example Initial commit 2026-09-02 15:28:41 -04:00
.gitignore Keep head reads alive across slow probe rounds 2026-09-02 19:27:14 -04:00
docker-compose.yml Initial commit 2026-09-02 15:28:41 -04:00
Dockerfile Initial commit 2026-09-02 15:28:41 -04:00
go.mod Initial commit 2026-09-02 15:28:41 -04:00
go.sum Initial commit 2026-09-02 15:28:41 -04:00
LICENSE Initial commit 2026-09-02 15:28:41 -04:00
Makefile Initial commit 2026-09-02 15:28:41 -04:00
README.md Keep head reads alive across slow probe rounds 2026-09-02 19:27:14 -04:00

icu-rpc-switch

One read-only JSON-RPC URL in front of a pruned node and one or more archive endpoints. Every call is routed by the oldest block it needs: recent reads race the local node against the archives, older reads go only to endpoints whose verified retention covers them, and a pruned node's null is never returned until an archive has confirmed it.

Interfaces

  • POST /rpc/{chainId} — JSON-RPC 2.0, single or batch; POST /rpc aliases defaultChainId.
  • GET /beacon/{chainId}/eth/... — read-only proxy to the chain's beacon API; GET /beacon/eth/... aliases the default chain.
  • GET /healthz, GET /readyz, GET /metrics (Prometheus).

With requireOrigin set, /rpc and /beacon answer only requests that a browser issued from one of allowedOrigins (Origin, Sec-Fetch-Site: same-origin or Referer); bare clients get 403.

pagePolicies pins requests by the referring site path: with {"pathPrefix": "/address/", "tier": "archive"} every call issued from an address page is tried on verified archives first, falling back to the other endpoints once every archive has failed.

Transaction submission, signing, filters, subscriptions and the admin_, personal_, engine_, miner_, debug_, txpool_, clique_ and les_ namespaces are refused with -32601. Unknown methods are forwarded to verified archives only.

Configuration

One strict JSON file — copy config/rpc-switch.example.json to config/rpc-switch.json and fill in your endpoints (the real file stays out of git); unknown fields, duplicate names or chain ids, invalid URLs and out-of-range values are rejected. A positive blockLimit is an endpoint's guaranteed retained depth; null marks a claimed archive, which is verified at archiveProbeBlock before use.

The containing directory is watched: a valid edit (in place or by atomic rename) replaces the active configuration for new requests after a 250 ms debounce; an invalid one is logged and counted while the previous configuration stays active. A broken file at startup exits non-zero without opening the listener. routing.mode (parallel, hedged, best) and every other routing value are hot-reloadable.

Running

make check                                   # fmt, vet, test, race, build, example config
./icu-rpc-switch check-config config/rpc-switch.json
./icu-rpc-switch run -config config/rpc-switch.json -listen :8080

docker-compose.yml runs the switch behind Traefik (copy .env.example to .env for the host name and resolver); the container joins the reverse-proxy network and the node's internal network. An explorer such as Otterscan is deployed separately and pointed at https://<host>/rpc and https://<host>/beacon.

contracts/ holds PulseXNativeUsdOracle, an AggregatorV3-shaped native/USD price feed computed from PulseX stable pools, for explorers that need one.

Verification

curl -s https://<host>/rpc -H 'Content-Type: application/json' -H 'Origin: https://<host>' \
  -d '{"jsonrpc":"2.0","id":1,"method":"ots_getBlockDetails","params":[1000000]}'
curl -s https://<host>/beacon/eth/v1/beacon/headers/finalized -H 'Referer: https://<host>/'

retention_routing_total{decision="archive_only"} and endpoint_wins_total{endpoint="..."} in /metrics show where each class of request was served.

License

MIT — see LICENSE.