feat(rpc): resolve read precompiles at the chain head through an hl-node - #146
Open
sprites0 wants to merge 2 commits into
Open
feat(rpc): resolve read precompiles at the chain head through an hl-node#146sprites0 wants to merge 2 commits into
sprites0 wants to merge 2 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #26.
Blocks record only the read precompile calls their own transactions made, so
eth_call,eth_estimateGasanddebug_traceCallagainst the head fail with out-of-gas the moment theytouch HyperCore state no transaction has read yet — which is what a wallet does when it estimates
gas.
--forward-callavoids this by handing the whole call upstream, giving up state overridesand tracing.
--forward-read-precompileskeeps execution local and forwards only the unrecorded readprecompile inputs, to
--read-precompile-rpc-url(defaults to--upstream-rpc-url). Off bydefault: 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 purefunction of the payload sizes:
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
feat(rpc): resolve read precompiles …— the feature. No behaviour change without the flag.fix(rpc): install HL read precompiles for eth_simulateV1— a pre-existing bug, worthreviewing separately because it changes behaviour with the flag off.
eth_simulateV1builds ablock rather than going through
Call::transact, so its precompiles come fromHlBlockExecutionCtx, whichcontext_for_next_blockleaves empty. The address range then fellback to
0x800..=0x80d, leavingbboand everything above it uninstalled — a read of onefailed as though it were a plain account. Also adds
revm-inspectorsas a direct dependency(
TransferInspector, for simulate'straceTransfers).Verification
Against a June 2026 mainnet archive snapshot (head 37,692,944), with the flag on and pointed at
the public RPC:
eth_callbboeth_estimateGasbbo0x643d— identical to the full nodeeth_call, the Multicall3 batch from #142Multicall3: call faileddebug_traceCall/trace_calleth_simulateV1bbo / multicallstatus 0x0status 0x1eth_callat head−1000eth_estimateGasmatches the full node exactly for0x801,0x806,0x808,0x809,0x80a,0x80cand0x80e— input 0–32 B, output 32–384 B. Inside the #142 multicall every0x80eframe 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
gasUsedexactly.The forwarder is carried on the execution context and left
Nonefor real blocks, so theinvariant is structural rather than a property of the call path.
Known gaps
eth_estimateGasfor the eth_call fails on archive nodes but works on full nodes #142 multicall returns 399,080 locallyagainst 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.
eth_call/s; past that itreturns JSON-RPC
-32005 "rate limited"(and HTTP 429 for bursts —eth_callis weighted farmore heavily than
eth_blockNumber). Responses are cached per(block, address, input), so acall 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 localhl-node; the node logs a warning at startup when the forwarder resolves to the official RPC.
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