There are three main components that handle the way data is stored

  1. Disk Database: At the lowest level the data is persisted in a disk-backed key-value store ethdb.KeyValueStore
  2. Trie Structure: The system uses an Etherem Merkle Patricia trie and Verkle trees to organize accounts and storage cryptographically, the Trie interface provides the methods to read (GetAccountGetStorage) and write (UpdateAccount,UpdateStorage) data while maintaining a verifiable state root hash
  3. SnapShot Tree: To avoid expensive multi-level trie lookups a Snapshot Tree maintains a flat view of the state. It consists of a persistent base layer on disk (diskLayer) and multiple in-memory diff layers (diffLayer) that act as a journal tracking recent modifications from consecutive blocks.

Data Lifecycles

Writing Data

There are several steps involved in writing data, a state object stateObject, hold the canonical state of an account

STEP A : Local Modification (In-Memory)

  • Target Account : 0xaaaa
  • Balance Update: EVM Calls AddBalance(42). The stateObject records the change in data and logs it to the journal as a balanceChange, the journal is used to keep track of changes during execution, so in-case of failed executions, they can be reverted to its initial state.
  • Storage Update: The EVM calls SetState(key: 0xaa, value: 0xbb), the stateObject checks if the value is a null (no change), if the value is valid its logged to the journal as a storageChange, the value is then stored in the dirtyStorage map: dirtyStorage[0xaa] = 0xbb, the dirty storage is an in memory map used to hold current transaction data information

STEP B: Transaction Finalization Once a transaction finishes execution finalize() method is called on the stateObject

  • Pending Area: The value 0xbb moves from dirtyStorage to pendingStorage
  • Commit Markers: The system also populates uncommittedStorage with the original value to mark this slot for eventual Trie update at the end of the block.
  • Dirty Storage: dirtyStorage is then cleared up to free up space for the next transactions.

Step C: Block Finalization (Trie & Root Update)

  • At the end of the block, updateTrie() is triggered.
  • Logic:
    1. The system opens the account’s Storage Trie from disk if not already cached.
    2. It iterates through uncommittedStorage and calls tr.UpdateStorage(0xaa, 0xbb).
    3. If the value was set to 0x0, it would instead call tr.DeleteStorage.
  • Result: The stateObject.data.Root is updated with the new hash of the modified storage trie.

Step D: Global Commitment & Persistence

  • StateDB.commit() aggregates all changed accounts.
  • Outputs:
    1. accountUpdate: Contains the RLP-encoded account data (Nonce, Balance, Root).
    2. trienode.NodeSet: A collection of “dirty” nodes (raw modified trie data).
  • Disk Write: These are written to the Key-Value Store (e.g., LevelDB/Pebble).
  • Snapshot Update: A new diffLayer is added to the Snapshot Tree, storing the flat accountData and storageData for instant future reads.

What are “dirty” nodes? “Dirty” nodes are trie nodes that have been modified in memory but not yet replaced by their cryptographic hash

Step E: Maintenance and Prunning Because historical data continuously grows, an offline Pruner manages the long-term storage.
• Identification: The pruner uses Bloom filters to identify which trie nodes and contract codes are part of the active, non-stale state.
• Deletion: It iterates through the disk database and deletes any entries that are no longer referenced by the target state.
• Compaction: To physically reclaim space and improve performance, the database performs range compaction, purging the deleted data from the disk.

Data Reading Lifecycle

