eth_getProof: Verify Blockchain State Without Trusting Your RPC Node
Yesterday we wrote about what free RPC endpoints really give you, and the uncomfortable core of it is: every response from any RPC node — free or paid, yours or ours — is just something a server told you. eth_getBalance returns a number; you have no way to know it's true. For most apps that's fine. But there's a JSON-RPC method that changes the deal entirely: eth_getProof returns state with a cryptographic receipt — a Merkle proof you can verify yourself against a block hash. It's the primitive underneath bridges, light clients, and L2 withdrawals, and it works today on any Ethereum endpoint. Here's how it works and when to reach for it.
What it returns
eth_getProof (standardized in EIP-1186) takes an address, a list of storage slots, and a block tag. We ran it live against the WETH contract, asking for storage slot 0x0:
curl -X POST "https://rpc.swiftnodes.io/rpc/eth?key=YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getProof",
"params":["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
["0x0000000000000000000000000000000000000000000000000000000000000000"],
"latest"]}'
The response has two halves. The account proof: the account's nonce, balance (~2.09M ETH held by WETH when we ran it), codeHash, and storageHash, plus accountProof — an array of trie nodes (nine of them, the first being the 532-byte state-trie root node). And a storage proof per requested slot: the slot's value plus its own node array (seven nodes for our query).
A nice detail from the live run: WETH's slot 0 value came back as
0x577261707065642045746865720000000000000000000000000000000000001a
— which is ASCII for "Wrapped Ether" with Solidity's short-string length byte at the end. The contract's name lives in slot 0, and we just received it with a cryptographic proof attached.
How the proof actually proves anything
Ethereum's entire world state is a Merkle-Patricia trie whose root hash — the stateRoot — is committed in every block header. That's the trick:
- Take a block header you trust (more on that below). It contains
stateRoot. - The
accountProofis the path of trie nodes from that root down to the account's leaf, keyed bykeccak256(address). Each node's hash appears inside its parent — so you can recompute hashes from the leaf up and check the top equalsstateRoot. If any byte were forged, the hash chain breaks. - The account leaf commits to
storageHash— the root of that contract's own storage trie. EachstorageProofwalks fromstorageHashto the slot's value, keyed bykeccak256(slot), verified the same way.
So the node can't lie about a balance or a storage value without producing a hash collision. The only thing you have to trust is the block header — which is exactly the point: it reduces "trust this RPC server" to "trust this 32-byte block hash," and block hashes are much easier to source honestly:
- Cross-check multiple independent endpoints for the block hash — forging state now requires colluding providers, not one bad node.
- A consensus light client gives you headers verified against Ethereum's validator set — no RPC trust at all.
- On-chain sources: every L2 exposes its view of recent L1 block hashes, which is precisely how canonical bridges verify L1 state — and how L2→L1 withdrawals prove, with a storage proof, that a withdrawal record exists in the L2's state.
Using it from code
import { createPublicClient, http } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: http("https://rpc.swiftnodes.io/rpc/eth?key=YOUR_API_KEY"),
});
const proof = await client.getProof({
address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
storageKeys: ["0x0000000000000000000000000000000000000000000000000000000000000000"],
blockNumber: 23_500_000n, // pin to a specific block — proofs against "latest" go stale on the next block
});
Don't hand-roll the verification — trie traversal has enough edge cases (extension nodes, embedded short nodes, exclusion proofs) that you want a maintained implementation. @ethereumjs/trie's verifyProof, or the verifier inside light-client libraries like Helios, take the header's stateRoot, the address, and the proof array and return the verified value.
When to actually use it
- Reading state through endpoints you don't trust — the free/public tier from yesterday's post. Fetch the proof from the cheap endpoint, verify against a header you got somewhere trustworthy.
- Cross-chain state — reading L1 state from an L2 (or vice versa) without an oracle: on-chain block hash + storage proof = trust-minimized bridge read.
- High-stakes reads — a payout system checking a balance before releasing funds can demand proof instead of taking an
eth_call's word for it. - Historical facts — "what was this slot at block N," provable, for audits and disputes. Note that proofs for old blocks need archive state — a full node can only prove recent state, the same limitation as historical eth_call.
The gotchas
- You pass the raw slot, not its hash. The node applies
keccak256(slot)internally for the trie path. For mapping entries, compute the slot the same way you would for state overrides:keccak256(abi.encode(mappingKey, mappingSlot)). - Pin the block. A proof is against one block's
stateRoot; verify it against that block's header, and remember "latest" can differ between nodes — pass an explicit block number. - A zero value with a valid proof is an exclusion proof — cryptographic evidence the slot is empty, which is just as useful and just as verifiable.
- Proofs aren't small. Nine account nodes plus seven storage nodes was ~5KB for our WETH query; deep tries and many slots multiply that. This is a precision tool, not a bulk-read replacement — for bulk, batch normal calls and verify selectively.
eth_getProof works on Ethereum and EVM chains generally (support and trie formats vary on L2s — test your target). Try the WETH query above on our Ethereum endpoint — the free tier covers a lot of proofs, and flat-rate paid plans include the archive access that historical proofs need.
Related posts
- Sending a Raw Transaction: Why eth_sendTransaction Doesn't Work on a Provider
The write path trips up more developers than the read path. Here's why you sign transactions locally and broadcast them with eth_sendRawTransaction — with the full build-sign-send flow.
- eth_call State Overrides: Simulate Against State That Doesn't Exist
eth_call's third parameter lets you override balances, code, and storage before simulating. Here's how state overrides work, with a reproducible example and real use cases.
- Is Your RPC Node Actually at the Chain Tip? How to Catch a Stale Endpoint
eth_syncing returns false when a node is fully synced — and also when it hasn't started syncing. That trap, plus how to really tell if an RPC node (your own or a provider's) is at the chain tip: compare block height to a reference, check block-timestamp freshness, and the Solana equivalents (getHealth, catchup). A liveness check is not a freshness check.