Your application reads a contract's stored value before releasing funds or accepting a withdrawal. A successful server response gives you a number. It does not establish that Ethereum recorded that number.
eth_getProof adds evidence you can check against a block you trust. If you skip that check, a wrong value can still drive your application's decision. If the server cannot produce the evidence, another ordinary read cannot replace it.
The method returns proofs for an account and its storage. This guide shows how to verify one stored value, test missing history, and preserve the same trusted block during provider fallback.
Choose the block you trust first
A block header contains stateRoot, a hash that commits to Ethereum's account state. A proof connects the requested account to that root. The account then supplies the root for its own storage.
Obtain the header from a node you operate or a light client that validates it. A light client checks chain data without storing the full chain. Ethereum light clients start from a trusted checkpoint and check consensus updates.[1]
Fetching both the header and proof from one untrusted server does not establish authenticity. The server could supply a matching pair for invented state. Agreement between two remote procedure call (RPC) providers is a cross-check, not consensus verification.
Choose a finalized block when your application needs finality. Keep its hash, number, and state root together. Proof verification establishes state at that root; it does not establish that the block is canonical or finalized.
If a trusted system supplies only a block hash, authenticate the header against that hash before using its root. Use a header implementation that supports the chain's current fork. Do not copy a fixed list of header fields into a long-lived verifier.
Request the account and storage slot
EIP-1186 describes the proof response. The current execution API also defines block-number, tag, and block-hash selectors.[2] Test the selector your endpoint accepts.
{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getProof",
"params": [
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
["0x7050c9e0f4ca769c69bd3a8ef740bc37934f8e2c036e5a723fd8ee048ed3f8c3"],
"finalized"
]
}The address is Ethereum's USDC proxy. The slot identifies its implementation address in Circle's proxy code.[3] It illustrates a concrete question: which implementation did this proxy use at the chosen block?
For application use, resolve finalized once through your trusted source. Then request the proof for that fixed block. A tag can resolve to a different block on a later request.
The response contains accountProof, account fields, and one storageProof entry for each requested slot. Ethereum stores account and storage data in hash-linked trees called tries. A leaf holds the encoded value. The proof nodes use Recursive Length Prefix, or RLP, encoding. Your verifier must check both proof stages and the claimed fields beside them.