When the EVM calls GetState(0xaa) for account `0xdead:

  1. Check dirtyStorage: If modified in the current transaction, return immediately.
  2. Check pendingStorage: If modified in a previous transaction within the same block, return that value.
  3. Check originStorage: This is a cache of values loaded from the database during this block.
  4. Database Reader (CachingDB): If not cached, the system uses the Reader.
    • Flat Reader: Attempts to find the value in the Snapshot Tree’s memory diffLayers or the disk diskLayer (fastest, this seems counter intuitive, so here is why
    • When Each is Faster

  • DiffLayer faster: When accessing very recent state changes (last few blocks)
  • DiskLayer faster: When accessing stable state that’s been committed, or when difflayer chains get long This is why op-geth periodically flattens difflayers into the disk layer - to prevent performance degradation from long diff chains.).
  • Trie Reader: If snapshots are unavailable or not yet constructed, it performs a full cryptographic lookup in the Merkle Patricia Trie.

Data Deletion Lifecycle

  • Logic: Deletion is handled by setting values to zero/empty.
  • Account Deletion: Calling SelfDestruct marks the stateObject as selfDestructed.
  • Storage Deletion: Setting a key to common.Hash{} (zero).
  • Persistence: During updateTrie, zero-values trigger tr.DeleteStorage, which physically removes the keys from the Trie.
  • Snapshot Deletion: In the diffLayer, a deleted item is stored as a nil entry, which tells the system to ignore any values for that key in deeper layers.

Apart from writing operations, what other operations are there? Beyond writing, the system supports:

  • Read: GetAccountGetStorageContractCode.
  • Existence Checks: ExistEmpty (EIP-161).
  • Proofs: Prove (Merkle Proofs) and Witness (stateless verification data).
  • Prefetching: PrefetchAccount and PrefetchStorage to load data into memory before the EVM requests it.
  • Maintenance: Prune (deleting stale historical data) and Compact (reclaiming disk space).

1. Read Operations: GetAccountGetStorageContractCode

This lifecycle involves a priority-based lookup through multiple database layers (Snapshot, memory diffs, and the Trie).

A. GetAccount (Account Retrieval)

  • Input: common.Address (e.g., 0xAffE...)
  • Lifecycle:
    1. The system calls Reader.Account(addr).
    2. Snapshot Layer: It first attempts a “flat” read from the Snapshot Tree. If a match is found in the memory diffLayer or the disk diskLayer, the RLP data is retrieved immediately.
    3. Trie Layer: If the snapshot is missing, the trieReader locks, navigates the Merkle Patricia Trie, and retrieves the account leaf.
    4. Decoding: The RLP blob is decoded into a StateAccount object.
  • Output: *types.StateAccount (contains NonceBalanceRoot, and CodeHash).

B. GetStorage (Storage Slot Retrieval)

  • Input: common.Address (account) and common.Hash (key, e.g., 0x0...01)
  • Lifecycle:
    1. The system identifies the account’s StorageRoot.
    2. It checks the pendingStorage and originStorage caches in the stateObject.
    3. If not cached, it calls Reader.Storage(addr, key).
    4. The trieReader opens the specific Storage Trie using the account’s storage root and retrieves the value associated with the key.
  • Output: common.Hash (the data stored at that slot).

C. ContractCode Retrieval

  • Input: common.Address and common.Hash (code hash)
  • Lifecycle:
    1. The cachingCodeReader checks the in-memory codeCache (256MB).
    2. If it’s a cache miss, it performs a disk read via rawdb.ReadCode using the code hash.
    3. The bytecode is added to the cache for future requests.
  • Output: []byte (the raw contract EVM bytecode).

2. Deletion Operations: DeleteAccount and DeleteStorage

Deletions are staged in memory and only physically removed from the cryptographic tree during the block commitment phase.

A. DeleteAccount

  • Input: common.Address (e.g., 0xAffE...)
  • Lifecycle:
    1. The account is marked for deletion in the StateDB (e.g., via SelfDestruct).
    2. During commit(), the system identifies this account hash in the deletes map.
    3. It calls tr.DeleteAccount(addr), which removes the leaf from the main trie.
  • Output: Updated Global State Root (after re-hashing).

B. DeleteStorage

  • Input: common.Address and []byte (key)
  • Lifecycle:
    1. The EVM sets a storage slot to a zero-value (0x0...0).
    2. This change is finalized into uncommittedStorage.
    3. updateTrie() iterates through the uncommitted changes. Since the value is empty, it calls tr.DeleteStorage(addr, key).
    4. The trie collapses nodes if necessary (e.g., a branch with one child becomes a short node).
  • Output: Updated Account Storage Root.

3. Merkle Proofs: Prove

This operation provides the cryptographic path required to verify data without the full state.

  • Input: key []byte (the hashed path in the trie)
  • Lifecycle:
    1. The system initiates Trie.Prove(key, proofDb).
    2. It traverses the trie from the root node down to the leaf representing the key.
    3. Every RLP-encoded node encountered on this path is collected and stored in the proofDb.
    4. If the key is missing, it collects nodes up to the longest existing prefix, proving the key’s absence.
  • Output: A populated proofDb (a KeyValueWriter containing the path nodes).

4. Prefetching: PrefetchAccount and PrefetchStorage

Prefetching is a background optimization to reduce disk I/O latency during transaction execution.

  • Input: []common.Address (accounts) or [][]byte (storage keys)
  • Lifecycle:
    1. When an address is “touched” or identified as likely to be used, prefetch() is called.
    2. The triePrefetcher assigns this to a subfetcher goroutine.
    3. The subfetcher opens the target trie and calls sf.trie.PrefetchAccount(addresses).
    4. This forces the disk-backed nodes into the CachingDB memory layers.
  • Output: No functional return; the CachingDB is “warmed” for future reads.

5. Witness Generation: Witness

Witnesses allow for stateless verification, containing the minimal set of nodes needed to execute a block.

  • Input: None (triggered at block/transaction end)
  • Lifecycle:
    1. During execution, AccessEvents records every trie branch/chunk read or edited.
    2. The system calls Trie.Witness(), which aggregates these recorded access events.
    3. It maps the string representation of trie paths to their raw RLP-encoded node data.
  • Output: map[string][]byte (Keys: paths, Values: RLP nodes).

6. Iteration: NodeIterator

Iteration is used to traverse the entire state for maintenance tasks like dumping or pruning.

  • Input: startKey []byte (optional starting position)
  • Lifecycle:
    1. newNodeIterator(statedb) is initialized.
    2. Primary Traversal: stateIt traverses the global account trie in post-order.
    3. Leaf Detection: When a leaf (account) is found, the iterator looks up the preimage of the account hash to get the address.
    4. Secondary Traversal: dataIt is spawned to traverse the entire Storage Trie for that specific account.
    5. Code Access: The contract code associated with that account is retrieved.
  • Output: common.Hash (the current node hash) and it.Account() or it.Slot() (the data).

RLP (Recursive Length Prefix) is the primary serialization format used across the system to encode and decode complex data structures into a space-efficient binary format. It is used for virtually every core component, including block headers, transactions, account metadata, and trie nodes.

1. Purpose and Mechanism

The goal of RLP is to transform data objects into a format that can be stored on disk or transmitted over the network while remaining cryptographically verifiable.

  • Encoding: The system uses rlp.EncodeToBytes to convert structured data (like a StateAccount) into a byte slice.
  • Decoding: The process is reversed using rlp.DecodeBytes, which reconstructs the original object from raw bytes.
  • Length Prefixing: As the name implies, RLP works by adding a prefix to the data that specifies its length, allowing the decoder to know exactly how many bytes to read for each field.

2. Specialized RLP Formats in Data Storage

The storage system employs several variations of RLP to optimize performance:

  • Slim RLP: Used for mutated accounts in state updates to save space by excluding unnecessary fields.
  • Prefix-Zero-Trimmed RLP: A specialized format for storage slot values where leading zeros are removed before encoding to further reduce the data footprint.
  • Full Account RLP: The standard, complete encoding used for accounts within the cryptographic tries.

3. Core Applications

  • Block Hashes: A block’s unique hash is the Keccak256 hash of its RLP encoding.
  • Snapshots: Values stored in the high-performance Snapshot Tree are RLP-encoded.
  • Trie Integrity: Account data is stored as RLP-encoded blobs within the Merkle Patricia Trie; if the blob is incorrect for decoding, the system identifies the trie as corrupted.

4. Lifecycle Example: Encoding an Account

When the system commits an account update to the database, the following flow occurs:

  1. Input: A StateAccount object (e.g., Nonce: 5, Balance: 1000, Root: 0xabc..., CodeHash: 0x123...).
  2. Operation: The system calls rlp.EncodeToBytes(account).
  3. Intermediate Result: RLP prepends a length byte to each field and a master length byte for the entire list.
  4. Output: A raw []byte blob (e.g., 0xf84b05830f4240...).
  5. Persistence: This blob is then hashed to create a trie node or written directly to the Snapshot Layer.