Introduction
If you've ever tried to understand what's happening in an Ethereum transaction, you've probably looked at prestate traces hoping they'd reveal all. And you've probably been disappointed.
The traces show that storage changed, but not what it means. The good news: there's a tracer that can help us peer behind the curtain.
Let's look at a random ERC20 transfer I picked from mainnet:
{
"txHash": "0x9b676960821b83ca82efde55eadabb7ede3897970735b4ea5d97530da58eb53e",
"result": {
"pre": {
"0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97": {
"balance": "0xbd6f42907a82a220",
"nonce": 4513217
},
"0x6254e0d4b3b40c1bf8bc3c359d33024c7cc70309": {
"balance": "0xaea65b62f44c169",
"nonce": 1341
},
"0x6f40d4a6237c257fff2db00fa0510deeecd303eb": {
"balance": "0x0",
"codeHash": "0x8cf49f6749c84d0c4fad1cac5dcd25451e3714b0d0e2bda19610e97809ae017e",
"nonce": 1,
"storage": {
"0x2f3743fc8db8e5f6f83231c39b13d94e5dc95ab2862e1fbef80fb31f61db4247": "0x000000000000000000000000000000000000000000001d4a1d5c439f10d9674f",
"0x439afe030a0c2aa5aced28b979d9d910acfcf4969dad1d19cfc3586486321399": "0x000000000000000000000000000000000000000000019f64a71257ceed8bdce7"
}
}
},
"post": {
"0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97": {
"balance": "0xbd6fc3775535ac20"
},
"0x6254e0d4b3b40c1bf8bc3c359d33024c7cc70309": {
"balance": "0xae9e31a47227864",
"nonce": 1342
},
"0x6f40d4a6237c257fff2db00fa0510deeecd303eb": {
"storage": {
"0x2f3743fc8db8e5f6f83231c39b13d94e5dc95ab2862e1fbef80fb31f61db4247": "0x000000000000000000000000000000000000000000000ea50eae21cf886cb3a7",
"0x439afe030a0c2aa5aced28b979d9d910acfcf4969dad1d19cfc3586486321399": "0x00000000000000000000000000000000000000000001ae09b5c0799e75f8908f"
}
}
}
}
}
We can see that contract 0x6f40d4a6237c257fff2db00fa0510deeecd303eb had two storage slots change. But which addresses were involved in this ERC20 transfer? The trace doesn't tell us.
Why? Because of how Solidity implements mappings.
How Ethereum Contract Storage Works
Every smart contract has private storage organized in 256-bit chunks called slots. Each slot has a 256-bit address, making contract storage essentially a key-value store where both keys and values are 32 bytes.
For simple variables, Solidity assigns slots sequentially starting from 0:
uint256 public totalSupply; // slot 0
address public owner; // slot 1
Reading totalSupply means reading slot 0. Simple.
But mappings are different. A mapping(address => uint256) balances could have up to 2¹⁶⁰ entries. Solidity can't reserve slots for all possible keys upfront.
The Hash Trick
Solidity computes storage slots dynamically using keccak256. For a mapping declared at slot p, the value for key k is stored at:
slot = keccak256(k ‖ p)
where ‖ denotes concatenation (both k and p padded to 32 bytes).
Let's trace through our example. If balances is at slot 7 and we want the balance of 0x6254e0d4b3b40c1bf8bc3c359d33024c7cc70309:
keccak256(
0x0000000000000000000000006254e0d4b3b40c1bf8bc3c359d33024c7cc70309
0x0000000000000000000000000000000000000000000000000000000000000007
)
= 0x2f3743fc8db8e5f6f83231c39b13d94e5dc95ab2862e1fbef80fb31f61db4247
That hash matches one of the slots in our trace exactly.
Nested Mappings and Arrays
Nested mappings like mapping(address => mapping(address => uint256)) (used for ERC20 allowances) apply hashing recursively:
slot = keccak256(k2 ‖ keccak256(k1 ‖ p))
Dynamic arrays work similarly: slot p stores the length, and elements live at keccak256(p) + i.
The Problem
This design is elegant and gas-efficient, but it creates a problem for transaction analysis: keccak256 is one-way. When you see slot 0x2f3743fc8db8e5f6f83231c39b13d94e5dc95ab2862e1fbef80fb31f61db4247, there's no computationally tractable way to reverse the hash and discover the original address and slot number.
The Naive (Impossible) Solution
Could we precompute all possible hashes and build a lookup table?
A few problems with that:
- Storing all 32-byte hashes would require roughly 10⁷⁷ bytes—more storage than has ever been manufactured
- Computing them would take longer than the age of the universe
- Hash collisions mean multiple inputs produce the same hash anyway
At this point, you might give up. But there's a catch.
The Insight
Two observations change everything:
- All these hashes are calculated at some point during execution
- Most hashes (ENS being a notable exception) are calculated as part of smart contract execution
If we could intercept every hash computed during a transaction, we'd have a mapping from hashes back to their inputs.
Enter keccak256PreimageTracer
This is exactly why we built keccak256PreimageTracer and contributed it to Geth. Available since Geth v1.15.6, this tracer intercepts every execution of the KECCAK256 opcode and records the hash => preimage mapping.
For our transaction:
{
"txHash": "0x9b676960821b83ca82efde55eadabb7ede3897970735b4ea5d97530da58eb53e",
"result": {
"0x2606cc0d4b8425fb558be41c31785188c85a56be1b56fb19566fdbd233696f72": "0x00000000000000000000000040b9fcc4afc60bce840d8a1fbce086067118b81a0000000000000000000000000000000000000000000000000000000000000008",
"0x2f3743fc8db8e5f6f83231c39b13d94e5dc95ab2862e1fbef80fb31f61db4247": "0x0000000000000000000000006254e0d4b3b40c1bf8bc3c359d33024c7cc703090000000000000000000000000000000000000000000000000000000000000007",
"0x439afe030a0c2aa5aced28b979d9d910acfcf4969dad1d19cfc3586486321399": "0x00000000000000000000000040b9fcc4afc60bce840d8a1fbce086067118b81a0000000000000000000000000000000000000000000000000000000000000007",
"0x55fccf1a3c27a98545b25bb0d8f18e15e6018c681fbe92128077f822ff27f502": "0x0000000000000000000000006254e0d4b3b40c1bf8bc3c359d33024c7cc703090000000000000000000000000000000000000000000000000000000000000008"
}
}
Now we can decode the preimages:
| Hash | Address | Slot | Meaning |
|---|---|---|---|
0x2f37... |
0x6254...70309 |
7 | Sender's balance |
0x439a... |
0x40b9...8b81a |
7 | Recipient's balance |
0x55fc... |
0x6254...70309 |
8 | Sender's allowance |
0x2606... |
0x40b9...8b81a |
8 | Recipient's allowance |
Slot 7 is the balances mapping; slot 8 is the allowances mapping.
Putting It All Together
Combining prestate traces with keccak256 preimages, we can now read the full story:
| Address | Role | Balance Before | Balance After | Change |
|---|---|---|---|---|
0x6254... |
Sender | 138,893.47 FLUID | 69,735.57 FLUID | -69,157.90 FLUID |
0x40b9... |
Recipient | 122,122.09 FLUID | 191,280.00 FLUID | +69,157.90 FLUID |
0x6254e0d4b3b40c1bf8bc3c359d33024c7cc70309 sent ~69,157.90 FLUID to 0x40b9fcc4afc60bce840d8a1fbce086067118b81a.
Why This Matters
You might wonder: is this worth the trouble? After all, ERC20 contracts emit Transfer(address indexed from, address indexed to, uint256 amount) events with exactly this information.
But notice what we extracted:
- Actual token balances before and after the transaction
- Allowance state that was checked during execution
- All of this without making any additional blockchain calls
This unlocks several powerful use cases:
- Transaction forensics: Understand exactly what state a contract saw when making decisions
- Indexer construction: Build token balance databases from traces alone
- Debugging: See the full context of why a transaction behaved a certain way
- Historical analysis: Reconstruct state at any point without replaying from genesis
The preimage tracer turns opaque storage slots into readable data structures—making Ethereum's state machine finally transparent.
We contributed keccak256PreimageTracer to Geth and it's been available since v1.15.6. To use it, run your trace with "prestateTracer" for state changes and "keccak256PreimageTracer" for hash mappings, then combine the results.