A REST API for Ethereum Execution Clients
Adrian Sutton
The JSON-RPC API that Ethereum execution clients expose is genuinely awful to
work with. Every call is a POST with a jsonrpc envelope, a method string
and a positional params array. Numbers come back as hex strings — block
numbers, balances, gas, timestamps, all of it — so even after you’ve wrestled
the request together you’re piping the response through something to turn
0x1bc16d674ec80000 back into a number a human can read. It does just about
everything it can to be hostile to a quick curl.
Tools like Foundry’s cast make it usable,
and I reach for them constantly, but they’re still fiddly for ad-hoc poking and
they don’t help at all when something other than your terminal wants to talk to
the client. What really bugs me is that we designed such a bad API in the first
place and then built a layer of tooling to paper over it.
So, naturally, I built another layer to paper over it. exec-rest-api is a proxy that sits in front of any execution client and exposes a sensible REST API, translating your requests into the JSON-RPC calls the client actually wants.
The Awful API
Here’s what asking for the current chain ID looks like today:
curl -X POST -H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' \
http://localhost:8545
# {"jsonrpc":"2.0","id":1,"result":"0x1"}
A POST with a hand-written envelope, to ask a question that has no parameters,
and the answer is 0x1 rather than 1. Want the block number too? That’s a
second round of envelope-writing and another hex value to decode. None of this
is hard, exactly, it’s just tedious in a way that adds up every single time.
The same thing through the proxy:
curl http://127.0.0.1:8080/chain
# {"chainId": 1, "networkId": "1", "client": "Geth/v1.13.5...", ...}
A plain GET, no envelope, decimal numbers, and a bit of extra context thrown
in for free. That’s the whole idea.
What It Does
The API is organised the way you’d actually think about the chain rather than
the way the RPC methods are named. There are resources for /chain, /blocks,
/accounts, /transactions, /logs, /traces and /gas, plus a
/utils/keccak256 helper for the inevitable moment you need to hash something.
Behind each of those it’s still making the eth_getBlockByNumber and
eth_getBalance and friends that the client understands — you just don’t have
to think about it.
Hex quantity notation is stripped out everywhere, so numbers are numbers. Errors come back as RFC 9457 problem details instead of the JSON-RPC error shape, and anything that returns a list uses RFC 8288 cursor pagination rather than making you guess at ranges. If you do need the raw bytes — a block or transaction as RLP — content negotiation will give you that instead.
There’s a /health resource that’s genuinely useful for orchestration:
curl http://127.0.0.1:8080/health/ready
# {"ready": true, "upstreamReachable": true, "syncing": false, ...}
That single call rolls up “is the upstream reachable” and “is it still syncing”
into one readiness check, which is exactly the thing you otherwise end up
scripting by hand around eth_syncing.
For the cases where polling is the wrong model, there are Server-Sent Events
streams at /streams/blocks, /streams/logs, /streams/pending-transactions
and /streams/sync-status. SSE is a much friendlier thing to consume from a
script or a small service than the WebSocket subscription dance, and it works
with curl directly. There’s also a Prometheus /metrics endpoint and
request-tracing headers — X-Request-ID, X-Upstream-Method, X-Block-Height
— so when something looks wrong you can see exactly which JSON-RPC call your
request turned into.
Running It
The thing I cared most about was that it be trivial to start, because the whole
point is ad-hoc use. If you’ve got pipx:
pipx install exec-rest-api
exec-rest-api --upstream-http http://localhost:8545
There’s a plain pip install too, or a single-file .pyz you can download from
the releases and run directly:
./exec-rest-api.pyz --upstream-http http://localhost:8545
Or Docker, if you’d rather not install anything:
docker run --rm -p 8080:8080 \
ghcr.io/ajsutton/exec-rest-api:latest \
--upstream-http http://host.docker.internal:8545
It works with any execution client — it’s only ever speaking standard JSON-RPC upstream — so point it at Geth, Nethermind, Besu, Reth, whatever you’re running. The same low ceremony means it’s just as happy left running as a long-lived proxy in front of a node as it is spun up for thirty seconds to answer one question.
Is this likely to get any real use? Probably not. The current JSON-RPC is really very entrenched at this point but I do like how easy it is so solve and at least my home node can now expose a nice API. You’ll also note that it differs from the Ethereum consensus client REST API in some conventions because if I’m being overly opinionated about APIs I may as well run with it and do it the way I think the Consensus API should have been done. It didn’t do something as awful as use hex for numbers, but putting version numbers in URLs is almost as bad…