Skip to main content
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

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