Reading Historical State: eth_call at a Past Block
eth_call is how you read contract state without sending a transaction — check a balance, query an oracle, simulate a view function. By default it answers as of the latest block, which is what you want 95% of the time. The other 5% is where it gets interesting: what was this address's balance at block 19,000,000? What price did the oracle return last Tuesday? What did this function return right before that exploit? Those are all eth_call too — you just have to tell it which block to answer at.
The mechanism is simple. The trap is the node you point it at.
eth_call takes a block parameter
The raw JSON-RPC signature is eth_call(callObject, blockParameter). That second argument is usually omitted (defaults to "latest"), but it accepts a block number, a tag, or — per EIP-1898 — a block hash:
{
"method": "eth_call",
"params": [
{ "to": "0xA0b8...", "data": "0x70a08231000000000000000000000000<address>" },
"0x1212d00"
]
}
That 0x1212d00 is block 18,950,000 in hex. The node executes the call against the state as it existed at the end of that block — historical balances, historical storage, historical return values.
Your library makes this clean:
// viem — read a contract at a past block
const balance = await client.readContract({
address: token,
abi: erc20Abi,
functionName: "balanceOf",
args: [holder],
blockNumber: 18_950_000n,
});
// ethers v6 — blockTag on any call
const balance = await token.balanceOf(holder, { blockTag: 18_950_000 });
# web3.py — block_identifier
balance = token.functions.balanceOf(holder).call(block_identifier=18_950_000)
All three ultimately send the same eth_call with a block parameter. You can pass "latest", "safe", "finalized", a number, or a block hash — a hash is handy when you need to be reorg-proof about exactly which block you mean.
The catch: historical state needs an archive node
Here's the part that trips people up. A standard full node does not keep old state. Geth in its default mode (--gcmode=full) retains the full block history but prunes the state trie to roughly the last 128 blocks. Ask it for a balance at the latest block — instant. Ask for the same balance at a block from last week and you get:
missing trie node ... (path ...) state is not available
That's not a bug or a bad endpoint — the node genuinely threw that state away to save disk. Recent state (within ~128 blocks) works on a full node; anything older requires an archive node (--gcmode=archive), which keeps every historical state root and can reconstruct state at any block back to genesis. That capability is also why archive nodes are so much larger — see Full Node vs Archive Node for the difference and The Cheapest Way to Run an Ethereum Archive Node for what it costs to host one.
So the rule of thumb:
| Query | Works on full node | Needs archive node |
|---|---|---|
eth_call at latest / safe / finalized |
✅ | — |
eth_call within ~last 128 blocks |
✅ | — |
eth_call at an older historical block |
❌ "missing trie node" | ✅ |
eth_getBalance / eth_getStorageAt at an old block |
❌ | ✅ |
What people actually use this for
- Point-in-time accounting — token balances or LP positions at a period boundary (month-end snapshots, airdrop eligibility, tax lots).
- Historical oracle / price reads — "what did the Chainlink feed report at block X" for backtesting or dispute resolution.
- Forensics and debugging — replaying a view function just before and after a known event to see exactly what changed.
- Analytics pipelines — reconstructing a contract's state over time by calling the same getter across a range of blocks.
That last one is worth a warning: calling a getter across thousands of historical blocks is thousands of archive eth_calls, each of which makes the node reconstruct state. It's legitimate, but it's heavy — batch sensibly, cache what you can, and don't be surprised when an archive call is slower than a latest call. Pair it with retry + backoff so a slow archive query doesn't take your pipeline down.
Getting it right
The whole feature hinges on one thing: are you pointed at a node that has the state? If your historical eth_calls return "missing trie node," you're on a full node and need archive access — switching the endpoint, not the code, is the fix.
https://rpc.swiftnodes.io/rpc/eth?key=YOUR_API_KEY
SwiftNodes serves Ethereum and other chains with the historical depth these queries need, at flat-rate pricing instead of metered per-call billing — which matters a lot when an analytics backfill fires off tens of thousands of archive reads. Grab a free key at swiftnodes.io and point your blockNumber queries at it.
Related posts
- Retrying RPC Calls the Right Way: Backoff, Idempotency, and Failover
A naive retry loop turns a brief blip into an outage. Here's how to retry RPC calls correctly: exponential backoff with jitter, knowing which calls are safe to retry, and failing over across providers — with copy-paste examples in viem, ethers, and web3.py.
- How to Handle WebSocket Reconnections Without Losing Events
A WebSocket subscription that silently drops is worse than no subscription at all — you keep running, but events vanish into the gap. Here's how to build reconnect logic that detects the drop, backs off, re-subscribes, and backfills the missed events so your indexer never loses a log.
- The Real Cost of `debug_traceTransaction` (and When to Use It)
Tracing a transaction costs 20-30× more than reading its receipt. Sometimes that's worth it; usually it isn't. Here's what trace actually tells you, the four tracer modes, and when a cheaper RPC method gets the same answer.