There are three main components that handle the way data is stored
- Disk Database: At the lowest level the data is persisted in a disk-backed key-value store
ethdb.KeyValueStore - Trie Structure: The system uses an Etherem Merkle Patricia trie and Verkle trees to organize accounts and storage cryptographically, the
Trieinterface provides the methods to read (GetAccount,GetStorage) and write (UpdateAccount,UpdateStorage) data while maintaining a verifiable state root hash - 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). ThestateObjectrecords the change in data and logs it to thejournalas abalanceChange, 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), thestateObjectchecks if the value is a null (no change), if the value is valid its logged to thejournalas astorageChange, the value is then stored in thedirtyStoragemap: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
0xbbmoves fromdirtyStoragetopendingStorage - Commit Markers: The system also populates
uncommittedStoragewith the original value to mark this slot for eventual Trie update at the end of the block. - Dirty Storage:
dirtyStorageis 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:
- The system opens the account’s Storage Trie from disk if not already cached.
- It iterates through
uncommittedStorageand callstr.UpdateStorage(0xaa, 0xbb). - If the value was set to
0x0, it would instead calltr.DeleteStorage.
- Result: The
stateObject.data.Rootis updated with the new hash of the modified storage trie.
Step D: Global Commitment & Persistence
StateDB.commit()aggregates all changed accounts.- Outputs:
accountUpdate: Contains the RLP-encoded account data (Nonce, Balance, Root).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
diffLayeris added to the Snapshot Tree, storing the flataccountDataandstorageDatafor 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:
- Check
dirtyStorage: If modified in the current transaction, return immediately. - Check
pendingStorage: If modified in a previous transaction within the same block, return that value. - Check
originStorage: This is a cache of values loaded from the database during this block. - Database Reader (
CachingDB): If not cached, the system uses the Reader.- Flat Reader: Attempts to find the value in the Snapshot Tree’s memory
diffLayersor the diskdiskLayer(fastest, this seems counter intuitive, so here is why -
When Each is Faster
- Flat Reader: Attempts to find the value in the Snapshot Tree’s memory
- 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
SelfDestructmarks thestateObjectasselfDestructed. - Storage Deletion: Setting a key to
common.Hash{}(zero). - Persistence: During
updateTrie, zero-values triggertr.DeleteStorage, which physically removes the keys from the Trie. - Snapshot Deletion: In the
diffLayer, a deleted item is stored as anilentry, 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:
GetAccount,GetStorage,ContractCode. - Existence Checks:
Exist,Empty(EIP-161). - Proofs:
Prove(Merkle Proofs) andWitness(stateless verification data). - Prefetching:
PrefetchAccountandPrefetchStorageto load data into memory before the EVM requests it. - Maintenance:
Prune(deleting stale historical data) andCompact(reclaiming disk space).
1. Read Operations: GetAccount, GetStorage, ContractCode
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:
- The system calls
Reader.Account(addr). - Snapshot Layer: It first attempts a “flat” read from the
Snapshot Tree. If a match is found in the memorydiffLayeror the diskdiskLayer, the RLP data is retrieved immediately. - Trie Layer: If the snapshot is missing, the
trieReaderlocks, navigates the Merkle Patricia Trie, and retrieves the account leaf. - Decoding: The RLP blob is decoded into a
StateAccountobject.
- The system calls
- Output:
*types.StateAccount(containsNonce,Balance,Root, andCodeHash).
B. GetStorage (Storage Slot Retrieval)
- Input:
common.Address(account) andcommon.Hash(key, e.g.,0x0...01) - Lifecycle:
- The system identifies the account’s
StorageRoot. - It checks the
pendingStorageandoriginStoragecaches in thestateObject. - If not cached, it calls
Reader.Storage(addr, key). - The
trieReaderopens the specific Storage Trie using the account’s storage root and retrieves the value associated with the key.
- The system identifies the account’s
- Output:
common.Hash(the data stored at that slot).
C. ContractCode Retrieval
- Input:
common.Addressandcommon.Hash(code hash) - Lifecycle:
- The
cachingCodeReaderchecks the in-memorycodeCache(256MB). - If it’s a cache miss, it performs a disk read via
rawdb.ReadCodeusing the code hash. - The bytecode is added to the cache for future requests.
- The
- 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:
- The account is marked for deletion in the
StateDB(e.g., viaSelfDestruct). - During
commit(), the system identifies this account hash in thedeletesmap. - It calls
tr.DeleteAccount(addr), which removes the leaf from the main trie.
- The account is marked for deletion in the
- Output: Updated Global State Root (after re-hashing).
B. DeleteStorage
- Input:
common.Addressand[]byte(key) - Lifecycle:
- The EVM sets a storage slot to a zero-value (
0x0...0). - This change is finalized into
uncommittedStorage. updateTrie()iterates through the uncommitted changes. Since the value is empty, it callstr.DeleteStorage(addr, key).- The trie collapses nodes if necessary (e.g., a branch with one child becomes a short node).
- The EVM sets a storage slot to a zero-value (
- 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:
- The system initiates
Trie.Prove(key, proofDb). - It traverses the trie from the root node down to the leaf representing the key.
- Every RLP-encoded node encountered on this path is collected and stored in the
proofDb. - If the key is missing, it collects nodes up to the longest existing prefix, proving the key’s absence.
- The system initiates
- Output: A populated
proofDb(aKeyValueWritercontaining 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:
- When an address is “touched” or identified as likely to be used,
prefetch()is called. - The
triePrefetcherassigns this to asubfetchergoroutine. - The subfetcher opens the target trie and calls
sf.trie.PrefetchAccount(addresses). - This forces the disk-backed nodes into the
CachingDBmemory layers.
- When an address is “touched” or identified as likely to be used,
- Output: No functional return; the
CachingDBis “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:
- During execution,
AccessEventsrecords every trie branch/chunk read or edited. - The system calls
Trie.Witness(), which aggregates these recorded access events. - It maps the string representation of trie paths to their raw RLP-encoded node data.
- During execution,
- 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:
newNodeIterator(statedb)is initialized.- Primary Traversal:
stateIttraverses the global account trie in post-order. - Leaf Detection: When a leaf (account) is found, the iterator looks up the preimage of the account hash to get the address.
- Secondary Traversal:
dataItis spawned to traverse the entire Storage Trie for that specific account. - Code Access: The contract code associated with that account is retrieved.
- Output:
common.Hash(the current node hash) andit.Account()orit.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.EncodeToBytesto convert structured data (like aStateAccount) 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:
- Input: A
StateAccountobject (e.g.,Nonce: 5,Balance: 1000,Root: 0xabc...,CodeHash: 0x123...). - Operation: The system calls
rlp.EncodeToBytes(account). - Intermediate Result: RLP prepends a length byte to each field and a master length byte for the entire list.
- Output: A raw
[]byteblob (e.g.,0xf84b05830f4240...). - Persistence: This blob is then hashed to create a trie node or written directly to the Snapshot Layer.