Skip to content

feat(rpc): resolve read precompiles at the chain head through an hl-node - #146

Open
sprites0 wants to merge 2 commits into
node-builderfrom
feat/forward-read-precompiles
Open

feat(rpc): resolve read precompiles at the chain head through an hl-node#146
sprites0 wants to merge 2 commits into
node-builderfrom
feat/forward-read-precompiles

Conversation

@sprites0

Copy link
Copy Markdown
Collaborator

Closes #26.

Blocks record only the read precompile calls their own transactions made, so eth_call,
eth_estimateGas and debug_traceCall against the head fail with out-of-gas the moment they
touch HyperCore state no transaction has read yet — which is what a wallet does when it estimates
gas. --forward-call avoids this by handing the whole call upstream, giving up state overrides
and tracing.

--forward-read-precompiles keeps execution local and forwards only the unrecorded read
precompile inputs, to --read-precompile-rpc-url (defaults to --upstream-rpc-url). Off by
default: without the flag nothing changes and no request is made.

Historical blocks are untouched. HyperCore state is not archived, and hl-node overrides a
requested block with the head, so a historical call can only be answered by replaying what the
block recorded.

Gas

hl-node does not report a read precompile's gas over eth_call, but it turns out to be a pure
function of the payload sizes:

gas_used = 1000 + 33 * (input_len + output_len)

so the forwarder fetches only the output bytes and prices the result locally, deciding
out-of-gas against the real gas limit. The constants were measured by binary searching the
minimum gas that lets a direct call to each precompile succeed on mainnet, then confirmed against
a full node.

Two commits

  1. feat(rpc): resolve read precompiles … — the feature. No behaviour change without the flag.
  2. fix(rpc): install HL read precompiles for eth_simulateV1 — a pre-existing bug, worth
    reviewing separately because it changes behaviour with the flag off. eth_simulateV1 builds a
    block rather than going through Call::transact, so its precompiles come from
    HlBlockExecutionCtx, which context_for_next_block leaves empty. The address range then fell
    back to 0x800..=0x80d, leaving bbo and everything above it uninstalled — a read of one
    failed as though it were a plain account. Also adds revm-inspectors as a direct dependency
    (TransferInspector, for simulate's traceTransfers).

Verification

Against a June 2026 mainnet archive snapshot (head 37,692,944), with the flag on and pointed at
the public RPC:

check flag off flag on
eth_call bbo out of gas matches the full node byte for byte
eth_estimateGas bbo out of gas 0x643d — identical to the full node
eth_call, the Multicall3 batch from #142 Multicall3: call failed succeeds
debug_traceCall / trace_call frame records out of gas returns data
eth_simulateV1 bbo / multicall status 0x0 status 0x1
eth_call at head−1000 out of gas out of gas (unchanged)

eth_estimateGas matches the full node exactly for 0x801, 0x806, 0x808, 0x809, 0x80a,
0x80c and 0x80e — input 0–32 B, output 32–384 B. Inside the #142 multicall every 0x80e
frame is charged exactly 4168 gas, the formula's value.

Block execution is unaffected. Replaying 56 user transactions across 12 blocks (38 recorded
precompile calls) with forwarding enabled reproduces every stored receipt's gasUsed exactly.
The forwarder is carried on the execution context and left None for real blocks, so the
invariant is structural rather than a property of the call path.

Known gaps

  • One unexplained number. eth_estimateGas for the eth_call fails on archive nodes but works on full nodes #142 multicall returns 399,080 locally
    against 382,514 from the full node. The precompile frames are exactly right and all 37
    contracts involved have identical code at both heads, so this is in non-precompile execution —
    but the snapshot and the full node are 1.1M blocks apart and hl-node rewrites the requested
    block to the head, so there is no way to compare them at the same state. Left unexplained
    rather than guessed at.
  • Rate limits. The public RPC sustains about 10 read precompile eth_call/s; past that it
    returns JSON-RPC -32005 "rate limited" (and HTTP 429 for bursts — eth_call is weighted far
    more heavily than eth_blockNumber). Responses are cached per (block, address, input), so a
    call issuing 8 precompile reads over 2 distinct inputs costs 2 upstream requests, and
    eth_estimateGas's binary search costs nothing extra. Still, a busy node wants a local
    hl-node; the node logs a warning at startup when the forwarder resolves to the official RPC.
  • Batching is not implemented. Precompiles are invoked one at a time and the EVM blocks on
    each, so there is nothing to batch within a pass. Doing it needs the two-pass replay sketched in
    Support read precompiles in debug/trace APIs on latest block #26 (placeholder pass to collect the input set, one batched fetch, re-run against the primed
    cache), which would also give the atomic snapshot that issue asks for. Worth a follow-up rather
    than folding in here.

