One endpoint for every RPC provider. On your infrastructure.
Smart Router sits between your services and the providers you already contract with. It runs on your infrastructure, your keys and contracts stay yours, and it records why it chose each upstream. A routing layer you operate and inspect, not a black box you send traffic through.
Would rather not operate it yourself? Smart Router Cloud runs the same software for you →
git clone https://github.com/Magma-Devs/smart-router.git
cd smart-router
docker compose -f docker/docker-compose.yml up --buildcurl -X POST http://127.0.0.1:3360 \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'A provider fails. The request still lands.
Three upstreams are configured and taking traffic. One goes down: the failing provider leaves the request path and rejoins after a successful relay. Unservable requests give up inside their budget instead of hanging.
Defaults: 2 retries · 30s relay budget · 10 attempts · 1,000 sessions/node · health check 5m
Provider incidents are not hypothetical. Our RPC provider tracker logs them continuously.
- ├──Infurahealthy34%
- ├──Alchemyhealthy33%
- └──Private Nodehealthy33%
// All upstreams eligible. Share follows the routing strategy.
- request rate
- 1.2k rps
- p95 latency
- 78 ms
- retries / min
- 0
- failovers / min
- 0
- upstreams eligible
- 3 of 3
Illustrative product demo. Values are not benchmarks.
0 ms request received
↓
4 ms upstream alchemy selected
↓
310 ms response still pending
↓
hedge sent to infura
↓
355 ms infura responds
↓
first valid response returnedAnd if all three are down at once.
Smart Router forwards requests, it does not sync the chain, so an empty pool has nothing behind it. Your backup-direct-rpc tier takes over if configured; otherwise the upstream's own error reaches your client unmodified.
It removes single-provider risk, not the need for more than one provider.
Nothing here is hidden, and every default is yours to change.
Set the limit to 0 to turn retries off, or set your own per-attempt budget per request.
A slow attempt gets a parallel retry before the first times out, on a tick derived from block time and method. Read paths only.
Checks the response against what the request asked for, so a wrong block or truncated result is caught at the routing layer.
2 consecutive empty-pool errors trip the breaker: the relay then fails immediately instead of looping until the timeout.
eth_sendRawTransaction and its equivalents go to every eligible upstream in parallel, so one provider dropping your transaction does not lose it.
Keyed by chain, method, params and resolved block: finalized reads 1h, latest-style 500ms. Writes and pending state are never cached, and a request header forces a refresh.
A 200 response can still be wrong.
For calls where one response is not enough, Smart Router sends the same request to independent providers and compares results before returning one.
Set how many providers participate and how many must agree. Group labels can require separate infrastructure. Callers can raise participation, never shrink the operator's minimum.
When the quorum does not hold the request fails and says why. A minority answer never becomes the response.
no-agreementinsufficient-responsesdiversity-unmetinsufficient-capacityinsufficient-groupsUse it on selected methods, not every call across every provider.
- Provider Apending…
- Provider Bpending…
- Provider Cpending…
cross-validation:
policies:
- chain-id: "ETH1"
api-interface: "jsonrpc"
method: "eth_getLogs"
enabled: true
agreement-threshold: { floor: 2 }
max-participants: { floor: 3, cap: 5 }
- chain-id: "ETH1"
api-interface: "jsonrpc"
method: "eth_blockNumber"
forbid-caller-cv: trueThe next provider is not picked at random.
Smart Router scores upstreams on observed latency, sync freshness and availability. A fast node behind the chain does not win; a provider gets less attractive as its latency or error rate climbs.
provider latency block errors score
alchemy 71 ms current 0.1% 0.95
infura 84 ms current 0.2% 0.91
quicknode 96 ms current 0.4% 0.83
node-03 42 ms -4 blocks 0.0% 0.48Scoring is also how a bad node leaves and returns: an endpoint that keeps failing is disabled until a successful relay or the epoch tick. Health checks 5m · optional 5s liveness probe · 1,000 sessions per node.
smartrouter config.yml \
--use-static-spec specs/ \
--strategy latencyKeep your client. Keep your providers.
It sits in front of the RPCs you already use and decides where each request goes. Point your existing client at it. No SDK, no Magma-specific request format.
Infura
▲
│
viem │
ethers ──▶ Smart Router ──┼──▶ Alchemy
web3.py │
backend ├──▶ QuickNode
├──▶ Helius
└──▶ your nodeimport { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
const client = createPublicClient({
chain: mainnet,
transport: http('http://127.0.0.1:3360'),
});Per-upstream auth covers headers, query keys and mTLS. Inbound auth, CORS and rate limiting stay at the edge.
Interfaces are set per listener with api-interface and must be supported by the chain spec.
See which RPC is slow before your users tell you.
| provider | latency | block | errors | share | score |
|---|---|---|---|---|---|
| Infura | 82 ms | current | 0.2% | 38% | 0.91 |
| Alchemy | 72 ms | current | 0.1% | 41% | 0.95 |
| QuickNode | 95 ms | current | 0.4% | 15% | 0.83 |
| Node-03 | 44 ms | -4 blocks | 0.0% | 6% | 0.48 |
- hedge eth_call → alchemy after 300ms on infura
- retry eth_getLogs → quicknode (upstream 503)
- skip node-03 lag 4 blocks > threshold 2
- cache hit eth_getBlockByNumber (block 21_904_118)
- xval eth_getLogs 3 participants, 3 agree
- probe node-03 back within lag threshold
Prometheus metrics on metrics-listen-address, OpenTelemetry traces with W3C context propagation, and an optional dashboard for per-upstream share, percentiles, retries, hedges, cache hits and mismatches.
The highest-signal alert is any cross-validation mismatch on finalized data, where an upstream disagreed about history that cannot change. Starting alert set in the docs: no endpoint serving, error ratio > 5%, p99 > 2s, cache hits < 20%.
Keep the routing config in your repo.
One YAML file defines the listeners it opens (endpoints) and the upstreams it routes to. Provider keys stay behind ${var} placeholders, rendered at launch from a gitignored .env.
A config the router cannot serve fails at startup. Changes need a restart, and a 25s drain means rolling one instance at a time. Pin the image: a major bump means a breaking change.
Working configs ship under config/smartrouter_examples/. The wizard generates and health-checks a starting setup from your chains and upstreams.
1endpoints: 2 - network-address: "0.0.0.0:3360" 3 chain-id: "ETH1" 4 api-interface: "jsonrpc" 5 6direct-rpc: 7 - name: "eth-provider-a" 8 chain-id: "ETH1" 9 api-interface: "jsonrpc"10 group-label: "external"11 node-urls:12 - url: "https://your-eth-provider.example.com"13 timeout: 10s14 auth-config:15 auth-headers:16 x-api-key: "${RPC_KEY_ETH}"17 - url: "wss://your-eth-provider.example.com/websocket"1819 - name: "eth-own-node"20 chain-id: "ETH1"21 api-interface: "jsonrpc"22 group-label: "internal"23 node-urls:24 - url: "http://10.0.4.11:8545"25 - url: "ws://10.0.4.11:8546"smartrouter path/to/config.yml \
--use-static-spec specs/Start with an example that already runs.
Other chains resolve from the spec catalog at startup. Supported chains
Don't take our word for it. Run it.
Clone Smart Router. Add your RPC endpoints. Put traffic through it. Then kill one, kill all of them, and kill the router.
It is stateless: run 2+ instances behind your load balancer. /metrics/overall-health returns 503 when no upstream is serving. Wire it to your LB and your pager.
git clone https://github.com/Magma-Devs/smart-router.git