Documentation Index
Fetch the complete documentation index at: https://voltaire.tevm.sh/llms.txt
Use this file to discover all available pages before exploring further.
Conceptual Guide - For API reference and method documentation, see State API.
Ethereum state is a global key-value mapping of all accounts (EOAs and contracts) to their data (balance, nonce, code, storage). This guide teaches state fundamentals using Tevm.
What is Ethereum State?
Ethereum maintains a single, canonical world state - a mapping from 20-byte addresses to account data. This state changes with every transaction in every block.
// Conceptually, state is:
Map<Address, Account>
// Where Account contains:
{
balance: bigint, // Account balance in wei
nonce: bigint, // Transaction count (EOAs) or contract creation count (contracts)
storageRoot: Hash, // Root of account's storage trie (contracts only)
codeHash: Hash // Keccak256 of contract bytecode (contracts only)
}
Two Types of State
Ethereum distinguishes between account state (global) and storage state (per-contract):
- Account State: Maps addresses to account objects (balance, nonce, storageRoot, codeHash)
- Storage State: Maps 256-bit slots to 256-bit values within each contract
The StorageKey primitive combines these: {address, slot} uniquely identifies any storage location.
Account State
Two account types exist in Ethereum:
import * as State from 'tevm/State';
import * as Address from 'tevm/Address';
import * as Hash from 'tevm/Hash';
// EOA example (user wallet)
const eoaAddress = Address("0x742d35Cc6634C0532925a3b844Bc9e7595f51e3e");
const eoaAccount = {
balance: 1000000000000000000n, // 1 ETH in wei
nonce: 42n, // Has sent 42 transactions
storageRoot: Hash("0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421"), // Empty storage
codeHash: Hash("0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470") // Empty code
};
// EOAs have:
// - Empty storage (storageRoot = hash of empty trie)
// - Empty code (codeHash = hash of empty string)
// - Nonce tracks transaction count
import * as State from 'tevm/State';
import * as Address from 'tevm/Address';
import * as Hash from 'tevm/Hash';
// Contract account (USDC on mainnet)
const contractAddress = Address("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
const contractAccount = {
balance: 0n, // Contract has no ETH
nonce: 1n, // Has created 1 contract (proxy pattern)
storageRoot: Hash("0x..."), // Root of storage trie with token balances, allowances
codeHash: Hash("0x...") // Keccak256 hash of deployed bytecode
};
// Contracts have:
// - Non-empty storage (storageRoot = merkle root of state)
// - Non-empty code (codeHash = hash of deployed bytecode)
// - Nonce tracks created contracts (CREATE/CREATE2)
Storage State
Each contract account has its own storage trie mapping 256-bit slot numbers to 256-bit values:
import * as State from 'tevm/State';
import * as Address from 'tevm/Address';
// USDC contract storage example
const usdcAddress = Address("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
// Storage slot 0: total supply (simple variable)
const slot0 = State.StorageKey(usdcAddress, 0n);
// Storage slot 1: balances mapping base slot
const balancesBaseSlot = 1n;
// To access balances[userAddress], compute:
// slot = keccak256(abi.encode(userAddress, balancesBaseSlot))
const userAddress = Address("0x742d35Cc6634C0532925a3b844Bc9e7595f51e3e");
const balanceSlot = computeMappingSlot(userAddress, balancesBaseSlot);
const userBalanceKey = State.StorageKey(usdcAddress, balanceSlot);
Solidity Storage Layout
Solidity compiles variables to storage slots following specific rules:
// USDC-like token contract
contract Token {
uint256 public totalSupply; // Slot 0
mapping(address => uint256) public balances; // Slot 1 (base slot)
mapping(address => mapping(address => uint256)) public allowances; // Slot 2
address public owner; // Slot 3
bool public paused; // Slot 4
}
import * as State from 'tevm/State';
import { Keccak256 } from 'tevm/crypto';
// Simple variables occupy sequential slots
const totalSupplyKey = State.StorageKey(contractAddress, 0n);
const ownerKey = State.StorageKey(contractAddress, 3n);
const pausedKey = State.StorageKey(contractAddress, 4n);
// Mappings: slot = keccak256(abi.encode(key, baseSlot))
function computeMappingSlot(key: Uint8Array, baseSlot: bigint): bigint {
const encoded = Bytes64();
encoded.set(new Uint8Array(32 - key.length).fill(0), 0); // Padding
encoded.set(key, 32 - key.length);
encoded.set(encodeBigInt(baseSlot, 32), 32);
const hash = Keccak256.hash(encoded);
return BigInt(Hex.fromBytes(hash));
}
// balances[userAddress]
const balanceSlot = computeMappingSlot(userAddressBytes, 1n);
const balanceKey = State.StorageKey(contractAddress, balanceSlot);
// allowances[owner][spender] - nested mapping
const ownerSlot = computeMappingSlot(ownerAddressBytes, 2n);
const allowanceSlot = computeMappingSlot(spenderAddressBytes, ownerSlot);
const allowanceKey = State.StorageKey(contractAddress, allowanceSlot);
Merkle Patricia Trie Structure
Ethereum stores state in a Merkle Patricia Trie - a cryptographic data structure combining:
- Merkle Tree: Any change to data changes the root hash
- Patricia Trie: Space-efficient prefix tree for key-value lookups
How It Works
State Trie (accounts):
Root Hash: 0x1234...
├─ Branch Node (prefix: 0x0)
│ ├─ Extension Node (prefix: 0x07)
│ │ └─ Leaf Node: Account(0x0742d35...) → {balance, nonce, storageRoot, codeHash}
│ └─ Leaf Node: Account(0x0A0b869...) → {balance, nonce, storageRoot, codeHash}
└─ Branch Node (prefix: 0x1)
└─ Leaf Node: Account(0x1234567...) → {balance, nonce, storageRoot, codeHash}
Storage Trie (per-contract):
Root Hash: 0x5678...
├─ Branch Node (prefix: 0x0)
│ └─ Leaf Node: Slot(0x0000...00) → 0x00000000000000000000000003b9aca00 (1,000,000,000)
└─ Branch Node (prefix: 0xf)
└─ Leaf Node: Slot(0xf1c2...3e) → 0x000000000000000000000000000186a0 (100,000)
Each node hashes its children, creating a merkle tree where:
- The state root proves the entire account state
- Each account’s storageRoot proves that contract’s storage
State Root
The state root is a single 32-byte hash proving the entire world state:
import * as Hash from 'tevm/Hash';
// Every block header includes state root
const blockStateRoot = Hash("0x123abc..."); // 32 bytes
// Changing ANY account data changes the state root
// This enables:
// - Light client verification
// - State proofs
// - Fraud proofs for rollups
// Example: Transfer changes state root
const beforeTransfer = Hash("0xaaa...");
// ... execute transfer transaction ...
const afterTransfer = Hash("0xbbb...");
// State root changed because sender/receiver balances changed
console.log(Hash.equals(beforeTransfer, afterTransfer)); // false
Storage Key Operations
Tevm provides utilities for working with storage keys:
import * as State from 'tevm/State';
import * as Address from 'tevm/Address';
const contract = Address("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
// Create storage keys
const key1 = State.StorageKey(contract, 0n);
const key2 = State.StorageKey(contract, 1n);
// Compare keys
console.log(State.StorageKey.equals(key1, key2)); // false
console.log(State.StorageKey.equals(key1, key1)); // true
// Serialize for map keys
const map = new Map<string, bigint>();
map.set(State.StorageKey.toString(key1), 100n);
map.set(State.StorageKey.toString(key2), 200n);
// String format: "address:slot"
console.log(State.StorageKey.toString(key1));
// "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48:0"
// Deserialize from string
const keyStr = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48:42";
const parsed = State.StorageKey(keyStr);
console.log(Address.toHex(parsed.address)); // "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
console.log(parsed.slot); // 42n
// Type guard
if (State.StorageKey.is(key1)) {
console.log("Valid storage key");
}
State Transitions
Every transaction modifies state. The EVM applies state transitions:
import * as Address from 'tevm/Address';
import * as Hash from 'tevm/Hash';
// State transition example: ETH transfer
const sender = Address("0xAlice...");
const receiver = Address("0xBob...");
const amount = 1000000000000000000n; // 1 ETH
const gasPrice = 20000000000n; // 20 gwei
const gasUsed = 21000n;
// Before transaction
const stateBefore = new Map([
[Address.toHex(sender), {
balance: 2000000000000000000n,
nonce: 5n,
storageRoot: emptyStorageRoot,
codeHash: emptyCodeHash
}],
[Address.toHex(receiver), {
balance: 500000000000000000n,
nonce: 0n,
storageRoot: emptyStorageRoot,
codeHash: emptyCodeHash
}]
]);
// Apply transaction
function applyTransaction(state: Map<string, Account>, tx: Transaction) {
const senderAddr = Address.toHex(tx.from);
const receiverAddr = Address.toHex(tx.to);
// 1. Check sender balance >= amount + gas
const sender = state.get(senderAddr)!;
const totalCost = tx.value + (tx.gasPrice * tx.gasLimit);
if (sender.balance < totalCost) throw new Error("Insufficient balance");
// 2. Increment sender nonce
sender.nonce += 1n;
// 3. Deduct amount + gas from sender
sender.balance -= (tx.value + (tx.gasPrice * gasUsed));
// 4. Add amount to receiver
const receiver = state.get(receiverAddr)!;
receiver.balance += tx.value;
// 5. Refund unused gas (gasLimit - gasUsed)
sender.balance += (tx.gasPrice * (tx.gasLimit - gasUsed));
return state;
}
// After transaction
const stateAfter = applyTransaction(stateBefore, {
from: sender,
to: receiver,
value: amount,
gasPrice,
gasLimit: 21000n
});
// State root changes
const oldRoot = computeStateRoot(stateBefore);
const newRoot = computeStateRoot(stateAfter);
console.log(`State root changed: ${!Hash.equals(oldRoot, newRoot)}`); // true
Contract Storage Changes
Storage changes also trigger state root updates:
import * as State from 'tevm/State';
// SSTORE operation changes storage
const contractAddr = Address("0xContract...");
const storageKey = State.StorageKey(contractAddr, 0n);
// Before: slot 0 = 100
storage.set(State.StorageKey.toString(storageKey), 100n);
const storageBefore = computeStorageRoot(storage);
// After: slot 0 = 200 (SSTORE 0x00, 0xc8)
storage.set(State.StorageKey.toString(storageKey), 200n);
const storageAfter = computeStorageRoot(storage);
// Storage root changed
console.log(Hash.equals(storageBefore, storageAfter)); // false
// Contract's storageRoot in account state also changes
// Which changes the account trie
// Which changes the state root
Merkle Proof Verification
Merkle proofs allow proving account or storage data without the full state:
import * as Hash from 'tevm/Hash';
import { Keccak256 } from 'tevm/crypto';
import * as Rlp from 'tevm/Rlp';
// Merkle proof verifies account data against state root
function verifyAccountProof(
stateRoot: Hash,
accountAddress: Address,
accountData: Account,
proof: Uint8Array[]
): boolean {
// 1. Start with account data hash
const accountRlp = Rlp.encode([
accountData.nonce,
accountData.balance,
accountData.storageRoot,
accountData.codeHash
]);
let currentHash = Keccak256.hash(accountRlp);
// 2. Walk up the trie using proof nodes
for (const proofNode of proof) {
const combined = new Uint8Array(proofNode.length + currentHash.length);
combined.set(proofNode, 0);
combined.set(currentHash, proofNode.length);
currentHash = Keccak256.hash(combined);
}
// 3. Final hash should equal state root
return Hash.equals(Hash(currentHash), stateRoot);
}
// Example usage
const proof = [
new Uint8Array([/* proof node 1 */]),
new Uint8Array([/* proof node 2 */]),
new Uint8Array([/* proof node 3 */])
];
const isValid = verifyAccountProof(
blockStateRoot,
Address("0x742d35Cc6634C0532925a3b844Bc9e7595f51e3e"),
eoaAccount,
proof
);
console.log(`Proof valid: ${isValid}`);
Common Use Cases
Light Client Verification
Light clients verify state without downloading full blockchain:
// Light client flow
async function verifyBalance(
rpcUrl: string,
address: Address,
blockNumber: bigint
): Promise<{ balance: bigint, verified: boolean }> {
// 1. Get block header (contains state root)
const header = await fetch(`${rpcUrl}/eth_getBlockByNumber`, {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_getBlockByNumber',
params: [blockNumber.toString(16), false],
id: 1
})
}).then(r => r.json());
const stateRoot = Hash(header.result.stateRoot);
// 2. Request account proof from full node (EIP-1186)
const proof = await fetch(`${rpcUrl}/eth_getProof`, {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_getProof',
params: [Address.toHex(address), [], blockNumber.toString(16)],
id: 2
})
}).then(r => r.json());
// 3. Verify proof against state root
const accountData = proof.result;
const isValid = verifyAccountProof(
stateRoot,
address,
accountData,
proof.result.accountProof
);
return {
balance: BigInt(accountData.balance),
verified: isValid
};
}
Storage Proof for Token Balances
Prove token balance without trusting the RPC provider:
import * as State from 'tevm/State';
async function verifyTokenBalance(
tokenAddress: Address,
holderAddress: Address,
blockNumber: bigint
): Promise<{ balance: bigint, verified: boolean }> {
// 1. Compute storage slot for balances[holder]
const balanceSlot = computeMappingSlot(
Address.toBytes(holderAddress),
1n // Assuming balances mapping is at slot 1
);
// 2. Get storage proof (EIP-1186)
const proof = await fetch(`${rpcUrl}/eth_getProof`, {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_getProof',
params: [
Address.toHex(tokenAddress),
[balanceSlot.toString(16)],
blockNumber.toString(16)
],
id: 1
})
}).then(r => r.json());
// 3. Verify storage proof
const storageValue = BigInt(proof.result.storageProof[0].value);
const isValid = verifyStorageProof(
proof.result.storageHash,
balanceSlot,
storageValue,
proof.result.storageProof[0].proof
);
return {
balance: storageValue,
verified: isValid
};
}
Optimistic Rollup Fraud Proofs
Rollups use state proofs for fraud detection:
// Fraud proof submission
async function submitFraudProof(
l1StateRoot: Hash,
l2Batch: Batch,
invalidTx: Transaction
) {
// 1. Prove pre-state against L1 state root
const preStateProof = await generateStateProof(l1StateRoot, [
invalidTx.from,
invalidTx.to
]);
// 2. Execute transaction locally
const actualState = applyTransaction(preStateProof.state, invalidTx);
const actualPostStateRoot = computeStateRoot(actualState);
// 3. Compare with claimed post-state
const claimedPostStateRoot = l2Batch.postStateRoot;
if (!Hash.equals(actualPostStateRoot, claimedPostStateRoot)) {
// Fraud detected! Submit proof on-chain
await l1Contract.proveFraud({
batchId: l2Batch.id,
preStateRoot: l1StateRoot,
preStateProof,
transaction: invalidTx,
claimedPostState: claimedPostStateRoot,
actualPostState: actualPostStateRoot
});
// L1 contract verifies proof and slashes sequencer
}
}
Contract Storage Analysis
Analyze storage layout and usage:
import * as State from 'tevm/State';
// Enumerate storage slots
async function analyzeContractStorage(
contractAddress: Address,
provider: any
): Promise<Map<bigint, bigint>> {
const storage = new Map<bigint, bigint>();
// Storage slots are 2^256 possible values
// In practice, query known slots or iterate from 0
for (let slot = 0n; slot < 100n; slot++) {
const key = State.StorageKey(contractAddress, slot);
const value = await provider.send('eth_getStorageAt', [
Address.toHex(contractAddress),
'0x' + slot.toString(16).padStart(64, '0'),
'latest'
]);
const valueBigInt = BigInt(value);
if (valueBigInt !== 0n) {
storage.set(slot, valueBigInt);
console.log(`Slot ${slot}: ${valueBigInt}`);
}
}
return storage;
}
// Reverse engineer storage layout
const usdcStorage = await analyzeContractStorage(usdcAddress, provider);
// Slot 0: 1000000000 (totalSupply)
// Slot 3: 0x742d35... (owner address)
// Slot 4: 0 (paused = false)
Resources
Next Steps