🤖 Generated with Claude Code

sprites0 added 2 commits July 26, 2026 06:41
Blocks record only the read precompile calls their own transactions made, so eth_call,
eth_estimateGas and debug_traceCall against the head fail with out-of-gas as soon as they touch
HyperCore state no transaction has read yet — which is exactly what a wallet does when estimating
gas. --forward-call sidesteps this by handing the whole call to an upstream RPC, which gives up
state overrides and tracing.

--forward-read-precompiles keeps execution local and forwards only the unrecorded read precompile
inputs, to --read-precompile-rpc-url (defaulting to --upstream-rpc-url). Historical blocks are
untouched: HyperCore state is not archived, and hl-node overrides a requested block with the head,
so a historical call can only be answered by replaying what the block recorded.

hl-node does not report a read precompile's gas over eth_call, but it is a pure function of the
payload sizes — 1000 + 33 * (input_len + output_len) — so the forwarder fetches only the output
bytes and prices the result locally, deciding out-of-gas against the real gas limit. The constants
were measured by binary searching the minimum gas that lets a direct call to each precompile
succeed on mainnet, then confirmed against a full node: eth_estimateGas now returns identical
values for 0x801, 0x806, 0x808, 0x809, 0x80a, 0x80c and 0x80e, and each 0x80e frame inside a
Multicall3 batch is charged exactly 4168 gas.

Responses are cached per (block, address, input). eth_estimateGas binary searches over the same
transaction, so without a cache every iteration would re-query the upstream; the cache also makes
all reads within one RPC request agree with each other.

A JSON-RPC error only counts as a refused input when hl-node reports -32003 with a PrecompileError
message. Anything else — a rate limit, a wrong URL, a node without eth_call — surfaces as an RPC
error, because reporting it as a refused input would hand back a plausible but wrong out-of-gas
result.

Closes #26
eth_simulateV1 does not run through Call::transact the way eth_call does — it builds a block, so
its precompiles come from HlBlockExecutionCtx. That context is produced by context_for_next_block,
which has no provider to look the head up with and so returns HlExtras::default(). The precompile
address range then falls back to 0x800..=0x80d, leaving everything above it — bbo, and every
precompile added since — uninstalled, so simulating a call that reads one fails as though it were
a plain account rather than a precompile.

Against a June 2026 mainnet snapshot, simulating the Multicall3 batch from #142 returned
status 0x0 with `Multicall3: call failed`; it now returns status 0x1. A direct bbo read went from
out-of-gas to status 0x1 at 0x62e8 gas.

The fix carries the precompile data on HlBlockExecutionCtx, so simulation can attach the head's
address range and, when --forward-read-precompiles is set, a forwarder. Block execution leaves the
new field None, which keeps the invariant structural rather than relying on the call path: there
the recorded calls are consensus data, and an input missing from them means the block is wrong.
Replaying 56 user transactions across 12 blocks with forwarding enabled reproduces every stored
receipt's gasUsed exactly.

Pending blocks also borrow the head's precompile address range now, independently of the flag,
since they have no body of their own to take it from.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support read precompiles in debug/trace APIs on latest block

1 participant