Skip to content

Public /evm RPC returns latest state for unsupported block numbers in eth_call and eth_getCode #158

Open

Description

@shyhaeky95

Summary

The public /evm endpoint appears to ignore non-latest block numbers passed to eth_call and eth_getCode, evaluating the requests against current state instead. Unsupported historical block numbers — and block numbers above the current head — return well-formed results rather than a JSON-RPC error.

The documentation already states that for these state methods "only the latest block is supported". This report is therefore not a request for archival state support. The issue is that an unsupported block parameter is silently accepted and answered from a different state than the caller asked for.

This report covers only https://rpc.hyperliquid.xyz/evm. I have not tested a locally running hl-node, so I cannot tell whether the behaviour originates in the node implementation or in the hosted RPC layer.

Environment

  • Endpoint: https://rpc.hyperliquid.xyz/evm (no key)
  • eth_chainId: 0x3e7 (999)
  • web3_clientVersion: hyperliquid evm Mainnet
  • Measured 2026-07-28T14:46:02Z, head block 41,677,284 (0x27bf1e4)
  • Contract used: WHYPE 0x5555555555555555555555555555555555555555 (genesis predeploy), totalSupply() = 0x18160ddd

Controls first

RPC=https://rpc.hyperliquid.xyz/evm
W=0x5555555555555555555555555555555555555555

# positive — the endpoint serves real state
curl -s $RPC -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getCode","params":["'$W'","latest"]}'
# → 2041 bytes of code

# negative — "0x" is an answer this endpoint can actually produce
curl -s $RPC -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getCode","params":["0x00000000000000000000000000000000deadbe01","latest"]}'
# → "0x"

1. Same fixed block number, two calls, 30 seconds apart

This is the decisive test. Block 0x26cafa4 (head − 1,000,000), identical request both times:

curl -s $RPC -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"'$W'","data":"0x18160ddd"},"0x26cafa4"]}'
read raw result
t = 0s 0x00000000000000000000000000000000000000000004cda11f6182186624fe31
t = 30s 0x00000000000000000000000000000000000000000004cda16e395bf78a8a39b1

Δ ≈ +5.68 WHYPE for the same block number. The magnitude varies with wrap/unwrap traffic — a run 20 minutes earlier gave +39.46 — and the magnitude is not the point. A block number that yields two different answers is not being applied.

2. A block number above the current head

curl -s $RPC -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getCode","params":["'$W'","0x3148864"]}'

0x3148864 is head + 10,000,000. Expected a JSON-RPC error; actual: no error, 2041 bytes, identical to latest.

3. eth_call at head − 1,000,000 matches latest byte for byte

Both latest and 0x26cafa4 return 0x00000000000000000000000000000000000000000004cda12f10131bba55fe31.

Suggestive rather than conclusive on its own — two points in time can coincide — but consistent with (1) and (2).

4. Blocks are retained; state is not

curl -s $RPC -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getBlockByNumber","params":["0x26cafa4",false]}'

Returns the block, echoing "number":"0x26cafa4". So a caller can fetch a real historical block and a "state at that block" in the same script, and only the second one is wrong.

Why this matters

An explicit unsupported-block error is easy for callers to detect and handle. A valid-looking response from the wrong state is not: standard RPC libraries accept it, and downstream datasets can silently label current state as historical state.

I ran into this while pinning token supply measurements to fixed blocks. The values looked valid and stayed valid across two independent read paths, until repeated calls at the same block number returned different results.

Suggested fix

Reject unsupported non-latest block parameters with a JSON-RPC error, including block numbers above the current head — for example Resource not found, or whatever error the endpoint already uses for an invalid block height. The endpoint should not silently substitute latest state.

As a temporary mitigation the documentation could state explicitly that non-latest block parameters are currently ignored rather than rejected, though rejecting them would be substantially safer.

What I did not test

eth_getBalance, eth_getStorageAt, eth_getTransactionCount; a locally running hl-node; testnet. This is one hosted endpoint, two state methods, one point in time.