The trusted block supplies the state root. The untrusted RPC supplies account and storage proofs.
- Verify the account proof against the trusted state root.
- Extract the storage root from the proven account.
- Verify the requested slot against that storage root.
- Return only the proven value. Reject mismatched fields or missing proof nodes.
Verify the value, not just the proof nodes
A server can return valid proof nodes alongside a changed value. Checking the nodes while using that separate number defeats verification.
Helios shows the required sequence in real code.[4] Its RPC provider checks the returned address, verifies account and storage proofs, and checks that every requested slot appears. Its account verifier binds nonce, balance, storage root, and code hash to the block's state root.
The example below follows that sequence for one slot in an existing account. It returns the value decoded from the verified storage leaf. It rejects an absent account rather than implementing account exclusion handling.
Install the pinned example dependencies in a separate directory:
npm install @ethereumjs/mpt@10.1.3 viem@2.57.6Save this as verify.mjs. The trie library checks the hash path; viem decodes the proven leaf.[5]
import assert from 'node:assert/strict';
import { verifyMerkleProof } from '@ethereumjs/mpt';
import { bytesToHex, fromRlp, hexToBytes, padHex } from 'viem';
const EMPTY_ROOT = '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421';
const quantity = (hex) => {
assert.match(hex, /^0x[0-9a-fA-F]+$/);
return BigInt(hex);
};
const integer = (hex) => hex === '0x' ? 0n : quantity(hex);
const same = (a, b) => a.toLowerCase() === b.toLowerCase();
async function leaf(root, key, nodes) {
assert.match(root, /^0x[0-9a-fA-F]{64}$/);
assert.ok(Array.isArray(nodes));
if (nodes.length === 0) {
assert.ok(same(root, EMPTY_ROOT), 'empty proof for a nonempty trie');
return null;
}
return verifyMerkleProof(hexToBytes(key), nodes.map((node) => hexToBytes(node)), {
root: hexToBytes(root), useKeyHashing: true,
});
}
export async function verifiedSlot(stateRoot, address, slot, response) {
assert.match(address, /^0x[0-9a-fA-F]{40}$/);
const key = padHex(slot, { size: 32 });
assert.match(key, /^0x[0-9a-fA-F]{64}$/);
assert.ok(response && same(response.address, address), 'wrong account');
const account = await leaf(stateRoot, address, response.accountProof);
assert.ok(account !== null, 'this example requires an existing account');
const fields = fromRlp(bytesToHex(account), 'hex');
assert.ok(Array.isArray(fields) && fields.length === 4);
const [nonce, balance, storageRoot, codeHash] = fields;
assert.equal(quantity(response.nonce), integer(nonce));
assert.equal(quantity(response.balance), integer(balance));
assert.ok(same(response.storageHash, storageRoot));
assert.ok(same(response.codeHash, codeHash));
assert.equal(response.storageProof.length, 1, 'missing or extra slot');
const entry = response.storageProof[0];
assert.equal(quantity(entry.key), quantity(key), 'wrong slot');
const raw = await leaf(storageRoot, key, entry.proof);
const decoded = raw === null ? '0x' : fromRlp(bytesToHex(raw), 'hex');
assert.equal(typeof decoded, 'string');
const value = integer(decoded);
assert.equal(quantity(entry.value), value, 'unproven claimed value');
return value;
}
export async function rpc(url, method, params) {
const res = await fetch(url, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
assert.ok(body && body.jsonrpc === '2.0' && body.id === 1);
assert.ok(!body.error && body.result != null, 'RPC error or missing result');
return body.result;
}
An absent storage slot can prove zero. A missing response cannot. Empty proof arrays are valid only for an authenticated empty trie root; they do not establish absence in a nonempty trie.
The example rejects RPC errors, null results, wrong response IDs, and non-JSON responses. It has a 15-second request timeout. Production callers also need response-byte limits and bounded provider attempts.
Use it with a trusted node and an untrusted proof endpoint:
import { rpc, verifiedSlot } from './verify.mjs';
const header = await rpc(process.env.TRUSTED_NODE_URL,
'eth_getBlockByNumber', ['finalized', false]);
const address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const slot = '0x7050c9e0f4ca769c69bd3a8ef740bc37934f8e2c036e5a723fd8ee048ed3f8c3';
const proof = await rpc(process.env.PROOF_RPC_URL,
'eth_getProof', [address, [slot], header.number]);
const value = await verifiedSlot(header.stateRoot, address, slot, proof);
console.log(`Verified slot: 0x${value.toString(16)}`);TRUSTED_NODE_URL must point to the trusted source you chose. Naming an environment variable does not create that trust. This example uses a fixed block number for endpoint compatibility; the root check binds acceptance to the chosen state.
Before integrating the verifier, test changed values, wrong roots, missing nodes, and the wrong slot. Include a proven zero and a failed exclusion proof.
This teaching example is not a complete bridge verifier. It does not authenticate headers, verify bytecode, establish proxy storage layouts, or manage consensus checkpoints. Use the verifier your protocol requires for those responsibilities.
Test history with the actual proof request
Historical value reads and historical proofs need different data. Geth's path-based archive mode separates flat state history from historical trie nodes. Its archive documentation requires --history.trienode retention for historical proofs.[6]
An endpoint can therefore answer eth_getStorageAt for an old block and fail eth_getProof for it. An archive label alone does not establish proof support.
Before selecting an endpoint, test the account, slots, and fixed block selector your application uses. Include your oldest required checkpoint. Repeat that request through each fallback endpoint.
| Check | Passing evidence | If it fails |
|---|---|---|
| Exact block and selector | Proof verifies against the chosen root | Try another endpoint for the same block |
| Oldest required checkpoint | Account and slot proofs both verify | Request retained proof history or change the supported range |
| Missing slot | Exclusion proof verifies as zero | Reject an empty or incomplete answer |
| Requested account and keys | Response matches both | Reject the response |
Do not infer a universal retention window from one public endpoint. Node configuration and provider routing affect availability. A reported client name does not reveal every backend or policy.
Keep fallback from changing the answer
Fallback may change who supplies the proof. It must preserve the account, slots, block, and trusted root. Replacing an unavailable historical proof with a proof at latest answers a different question.
Handle failures according to the action they permit:
- A timeout or rate limit permits a bounded retry with backoff.
- Missing proof history permits another endpoint for the same block.
- An unavailable method or access denial needs a compatible endpoint or corrected access.
- Invalid parameters need a corrected request before retrying.
- A malformed response or failed proof must never produce an application value.
Inspect HTTP status and the JSON-RPC error together. Error codes alone do not identify a provider's history policy. Keep diagnostic logs free of credential-bearing endpoint URLs.
When all attempts fail, stop the operation that needs the verified value. Alert or retry later. Do not substitute zero, a cached value from another block, or eth_getStorageAt.
For non-finalized blocks, verification does not remove reorganization handling. A proof can remain valid for a block that leaves the canonical chain. Block identity and reorg recovery explains how consumers preserve and recheck block identity.
Group keys without assuming a universal limit
Combine slots for the same account and block when your endpoint accepts them. One response includes one account proof plus the requested storage proofs. Separate calls repeat the account proof.
Geth's implementation caps eth_getProof at 1,024 storage keys.[7] That is a client limit, not a safe response-size budget for every endpoint. Select a smaller batch size from your transport and provider limits.
A JSON-RPC batch of single-slot calls still contains separate proof responses. It does not merge their account proofs. Batch sizing and partial failures covers response matching and targeted retries.
Check method charges and failed-attempt accounting with your provider before budgeting the workload. Cache verified results by chain, block hash, account, and slots. Keep finality or canonical-chain checks separate from proof validity.
For Dwellir infrastructure, contact the team with your chain, oldest required block, selector, and slot count. Ask for endpoint-specific proof support before relying on it. The archive nodes guide explains historical state access.


