The store package serves as the storage layer for the Canopy blockchain, utilizing BadgerDB as its underlying key-value store. It offers structured abstractions for nested transactions, state management, and indexed blockchain data storage. This document provides a comprehensive overview of the package’s core components and their functionalities, introducing key concepts while progressively building each component into a complete storage solution.
## Why BadgerDB?`
BadgerDB is a fast, embeddable, persistent key-value
(KV) database written in pure Go. It’s designed to be highly performant for both read and write
operations and is the underlying databased used by the Canopy Blockchain for all of its persistence
operations.
BadgerDB has been replaced by pebbleDB, a documentation update is required
In Canopy’s store’s module:
The store package is built from several key components that work together, like building blocks, to create a complete storage system. It could be represented like a well-organized filing cabinet, where each component has a specific job in managing and storing data. Each component from the simplest to the most complex is described as follows:
Txn: Database transaction manager and BadgerDB bridge
RWStoreI interfaceTxn: The transaction manager
RWStoreI interface to interact with the databaseSMT (Sparse Merkle Tree): A special data structure for proving data existence
RWStoreI interface and leverages Txn to interact with the
databaseIndexer: The filing system
Txn under the hood to save the data in BadgerDBStore: The main coordinator
Indexer: Organizes and indexes blockchain dataStateStore: Stores the actual blockchain data using Txn under the hoodStateCommitStore: Stores hashes of the data using SMT under the hoodgraph TD
Store[Store] --> Indexer[Indexer<br/><i>Indexed data</i>]
Store --> StateStore[StateStore<br/><i>Raw Data</i>]
Store --> StateCommitStore[StateCommitStore<br/><i>Hash Data</i>]
StateStore --> Txn[Txn]
StateCommitStore --> SMT[SMT<br/><i>Sparse Merkle Tree</i>]
Indexer --> Txn
SMT --> Txn
Txn --> BadgerDB[BadgerDB]
Store --> BadgerDB
Store manages three main components:
Indexer (for indexed blockchain data)StateStore (for raw data)StateCommitStore (for hash data)StateStore, Indexer, SMT) use it directlyBadgerDB serves as the foundation:
Txn to reach BadgerDBThis architecture ensures each component handles its specific tasks while maintaining consistent data storage and access patterns.
The Txn type serves as both a bridge to BadgerDB and a transaction manager, providing an
abstraction layer over BadgerDB’s native transaction system. It implements the RWStoreI interface,
enabling atomic operations, rollbacks, and nested transactions.
Txn builds upon BadgerDB’s foundation by:
Interface Compliance: Implements the RWStoreI interface, establishing a consistent contract
for all storage operations within Canopy. This standardization enables different components to
interact with the storage layer uniformly.
Transaction Management: Encapsulates BadgerDB’s transaction handling, supporting both read-only and read-write operations through:
The main operations supported by Txn according to the RWStoreI interface are:
Get(key []byte) ([]byte, ErrorI): Retrieves the value associated with the given key.Set(key, value []byte) ErrorI: Sets the value for the given key.Delete(key []byte) ErrorI: Deletes the value associated with the given key.Iterator(prefix []byte) (IteratorI, ErrorI): Creates an iterator over byte-wise
lexicographically sorted key-value pairs that start with the given prefix.RevIterator(prefix []byte) (IteratorI, ErrorI): Creates a reverse iterator over byte-wise
lexicographically sorted key-value pairs that start with the given prefix.All keys in Txn are automatically prefixed with a unique identifier (e.g., “s/” for state
store, “x/” for commitment nodes and commit IDs) to achieve two main purposes:
Data Isolation: Each component (StateStore, StateCommitStore, Indexer) maintains its own
prefix-based namespace, preventing key collisions in the shared BadgerDB instance.
Efficient Iteration: Since BadgerDB stores keys in lexicographical order, prefixes enable efficient range queries within specific components. For example, iterating through all state store entries (“s/…”) without touching commitment and commit ID data (“x/…”).
These prefixes are only used internally and never exposed to the user.
The Txn type provides a transaction-like interface for the store, enabling atomic operations and
rollbacks. All writes are first stored in an internal in-memory txn before being flushed to the
database, leveraging either the in-memory txn for current state keys or a badgerDB reader for
committed keys. This design is particularly useful for testing block proposals and managing
ephemeral states.
A key feature of this implementation is its support for nested transactions, which offer a code-level abstraction for managing hierarchical state modifications. Unlike database-level nested transactions, these are implemented entirely in memory, allowing operations to be grouped and independently committed or rolled back. This creates an abstraction for managing complex state changes that need to be atomic at different levels, while maintaining compatibility with BadgerDB’s single-level transaction model.
Some of the key Features of the nested transactions are:
Hierarchical Structure Transactions form a hierarchy where child transactions inherit parent state but keep their own changes isolated until committed. Parents can roll back child changes atomically.
Independent Commit/Rollback Each transaction level can be committed or rolled back independently. Child changes affect the parent only when committed, and parent rollbacks undo all nested changes.
State Isolation Changes are confined to the transaction level where they occur. Parents don’t see uncommitted child changes; children can access committed parent state.
Atomic Operations All changes within a transaction level are atomic. Commits and rollbacks affect only that level and its children, preserving integrity and consistency.
Txnactually DoesTxnReader and TxnxWriter interface enabling
nested transaction hierarchiesA Merkle Tree is a data structure that enables efficient and secure verification of large data sets. It works by hashing pairs of nodes and progressively combining them upwards to create a single “root” hash that cryptographically commits to all the data below it.
A Sparse Merkle Tree (SMT) extends this concept by efficiently handling sparse key-value mappings - meaning it’s optimized for situations where only a small subset of possible keys are actually used, such as in blockchain state storage like this module. This optimization is crucial because a traditional Merkle tree would require storing all possible keys, even empty ones, making it impractical for large key spaces.
Instead, a SMT allows us to both store and prove the existence (or non-existence) of key-value pairs while maintaining a minimal memory footprint and providing efficient verification capabilities.
graph TD
Root --> H0
Root --> H1
H0 --> H00
H0 --> H01
H1 --> H10
H1 --> H11
H00 --> L0[L0 - Value 1]
H00 --> L1[L1 - Empty]
H01 --> L2[L2 - Empty]
H01 --> L3[L3 - Empty]
H10 --> L4[L4 - Empty]
H10 --> L5[L5 - Empty]
H11 --> L6[L6 - Empty]
H11 --> L7[L7 - Empty]
classDef highlight fill:#0000ff,stroke:#333,stroke-width:2px;
class Root,H0,H00,L0 highlight;
classDef highlightSiblings fill:#ff0000,stroke:#333,stroke-width:2px;
class H01,H1,L1 highlightSiblings;
In a traditional Sparse Merkle Tree, as illustrated above, each level splits into two child nodes,
creating a binary tree structure. The blue nodes show the path to Value 1, while the red nodes
represent the sibling hashes needed for proof verification. For example, to prove the existence of
Value 1, we need:
Value 1 itselfThis approach has significant limitations:
These limitations become particularly problematic in blockchain environments where efficiency and scalability are crucial. Canopy’s SMT implementation introduces several optimizations to address these challenges while maintaining the security properties of traditional Sparse Merkle Trees.
Canopy’s SMT implementation introduces key optimizations to address traditional SMT limitations:
Modification Operations
a. Upsert (Insert/Update)
b. Delete
Steps:
Path Finding: Navigate down the tree following the binary path of ‘1101’ until reaching the closest existing node (‘111’)
Position Check: Determine target node (‘1101’) doesn’t exist at current position
Parent Creation: Form new parent node with key ‘11’ (greatest common prefix between ‘1101’ and ‘111’)
Restructure: Update old parent to reference new parent node, making previous node (‘111’) a child of new parent
Insert: Add new node (‘1101’) as the second child of the new parent, maintaining binary tree properties
ReHash: Progressively updates hash values from the modified node’parent to the root after each operation, ensuring cryptographic integrity of tree structure as shown in the diagram in green.
Steps:
Path Finding: Navigate down the tree following the binary path of ‘010’ until reaching the target node
Node Removal: Remove target node (‘010’) from tree structure
Parent Update: Replace parent node ‘0’ in grandparent with target’s sibling ‘000’
Tree Rehash: Recalculate hash values upward from ‘000’ parent to the root (on this case is the same as root) to maintain integrity as shown in the diagram in green.
Canopy’s SMT implementation supports both proof-of-membership and proof-of-non-membership through
the GetMerkleProof and VerifyProof methods. These proofs enable verification of whether a
specific key-value pair exists in the tree without requiring access to the complete tree data.
GetMerkleProof)The proof generation process constructs an ordered path from the target leaf node (or the closest existing node where the key would reside if present) back to the root, by including every sibling node encountered along each branch of this traversal.
Initial Node: The first element in the proof is always the target node (for membership proofs) or the node at the potential location (for non-membership proofs)
Sibling Collection: For each level from the leaf to the root:
VerifyProof)The verification process reconstructs the root hash using the provided proof and compares it with the known root, if the hashes match, the resulting tree is then used in order to verify the existence of the requested key-value pair. As a side note, unlike traditional sparse merkle trees, both the key and value are required in order to generate the parent’s hash:
The Indexer component serves as an organized filing system for blockchain data, using prefix-based storage patterns to enable efficient data retrieval and iteration. It manages four primary types of data: transactions, blocks, quorum certificates, and checkpoints.
The Indexer uses unique prefix bytes to segregate different types of data:
var (
txHashPrefix = []byte{1} // Transaction by hash
txHeightPrefix = []byte{2} // Transactions by height
txSenderPrefix = []byte{3} // Transactions from sender
// and more...
)
This prefix system creates distinct “namespaces” in the database, allowing for:
graph TD
TX[Transaction] --> TxHash[By Hash<br/>prefix: 1]
TX --> TxHeight[By Height<br/>prefix: 2]
TX --> TxSender[By Sender<br/>prefix: 3]
TX --> TxRecipient[By Recipient<br/>prefix: 4]
graph TD
Block[Block] --> BlockHash[By Hash<br/>prefix: 5]
Block --> BlockHeight[By Height<br/>prefix: 6]
BlockHeight --> Txs[Associated Transactions<br/>prefix: 2]
The Indexer leverages BadgerDB’s lexicographical ordering to implement iterations:
// Example: Get transactions newest to oldest
it, err := indexer.db.RevIterator(txHeightPrefix)
// Example: Get transactions oldest to newest
it, err := indexer.db.Iterator(txHeightPrefix)
While the Indexer supports iteration capabilities, this approach should be used cautiously due to its performance overhead. When dealing with large datasets, it is strongly recommended to retrieve multiple elements in a single bulk operation and unmarshal them collectively, rather than performing individual iterations and unmarshalling operations, as this pattern has proven to be significantly more performant in practice.
The Indexer uses the following key encoding strategy:
This structured approach to data indexing and storage enables efficient querying and iteration over blockchain data while maintaining data integrity and accessibility.
Store struct: Putting it all togetherThe Store struct serves as the central coordinator for Canopy’s storage system, integrating all previously described components into a cohesive whole. It provides a clean, high-level API that abstracts away the complexity of the underlying storage components.
The Store struct brings together three main components:
The Store ensures atomicity through BadgerDB’s transaction system:
Commit() method finalizes changes with a single atomic writeCanopy separates the data of the state into two types of stores: the Latest State Store (LSS) and the Historical State Store (HSS).
The Latest State Store maintains the most current version of all state data, using the prefix s/ for all keys. It:
The Historical State Store maintains historical state data in partition-based snapshots, using the prefix h/{partition_height}/ (e.g., h/10000/). It:
The partitioning approach works as follows:
partitionFrequency blocks, e.g., 10,000), a complete state snapshot is created in a new HSS partitionStore automatically determines whether to use LSS or HSS based on the query heightThe Store maintains two critical pieces of information:
These are tracked in the CommitIDStore, enabling:
This comprehensive approach to storage management provides Canopy with a robust, efficient, and secure foundation for blockchain state handling, enabling both high-performance current operations and flexible historical data access.