Ethereum JSON-RPC compatibility
Status: metadata, bounded recent-material HTTP, and live WebSocket profiles are implemented. Canonical header/transaction/receipt bytes are decoded into Alloy RPC types. Bounded on-demand number/range queries are implemented for configured sources that declare every required capability complete.
1. Product promise
Leani can replace a paid RPC for the common methods its configured sources can answer exactly. It is not a full execution/archive node.
Three answer paths exist:
- Recent: serve from the rolling raw block/receipt cache.
- On-demand: fetch the relevant historical chunk from Xatu/EraE/another backend, answer slowly, and discard it.
- Materialized: serve from an operator-enabled durable index.
The response should expose a non-standard diagnostic header or companion method indicating source, finality, and whether an answer was recent, on-demand, or materialized.
2. Method matrix
The currently shipped leani-evm-free-v1 profile implements
web3_clientVersion, net_version, eth_chainId, eth_blockNumber,
eth_syncing, eth_config, and leani_getCapabilities over HTTP,
including JSON-RPC batches and notifications. The WebSocket endpoint also
accepts regular calls and implements newHeads and filtered logs
subscriptions from post-commit live events. Methods needing raw blocks,
receipts, or logs return the explicit server error -32004 until that
material is present; state/EVM methods remain unsupported. The capability
response is the machine-readable authority for a running node.
eth_config follows final EIP-7910: it accepts no parameters and returns
current, next, and last fork configurations with JSON-number blob
parameters, chain ID, fork hash, precompiles, and system contracts. Its
checked Mainnet schedule uses exact activation timestamps; it does not call an
upstream configuration RPC. current is the fork active at the timestamp of
the newest retained raw canonical block. This timestamp can be unavailable
even when eth_blockNumber knows a newer canonical anchor.
Forks activate by timestamp, so a node that retains no head frame, such as an
empty one or one that has only backfilled, fails with -32004
(eth_config_head_timestamp_unavailable) instead of choosing a fork by block
number or taking the newest one. A head older than the first scheduled fork
fails with eth_config_head_predates_schedule, and a node whose chain the
schedule is not for fails with eth_config_chain_schedule_unavailable.
Blob transaction receipts report blobGasPrice as the protocol blob base fee
of the fork active at the block’s timestamp, from the same schedule and fee
function as the blobs-money processor. Receipts are built for a whole block,
so when that price cannot be computed, every receipt of a block with a blob
transaction fails with -32004, the receipts of its other transactions
included, from eth_getBlockReceipts and eth_getTransactionReceipt alike,
rather than carrying a guessed price. error.data.reason says why:
blob_gas_price_schedule_unavailable: no scheduled fork covers the block’s timestamp, or the schedule is for another chain. A node on a chain other than Mainnet without a blobs processor uses the checked Mainnet schedule, so the receipts of every block with a blob transaction fail this way.header_excess_blob_gas_missing: the block header has no excess blob gas.quantity_exceeds_u128: the price does not fit in 128 bits. Only an excess blob gas that no valid chain reaches, as a forged header may claim, gets there.
eth_getLogs can use Xatu’s predicate-complete projection under the
trusted-dataset policy. Every requested block and required log field must be
present; a partial projection, a narrower address/topic scope, or a broken
parent link fails explicitly. Complete-cryptographic policy still requires
complete material. Retained raw-log projections can serve the same requests
without upstream access when their scope and fields cover the query. Decoded
processor entities alone are not a lossless raw-log store: enable on-demand
history or retain suitable raw material for historical RPC.
eth_blockNumber, latest, and eth_syncing.currentBlock report the
canonical tip: the newest delivered block at any level, unattested
included, as an Ethereum RPC’s latest (see
finality levels). They report it independently of raw-frame pruning, or the highest block the
progress processor has finalized when that is higher or no tip is known, as
on a node without live following. A node that knows no block
reports 0x0, as a fresh Ethereum client does; eth_blockNumber never
fails for want of a head. A reported head can lag the network, while
disconnected or on a historical-only node; consult eth_syncing, which
reports liveReady: false then, before treating it as the network tip.
For a number/range request outside the recent window, the router selects the
lowest-priority viable HistorySource, enforces finality/trust and hard
byte/frame/concurrency/timeout limits, validates ordered parent-linked frames,
and fails over only at the whole-request boundary. Frames are discarded after
serialization. A range crossing into the recent window must have an exact
parent-hash handoff. Hash-only transaction/receipt history still requires a
durable locator and therefore remains recent-only by default.
| Method | EVM-free support | Default behavior | Caveat |
|---|---|---|---|
web3_clientVersion |
Exact | Local | — |
net_version |
Exact | Local | — |
eth_chainId |
Exact | Local | — |
eth_blockNumber |
Exact for the known canonical chain | Canonical tip or finalized coverage | 0x0 before any block is known; never an unfinalized processor cursor |
eth_syncing |
Exact for this node | Canonical head and live readiness | A disconnected or historical-only node does not claim live readiness |
eth_getBlockByNumber |
Exact with body source | Recent/on-demand | Historical latency depends on backend |
eth_getBlockByHash |
Exact with locator | Recent; optional index | Hash-to-chunk lookup otherwise expensive |
eth_getBlockTransactionCount* |
Exact with body | Recent/on-demand | — |
eth_getTransactionByBlock* |
Exact with body | Recent/on-demand | — |
eth_getTransactionByHash |
Exact with locator | Recent; optional index | Global tx locator consumes storage |
eth_getTransactionReceipt |
Exact with receipt locator | Recent; optional index | Same lookup issue |
eth_getBlockReceipts |
Exact with receipts | Recent/on-demand by block | Excellent fit for P2P/EraE |
eth_getLogs |
Exact with complete logs | Recent/on-demand/materialized | Filtered datasets are trusted for completeness |
eth_feeHistory |
Not shipped | Explicit unavailable error | Requires a separately tested client-compatible policy |
eth_gasPrice |
Planned | Unsupported | Client policy semantics differ |
eth_maxPriorityFeePerGas |
Planned | Unsupported | Client policy semantics differ |
eth_subscribe(newHeads) |
Exact | Live | Replacement heads are emitted after branch commit |
eth_subscribe(logs) |
Exact | Live | Reorged logs emit removed: true before replacements |
| filter polling methods | Feasible | Later | Stateful server-side filters |
eth_sendRawTransaction |
Feasible transport | Optional | P2P broadcast or fallback, not indexing core |
eth_getBalance |
Not general | Unsupported | Requires executed state, including internal effects |
eth_getTransactionCount |
Not general | Unsupported | Contract nonces/internal creation require execution |
eth_getCode |
No | Unsupported | Requires state |
eth_getStorageAt |
No | Unsupported | Requires state |
eth_call |
No | Unsupported/optional forward | Requires historical code/storage and EVM |
eth_estimateGas |
No | Unsupported/optional forward | Requires EVM |
| debug/trace methods | No from receipts | Unsupported | May be served only by a trace-capable dataset/backend |
“On-demand” cannot make every hash-only lookup cheap. Block-number/range queries map naturally to chunked archives. Transaction-hash queries need a locator service or a compact local tx_hash -> block_number index.
Requests, block tags, and missing blocks
A call whose id is null gets a response with a null ID; only a call
without an id member is a notification. An id that is an object, an
array, or a boolean gets -32600 with a null ID. A call must be a JSON
object: an array in its place, such as ["2.0", 1, "eth_chainId"], gets
-32600 with a null ID too.
A notification runs like any other call, side effects included, and gets
no response, as JSON-RPC 2.0 requires: an eth_unsubscribe notification
ends its subscription, and an eth_subscribe notification opens one whose
ID the client never learns. A batch answers only its calls with IDs, and a
batch of only notifications gets no response at all (HTTP 204).
Block numbers and transaction indexes are QUANTITY values: 0x and one or
more hexadecimal digits, without leading zeros except in 0x0, at most 64
bits. Anything else, such as 0x+1, 0x01, or 0x, gets -32602.
Wherever a block number is accepted, including fromBlock and toBlock of
eth_getLogs, these tags are too:
| Tag | Block |
|---|---|
latest |
the head eth_blockNumber reports, at any level |
finalized |
the newest canonical block the node has verified as finalized |
safe |
the same block as finalized |
earliest |
block 0 |
pending |
none: Leani builds no pending block, so -32004 (pending_block_unavailable) |
Until the node has verified a finalized block, finalized and safe fail
with -32004 (finalized_block_unavailable). Leani does not track the
justified checkpoint that execution clients call safe, so safe names the
finalized head, which is never newer. A client that waits for safe blocks
to avoid reorgs therefore stays safe, but it sees an older block than other
clients report.
Block lookups return null only for a number above every block the node
knows exists: its canonical tip and the progress processor’s indexed height. Ethereum clients read null as “no such block”, and a
reorg checker concludes that a block which turned null was reorged out.
So a block at or below the head that neither the recent window nor
on-demand history serves fails with -32004 instead: block_not_retained,
or the history source’s reason when history was asked. A block hash that
the recent window does not hold fails with block_not_retained unless
on-demand history offers a block-hash lookup, in which case null means
that lookup does not know the hash. This applies to eth_getBlockByNumber,
eth_getBlockByHash, eth_getBlockReceipts,
eth_getBlockTransactionCountBy*, and
eth_getTransactionByBlock*AndIndex, which still returns null for an
index past the last transaction of a block it serves. latest resolves the
canonical tip and may fetch its raw material on demand; without a known tip
it fails with block_not_retained. earliest names block 0, so it requires
retained or historical material for block 0.
eth_getLogs with blockHash returns only logs of the block with that
hash. If a reorg replaces that block between resolving the hash and reading
the block, the call fails with -32004 rather than return the
replacement’s logs, as it does once the reorg has happened
(block_hash_not_retained). An empty alternatives array at a topic
position, [], matches any topic there, as null does.
Transport rules and limits
HTTP calls need content-type: application/json, with any parameters, and
get HTTP 415 otherwise. A browser Origin other than a loopback one
(http(s)://localhost, 127.0.0.1, or [::1] on any port) or one listed
in rpc.allowed_origins gets HTTP 403, on HTTP calls and WebSocket upgrades
alike; requests without an Origin header pass. A cross-site page therefore
cannot post a text/plain batch or open a WebSocket to a loopback node.
A request body that is not valid UTF-8 JSON gets HTTP 200 with a -32700
parse error and a null ID. A body over 1 MiB gets HTTP 413 with a JSON-RPC
error, not plain text: -32005 with a null ID, and error.data holding
reason request_size_limit_exceeded and the limit in bytes.
| Limit | Default | Setting | When exceeded |
|---|---|---|---|
| Request body or WebSocket message | 1 MiB | — | HTTP 413 with a -32005 error; a longer WebSocket message ends the connection |
| Calls per batch | 100 | rpc.max_batch_requests |
one -32600 error object for the whole batch |
| Response bytes | 16 MiB | rpc.max_response_bytes |
-32005 for the call that passes the limit, or whose eth_getLogs result would, and for every later call in the batch, which do not run; later notifications still run |
eth_getLogs results |
10,000 | rpc.max_log_results |
-32005 “query returned more than 10000 results. Try with this block range [0x…, 0x…].” |
| Filter addresses | 1,000 | rpc.max_log_addresses |
-32602 |
| Alternatives per topic position | 1,000 | rpc.max_log_topic_alternatives |
-32602 |
| Subscriptions per WebSocket connection | 128 | rpc.max_subscriptions_per_connection |
-32005 from eth_subscribe |
| WebSocket connections | 256 | rpc.max_websocket_connections |
HTTP 503 on the upgrade |
| Subscription notifications of one chain event, per connection | 16 MiB | rpc.max_subscription_event_bytes |
the connection is closed with code 1008 |
| Unsent subscription notifications, per connection | 16 MiB | rpc.max_subscription_event_bytes |
the connection is closed with code 1013 |
| Queued or sending notifications, all WebSocket connections | 64 MiB | rpc.max_outbound_bytes |
the connection holding the most is closed with code 1013 |
Notifications kept queued past a quarter of rpc.max_subscription_event_bytes, per connection |
30 s | — | the connection is closed with code 1013 |
The eth_getLogs hint names the range from fromBlock up to the block
before the one where the results passed the limit, and error.data carries
it as from, to, and limit. The hint is missing when the first block
alone has more matching logs than the limit; read that block with
eth_getBlockReceipts or raise the limit. The filter limits also apply to
eth_subscribe("logs") filters. Closing a WebSocket connection frees its
slot and ends its subscriptions.
The subscriptions of one WebSocket connection share an output budget per
chain event: the notifications that one block, or one reorg with its
removed and replacement logs, produces for the connection may take at most
rpc.max_subscription_event_bytes encoded bytes. The node serializes a log
or head once, however many of the connection’s subscriptions it matches,
but each notification counts in full. A connection whose subscriptions
pass the budget is closed with code 1008 (policy violation), without that
event’s notifications, and with a close reason that names the setting. Its
subscriptions would pass the budget again on a like block, so reconnect
with fewer or narrower subscriptions, or spread them over several
connections.
The node also holds at most rpc.max_subscription_event_bytes of a
connection’s notifications unsent. A client that reads more slowly than
its subscriptions produce notifications is closed with code 1013 (try
again later), as it is when it falls behind the node’s bounded event
channel, and may reconnect. Notifications are queued one at a time, so while
earlier ones are still unsent, a connection can get 1013 before one event’s
notifications pass the budget that would close it with 1008. Notifications
still unsent when a connection is closed are dropped, and a connection that
cannot take its close frame within a second is dropped without one. Other
connections are unaffected.
A call’s response is sent before the connection’s next message is read,
so a client that stops reading stops being answered too.
3. RPC profiles
Minimal
- chain metadata;
- current head;
- recent blocks, transactions, receipts, logs;
- recent fee history;
- new-head and log subscriptions;
- custom aggregate queries/subscriptions.
Storage is aggregate output plus a small recent window.
Indexer
Minimal plus:
- historical
eth_getLogs; - block-number-based historical blocks/receipts;
- configurable log materialization;
- longer recent window.
Historical answers may be slow unless their data is materialized.
RPC-heavy
Indexer plus:
- transaction locator;
- transaction/receipt index;
- larger raw window;
- response cache;
- optional forwarding for state methods.
This profile intentionally uses more storage.
4. ERC-20 balance semantics
What can be derived
For a token where every balance change produces a standard:
Transfer(address indexed from, address indexed to, uint256 value)the node can maintain:
balance[token, from] -= valuebalance[token, to] += valuewith zero-address handling for mint and burn. Logs from reverted transactions do not appear in the canonical receipt and therefore do not change the derived balance.
What can break the model
- rebasing or reflection changes balances without per-holder
Transferevents; - deliberately non-standard tokens;
- unusual proxy upgrades that change balance semantics;
- tokens whose historical starting state predates the selected start block;
- metadata changes or contracts that revert on metadata calls;
- migrations that create balances outside normal transfer events.
The API should call this an event-derived token balance, include the processed block, and expose its assumptions. “Declared” means that the processor manifest and query result declare:
- the chain, token/address scope, and starting block;
- the derivation method, such as
erc20_transfer_ledger; - the highest processed and finalized blocks;
- whether historical backfill and the live handoff are complete;
- the token behavior assumptions under which the value is exact.
For a conventional ERC-20 indexed from before its initial mint, this value should be exact. The declaration exists to distinguish that result from generic EVM state for rebasing or non-standard tokens. The node should not transparently intercept arbitrary eth_call(balanceOf) and claim universal equivalence.
Illustrative response metadata:
{ "token": "0x...", "address": "0x...", "balance": "123000000", "asOfBlock": 25620123, "finalizedThrough": 25620064, "method": "erc20_transfer_ledger", "coverageFrom": 1234567, "complete": true}A custom idx_getTokenBalances method should be the canonical API. An optional alchemy_getTokenBalances compatibility adapter could be added for migrations, but it must return only the declared indexed coverage and completeness metadata.
Storage modes
| Mode | Retained state | New arbitrary address |
|---|---|---|
| Address watchlist | Only watched address/token pairs | Requires targeted backfill |
| Token allowlist | All holders for selected tokens | Immediate within allowed tokens |
| Global current ledger | All non-zero token/address pairs | Immediate, larger database |
| Full balance history | Every change/checkpoint | Largest; opt-in |
For minimal deployments, watchlist mode is the default. Xatu’s predecoded ERC-20 transfer Parquet table makes a targeted historical backfill possible without querying a paid RPC. The live source still sees all receipt logs but retains only relevant deltas.
Adding a watch address should be a first-class job:
- atomically register address and start cursor;
- capture new live deltas for it immediately;
- backfill historical incoming/outgoing transfers;
- reconcile at an overlapping block;
- mark the derived balance complete.
This is the same cold/hot handoff problem in miniature.
Historical balance retention
Current balance requires one row per retained (token, address). Historical balance at block N requires more:
- every transfer delta;
- periodic snapshots plus later deltas;
- or re-running a public-data query on demand.
The default should keep current balances and a small undo window. Historical balance queries can be slower and replay public transfer data unless explicitly materialized.
5. Native ETH balances
Native balances are not equivalent to top-level transaction value transfers. They also change through:
- gas charges and priority fees;
- internal calls;
- contract creation;
- withdrawals;
- protocol rewards in older history;
- other execution-level balance changes.
Xatu currently publishes canonical_execution_balance_diffs and native transfer tables, so it can act as a trusted historical executed-state source. That does not solve low-latency live native balances through receipt-only P2P: generating complete live balance diffs requires EVM execution or another executed-data provider.
Therefore:
idx_getNativeBalancemay be offered when aSTATE_DIFFSbackend is configured and sufficiently current;- standard
eth_getBalanceremains unsupported until the node has an exact state source; - responses must report backend lag and trust.
6. NFT balances
- ERC-721 ownership is generally derivable from
Transfer. - ERC-1155 requires both
TransferSingleandTransferBatch. - The same non-standard/rebase caveats and retention modes apply.
These should follow ERC-20 support rather than complicate the first implementation.
7. Aggregate subscriptions
Native aggregate subscriptions should use durable cursors rather than transient WebSocket connection state:
processor_idprocessor_versionblock_numberblock_hashchange_sequencefinalityEvent kinds:
applyundofinalizedreset_requiredprocessor_complete
Clients resume from a cursor. If the node has pruned the required change history, it returns reset_required with the latest queryable checkpoint.
The v1 native transport is HTTP query plus SSE, with a TypeScript SDK that
handles reconnect and opaque cursors. Standard eth_subscribe remains
WebSocket JSON-RPC and follows Ethereum’s subscription semantics. See
native API and delivery protocol.
Built by Smart-Byte · Legal notice (Impressum) · Privacy