Browse by type
A database for the Ethereum state trie.
TBD is an embedded database which supports reading and writing Accounts as well as Storage values in atomic transactions (typically corresponding to a single block). The primary objective of this storage format is to minimize sequential disk access latency during block processing, in 2 stages: - Reading Account and Storage values while executing EVM transactions, - Writing Trie updates, including computation of the State Root, upon completion of the block.
This new database design is intended to replace the traditional generic Key/Value stores typically used in EL clients, including LSM-tree databases such as LevelDB (used by geth) and B-tree databases such as MDBX (used by Reth), specifically for persisting the EVM State Trie. While these other Key/Value databases are highly tuned and extremely performant for reading and writing arbitrary unstructured data, this is a suboptimal mechanism for persisting a highly structured data structure such as the Ethereum State Trie. While traversing the trie from Root to Leaf in order to read a single value is predicted to scale logarithmically with the size of the trie (O(log N)), this is also the cost associated with accessing each item stored in a Key/Value database. In effect, the database must be fully searched for each independent trie node, and this work must be repeated until a Leaf node is found, resulting in a true scaling factor of O(log N * log N). In practical terms with the scale of chains such as Ethereum or Base, we may expect traversal of the State Trie to require on the order of ~50 disk operations in order to read or write a single value (Account or Storage Slot), although reads and writes to higher portions of the trie may be trivially deduplicated when processing a batch of Trie accesses, as is typically the case for EVM block execution.
Instead of treating each item in the database as unique and unrelated, we instead choose to arrange the data on disk based on the structure of the Trie. Related nodes (Subtries) are grouped together into a series of Pages, and finding the child of a Node only requires following a pointer to the next Page containing it. This may require reading or writing to a “random” section of the disk, but we assume that colocation beyond the Page size is not critical to modern solid state storage. Instead of the O(log N * log N) scaling behavior of using a traditional Key/Value storage to persist the Trie, we expect that this storage layout will instead scale at the theoretical optimal O(log N). In practice, this should reduce the cost of accessing a single value from ~50 disk operations down to ~8. By packing together multiple layers of the trie onto the same Page, this may even be further reduced to as low as ~4 operations, resulting in around a 10x increase in bandwidth and reduction in latency for storage-related workloads. Further optimizations such as omitting top levels of the Trie from persistent storage may also be possible, at the cost of higher complexity, memory usage, and startup time.
In order to support simultaneous reads and writes, enabling parallel EVM execution as well as state reads in tandem with block execution, this database achieves Multiversion Concurrency Control (MVCC) via a Copy on Write scheme. Updates to disk are performed by copying the contents of existing Pages to unused (“orphaned”) or newly-allocated Pages, and only updating the root pointer after these changes have been persisted. By reserving the first two pages of the disk as Root Pages, we can ensure that the database state will always be consistent after the latest fsync, with the latest root identified by selecting the Root Page with the greater version number. This approach allows for a single writer (sufficient even for parallel EVM execution), multiple concurrent readers, and strong consistency without the need for a Write Ahead Log (WAL). In a Tree-structured database with a Copy On Write scheme, modifying a Leaf node typically results in write amplification as it requires updating all of its ancestors, including the root node. However, in the case of the Ethereum MPT (Merkle Patricia Trie), which uses a recursive hashing scheme, this write amplification has minimal impact because any modification to a subtrie in the MPT causes changes to the hash of all its ancestors, including the Root, requiring these ancestor Pages to be rewritten regardless. Notably, the use of Copy On Write requires that unreferenced pages be intentionally reclaimed in order to prevent the storage requirements from growing much faster than active state size.
Example shown below holding the following (8 nibble) key/value pairs for illustrative purposes: 12345672: first, 12345678: second, 12349bcd: third, 1234f074: fourth, 1234f07d: fifth, 1234fe00: sixth, 12cba5ed: seventh
flowchart TD
subgraph Page 1
A1{12} --> |3| A2
A1 --> |c| A10[ba5ed: seventh]
A2{4} --> |5| A3(Page 2)
A2 --> |9| A6[bcd: third]
A2 --> |f| A7{ }
A7 -->|0| A8(Page 3)
A7 -->|e| A9[00: sixth]
end
A3 --> B1
subgraph Page 2
B1{67} --> |2| B2[: first]
B1 --> |8| B3[:second]
end
A8 --> C1
subgraph Page 3
C1{7} --> |4| C3[: fourth]
C1 --> |d| C4[: fifth]
end
While the MPT structure is formally designed for arbitrary path/value data storage, there are specific optimizations which may be made when focusing solely on the State Trie. In particular, all Accounts will have a 32 byte path (64 nibbles), meaning that Branch nodes will never actually store any data within the Account portion of the trie. All Accounts must be stored in Pseudo-Leaf nodes instead (these may contain a reference to a Storage Trie Root). A similar behavior can be noted within each Storage Trie, as each Storage slot is mapping from a 32 byte key to a 32 byte value. This is valuable information when designing a Page format. If each Account is addressed by a 32 byte path and each Storage slot is addressed by a 64 byte path, then we also can imply the type of a node simply by the length of its path (32: Account, 64: Storage, Other: Branch) or automatically determine the length of the path for any Leaf (Account or Storage) node.
All data in this database is broken down into 4KB Pages, the minimal unit of disk IO on modern SSDs. Pages may reference each other in a strictly hierarchical structure (no cycles), which allows old Pages to simply become orphaned and later recycled after each commit. In order to reduce the overhead associated with copying and modifying Pages, we aim to minimize unnecessary serialization/deserialization. This is achieved by utilizing a Slotted Page format, with a list of ordered Pointers at the beginning of each Page which refers to a sequence of Cells which are written backwards from the end of the Page. Updating a Page may only require modifying a single Cell’s contents, even when the size of the Cell’s Trie Node changes.
Although the state root of the MPT is formally defined based on the RLP encoding of Nodes, this is an inefficient use of state. Instead we will utilize custom encoding formats specific to Branches, Accounts, and Storage slots which can be more efficiently compressed on disk, while only using RLP for hash computation.
In addition to using Pages to store data (Trie Nodes), we will also need to keep track of orphaned Pages. This is tracked using a standard on-disk list format which also leverages Copy-on-Write, which has the side effect that updating the orphaned Pages set will always create new orphaned Pages.
Database is broken down in to pages, each page has 4 KB size. * Header * Snapshot ID (8 bytes) * This is the Version when page is created. * Page content (4088 bytes) * This is either root page or subtrie page.
block-beta
columns 1
block:outer
snapshotId["Snapshot ID (8 bytes)"]
end
pageContent["Page Content (4088 bytes)"]
Pages 0 and 1 are reserved for the current and previous Root Pages, which contain database-level metadata.
block-beta
columns 1
block:outer
SnapshotID["Snapshot ID (8 bytes)"]
end
StateRoot["State Root (32 bytes)"]
RootPageNum["Root Subtrie Page Number (4 bytes)"]
MaxPageNum["Max Page Number (4 bytes)"]
O0["Orphaned Page 0 (4 bytes)"]
O1["Orphaned Page 1 (4 bytes)"]
O2["Orphaned Page 2 (4 bytes)"]
OETC["..."]
OLast["Orphaned Page 1010 (4 bytes)"]
ONext["Orphan List Page (4 bytes)"]
Note that the number of Root Pages must be a minimum of 2 in order to support MVCC, but we may choose to use a larger value in order to support rapid rollbacks / chain reorgs. In this case, we would likely use a much larger value, such as 128 or 1024. Rolling back from version V’ -> V would require nullifying all Root Pages with version greater than V and treating all Pages after the Max Page Number as Orphaned.
Pages 256 and greater are used to store the Trie Nodes, with each Page containing a single Subtrie, which may consist of any number of Branch, Account, and Storage nodes.
block-beta
columns 1
block:outer
snapshotId["Snapshot ID (8 bytes)"]
end
NumCells["Number of Cells (1 byte)"]
block
P0["Pointer 0 (3 bytes)"] P1["Pointer 1 (3 bytes)"] P2["Pointer 2 (3 bytes)"] P3["Pointer 3 (3 bytes)"]
end
Blank1["Blank Space"]
C3["Cell 3"]
C2["Cell 2"]
Blank2["Blank Space"]
C1["Cell 1"]
C0["Cell 0"]
P0-->C1
P1-->C3
P2-->C2
P3-->C0
Due to the exponential decrease in Trie density as deeper portions of the Trie are traversed, we expect the overwhelming majority of Branch nodes to contain a small number of children (2-4) and short prefix (0-4 nibbles), but also expect the top portion of the Trie to primarily consist of nearly-16-child Branches with 0 additional prefix. In order to keep the encoded size of a Branch node relatively stable as individual children are inserted and removed, we choose to use a variable branching factor for the Branch node based on the number of children it contains. Based on the value of the Children Bitmask, the Branch will contain 2, 4, 8, or 16 Child slots in order to only resize the Branch node on each doubling of Child occupancy.
Note that a Branch Node with a non-empty Path Prefix is treated as both an Extension Node and a Branch Node for the purpose of RLP encoding and merkleization, and must be hashed twice.
Example shown below with only 3 children (0, 7, and F), using a branch width of 4
block-beta
columns 1
block:flags:1
columns 8
Type["Type (2 bits)"]
PathLen["Path Prefix Len (6 bits)"]
end
PathNibbles["Path Prefix Nibbles (0-32 bytes)"]
ChildrenBitmask["Children Bitmask (2 bytes)"]
block:c0
C0_RLP["Child0 RLP (33 bytes)"] C0_Location["Child0 Location (4 bytes)"]
end
block:c7
C7_RLP["Child7 RLP (33 bytes)"] C7_Location["Child7 Location (4 bytes)"]
end
block:cf
CF_RLP["ChildF RLP (33 bytes)"] CF_Location["ChildF Location (4 bytes)"]
end
block:cx
CBlank["Blank Child Slot"]
end
browse all types & interfaces →
$ claude mcp add triedb \
-- python -m otcore.mcp_server <graph>