Activity

  1. shyhaeky95 commented on Aug 17, 2026

    @shyhaeky95
    Author

    Following up: the hosted endpoint still reproduces this 1,713,929 blocks later, and there is now a test that reproduces it without depending on state drift. Two of the methods I had listed as untested are also covered below.

    Still reproduces, 1,713,929 blocks later

    Same endpoint, no key. Measured 2026-08-17T03:06:23Z, head 43,391,213 (0x29618ed); eth_chainId 0x3e7, web3_clientVersion hyperliquid evm Mainnet. The original report was at head 41,677,284.

    Controls first, as before — positive eth_getCode(WHYPE, latest) = 2041 bytes; negative eth_getCode(<nonexistent>, latest) = 0x; and a bogus address at 0x1 also returns 0x, so in these eth_getCode calls the address argument is being applied while the block argument is not.

    • Block above head: eth_getCode(WHYPE, 0x32eaf6d) (head + 10,000,000) → no error, 2041 bytes, identical to latest.
    • Same tag twice: eth_call(WHYPE.totalSupply(), 0x286d6ad) read twice → 5787942.346767374 then 5787922.534321297, Δ ≈ −19.81 WHYPE.
    • eth_getBlockByNumber("0x286d6ad", false) still returns that historical block and echoes "number":"0x286d6ad", while the state calls above do not return state corresponding to that tag.

    A deterministic version of test 1

    Test 1 in the original report depends on state changing between two reads, which makes it traffic-dependent. This one does not: a contract cannot have code before the block that created it.

    RPC=https://rpc.hyperliquid.xyz/evm
    C=0x1dd6673260f584f9c2ade5e2ddc5a641fa09b468
    
    # the endpoint itself supplies the deploy block
    curl -s $RPC -H 'content-type: application/json' \
      -d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt","params":["0x91abe23dec9671c4589afb0da994a89dc634475248380d2e808ad38cfbb8a83d"]}'
    # → "contractAddress":"0x1dd6…b468", "blockNumber":"0x27d6651"  (41,772,625)
    
    curl -s $RPC -H 'content-type: application/json' \
      -d '{"jsonrpc":"2.0","id":1,"method":"eth_getCode","params":["'$C'","0x1"]}'
    # → 212 bytes, identical to latest
    block tag eth_getCode
    latest 212 bytes
    0x27d6650 (deploy − 1) 212 bytes
    0x1 212 bytes

    A second contract behaves the same — 793 bytes at latest, at deploy − 1 (0x74818c), and at block 1. Its deployment went through a factory, so its receipt carries no contractAddress and the height needs two steps to check:

    # 1) this endpoint places the deploy tx in block 0x74818d (7,635,341)
    curl -s $RPC -H 'content-type: application/json' \
      -d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt","params":["0xf2968212b462a98fe80ad5c7ee9bf91a29e64379b5570d38f0474211be1cd14f"]}'
    # → "blockNumber":"0x74818d", "to":"0x4e59b44847b379578588920ca78fbf26c0b4956c", "contractAddress":null
    
    # 2) the tx → contract link, from an explorer
    # https://api.etherscan.io/v2/api?chainid=999&module=contract&action=getcontractcreation
    #   &contractaddresses=0x393d0b87ed38fc779fd9611144ae649ba6082109
    # → txHash 0xf2968212…, blockNumber 7635341

    That is 2 of 2 contracts I tested, not a survey. For the first contract, the deployment height used here is the one this endpoint returns in its own receipt; for the second it comes from the explorer step above. Both were re-read immediately before posting.

    Two of the methods I had listed as untested

    Same contract, same reasoning. Measured 2026-08-17T03:09:59Z.

    call latest 0x1 0x27d6650 (deploy − 1)
    eth_getStorageAt(C, 0x0, …) 0x…74a09313a4ce0175fa same same
    eth_getTransactionCount(C, …) 0x1 0x1 0x1

    Control: eth_getStorageAt(<nonexistent>, 0x0, latest) returns all zeros, so zero is an answer this endpoint can produce, and the non-zero value at block 1 is not an artifact of the method always returning something.

    eth_getBalance is not decided by this test — the contract holds 0 native at latest, so 0 at block 1 is consistent with either explanation. It would need an address with a non-zero balance and a verified deploy block. I have not run that.

    One note for anyone reproducing test 1

    On a run on 2026-08-12 a 30-second gap did not resolve — both reads returned the same value — while a 90-second gap did. The magnitude and even the presence of a delta depends on wrap/unwrap traffic in the window. The deterministic test above avoids this entirely, and I would suggest it as the reproduction path.

    What would show this is fixed

    Any one of these: eth_getCode or eth_call returning a JSON-RPC error for a non-latest block parameter instead of a result; eth_getCode(<contract>, 0x1) returning 0x for a contract deployed later; or two reads at the same historical tag returning the same value while the value at latest is independently shown to have changed over the same interval.

    I re-ran every check above immediately before posting: the non-latest calls still returned results rather than errors, and the pre-deployment eth_getCode checks still returned the same code as latest. If your result differs, the endpoint, version and configuration you used would be useful to compare against this hosted-RPC measurement.

    Scope

    Unchanged from the original report: this is the hosted https://rpc.hyperliquid.xyz/evm only. I have not run a local hl-node, and I have not tested testnet, so I still cannot say whether this originates in the node implementation or in the hosted RPC layer. No change to the suggested fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions