After block fields, timers, and the E2E harness labs: how hyperscale-rs stores ledger state on one shard — RocksDB substates, the JMT, keys, inserts vs updates, and a step-by-step transfer walkthrough with in-repo tests.
| Segment | Topic | You should be able to… |
|---|---|---|
| 1 | Scope & two layers | Say “one JMT per shard, versioned over time” and name RocksDB vs JMT roles |
| 2 | Keys & hashing | Build a storage_key, explain why JMT hashes it again |
| 3 | Genesis insert | Trace install_genesis → substates written → first JMT root |
| 4 | Transfer timeline | Separate “tx in block N” from “state_root bump on block N+1” |
| 5 | Insert vs update tables | For each step, say what changed in Node DB vs JMT |
| 6 | prepare_block_commit | Explain speculative root + PreparedCommit at propose time |
Prerequisite: skim state_root example in the block-fields module.
| Question | Answer in hyperscale-rs |
|---|---|
| One JMT for the whole network? | No. Each shard group has its own state tree and state_root in its block headers. |
| One new JMT per block? | No. One persistent tree per shard; each commit advances a version (typically block height). Old versions can remain for proofs / history. |
Where does state_root live? | In every BlockHeader for that shard — a commitment peers recompute from body.certificates. |
| JMT vs JVT in code? | JMT = tree algorithm (Blake3, fixed depth). JVT = versioned state layer name in Rust symbols — see glossary. |
Network Shard 0 validators ──► RocksDB + JMT (version 0…H) → state_root in shard-0 headers Shard 1 validators ──► separate RocksDB + JMT → state_root in shard-1 headers …
Cross-shard txs touch multiple shard trees independently; atomicity is provision-based (Phase 4), not one global JMT.
Every substate slot has the same logical address in both layers, but different on-disk encoding:
| Layer | Stores | Key format | Value format |
|---|---|---|---|
Node DB (RocksDB state CF) | Actual substate bytes you read during execution | entity_key ‖ partition ‖ sort_key (variable length) | Raw SBOR / engine bytes (e.g. balance encoding) |
| JMT (RocksDB JMT CF + in-memory snapshots) | Merkle fingerprint of the whole map | jmt_leaf_key(storage_key, owner) — owner-major 32-byte path (state_key.rs) | Leaf = BLAKE3(raw_value); internal nodes = Blake3 pairings |
In-repo contract — owner-prefixed leaf keys (state_key.rs)
/// Owner-major JMT leaf key: high 16 bytes = blake3(routing_node),
/// low 16 bytes = blake3(full storage_key). routing_node is the global
/// owner for internal nodes (vaults, KV stores) or the node itself for globals.
pub fn jmt_leaf_key(storage_key: &[u8], owner: Option<NodeId>) -> [u8; 32] { … }
// Provision proof verification uses the same rule via ProvisionEntry::owned_nodes.Takeaway: execution reads/writes Node DB; consensus headers carry the JMT root so everyone agrees on the map’s fingerprint without shipping every account on every block.
A token balance (or any substate row) is not “Alice” as a string. It is a triple:
| Part | Meaning | Example |
|---|---|---|
entity_key (db_node_key) | Which account / component (NodeId via SpreadPrefixKeyMapper) | 50-byte mapped key for Alice’s account component |
partition_num | Which “table” inside that entity (vault, metadata, …) | 0x58 = internal fungible vault partition in engine sharding |
sort_key | Which row in that table (resource id, field id, …) | Bytes identifying the GOLD resource |
Byte layout (shard/keys.rs)
// Key layout: [node_key][partition_num (1B)][sort_key]
pub fn to_storage_key(partition_key: &DbPartitionKey, sort_key: &DbSortKey) -> Vec<u8> {
let mut key = Vec::with_capacity(partition_key.node_key.len() + 1 + sort_key.0.len());
key.extend_from_slice(&partition_key.node_key);
key.push(partition_key.partition_num);
key.extend_from_slice(&sort_key.0);
key
}Execution emits DatabaseUpdates: nested map of entity_key → partition → sort_key → Set|Delete. Storage flattens that into JMT work items:
// tree/mod.rs — put_at_version (simplified)
let storage_key = make_storage_key(entity_key, partition_num, &sort_key.0);
let jmt_key = hash_storage_key(storage_key, owner_map); // → jmt_leaf_key(...)
let jmt_value = value.map(hash_value); // BLAKE3(raw bytes) or None = deleteToy diagram note: paths like 1010 / 1101 in the block-fields walkthrough are 4-bit teaching cartoons. Production uses the full 256-bit path implied by the 32-byte jmt_key.
Simulation and production fund accounts at genesis via initialize_genesis_with_balances. That produces DatabaseUpdates with DatabaseUpdate::Set on keys that **did not exist** — pure **inserts**.
Genesis install (storage-rocksdb/shard/core.rs)
impl GenesisCommit for RocksDbShardStorage {
fn install_genesis(&self, merged: &DatabaseUpdates) -> StateRoot {
Self::commit_substates_only(self, merged); // 1) write raw bytes @ version 0
Self::finalize_genesis_jmt(self, merged) // 2) build first JMT @ version 0
}
}| Account | Logical slot | Genesis value |
|---|---|---|
| Alice | entity_alice ‖ partition_vault ‖ sort_GOLD | 100 GOLD |
| Bob | entity_bob ‖ partition_vault ‖ sort_GOLD | 50 GOLD |
| Store | After genesis (INSERT × 2) |
|---|---|
| Node DB | Two new rows: Alice→100, Bob→50 (raw bytes in state CF) |
| JMT v0 | Two new leaf paths; sparse tree; state_root = R₀ |
| Block 0 header | state_root: R₀ |
JMT v0 (R₀) — INSERT leaves only
R₀
/ \
path→1010 path→1101
Alice=100 Bob=50
Node DB (state CF):
entity_alice|vault|GOLD → bytes(100)
entity_bob|vault|GOLD → bytes(50)
This is the same rhythm as single_shard_tx_sim and the storage test test_list_substates_for_node_at_height_returns_historical_data — simplified to Alice paying Bob 30 GOLD.
| When | Chain event | Node DB (shard 0) | JMT / header |
|---|---|---|---|
| Genesis | Fund accounts | INSERT Alice=100, Bob=50 | Build v0 → R₀ in block 0 |
| Block 1 proposed | Body includes transfer tx; certificates = [] | Unchanged | state_root(1) = R₀ (no certs to apply) |
| Block 1 committed | QC → commit; Phase 3 runs tx | UPDATE Alice=70, Bob=80 (live storage) | Execution version moves; header still R₀ |
| Block 2 proposed | Staples FinalizedWave for block 1’s wave | Already 70 / 80 | prepare_block_commit(R₀, certs) → R₁ in header |
| Block 2 committed | QC on header claiming R₁ | Same values; history CF records prior values | JMT v2 persisted; state_root(2) = R₁ |
Critical timing: the transfer tx sits in block 1’s transaction_root, but the state_root bump appears on block 2 when that execution’s FinalizedWave is stapled. See block 40 → 41 example.
Receipts carry the writes execution observed:
// types/receipt/consensus.rs — successful receipt includes:
database_updates: DatabaseUpdates // Set(entity_alice, …, 70), Set(entity_bob, …, 80)The storage integration test test_list_substates_for_node_at_height_returns_historical_data is the cleanest in-repo proof of insert-then-update on the **same** storage_key:
In-repo test (storage-memory/shard/tests.rs)
// Block height 1: INSERT value [100]
let updates1 = make_mapped_database_update(1, 0, vec![10], vec![100]);
let root_v1 = commit_with(&storage, &updates1, &block1, &qc1);
// Block height 2: UPDATE same sort_key → [200]
let updates2 = make_mapped_database_update(1, 0, vec![10], vec![200]);
let root_v2 = commit_with(&storage, &updates2, &block2, &qc2);
assert_ne!(root_v1, root_v2, "roots must differ after overwrite");| Step | Operation | Node DB (same storage_key?) | JMT |
|---|---|---|---|
| Genesis | INSERT | New rows for Alice & Bob slots | New leaves; root R₀ |
| Block 2 commit (after block 1 exec) | UPDATE | Same keys; overwrite 100→70, 50→80 | Same paths; new leaf hashes; root R₀→R₁ |
| Changed keys | Rehashes per key | Total (order of magnitude) | |
|---|---|---|---|
| Toy 4-bit paths | 2 (Alice, Bob) | ~4 (= depth) | ≈ 2 × 4 = 8 |
| Production | 2 | ~256 | ≈ 512 (+ snapshot I/O) |
UPDATE — same jmt_key paths, new leaf hashes
R₁ (was R₀)
/ \
path→1010 path→1101
Alice=70 Bob=80 ← same paths as genesis, new values
Historical reads: at JMT version 1 you still see Alice=100; at version 2 you see 70 — the test asserts exactly that pattern with list_substates_for_node_at_height.
prepare_block_commit at propose timeWhen the proposer builds block 2, it does not guess the root. It replays certificates against the parent state:
// storage/src/shard/chain_writer.rs (trait docs)
// 1. prepare_block_commit → (state_root, jmt_snapshot, prepared)
// 2. Runner stores prepared closure keyed by block hash
// 3. At QC commit → invoke prepared(SyncHint, certified_block, …)
// 4. If no prepared closure (sync) → commit_block recomputes from scratchInside prepare: merge all database_updates from finalized wave receipts → flatten to (storage_key, optional_value) → put_at_version → new root + collected JMT nodes for the batch write.
parent_state_root R₀ + body.certificates (FinalizedWave₁)
│
├─ merge DatabaseUpdates from receipts
├─ for each key: Node DB read (or base_reads cache) + JMT path update
└─► state_root R₁ + JmtSnapshot + PreparedCommit closure
Peers verify R₁ before BlockVote; after QC, closure persists v2 to RocksDB.
| File | Read for |
|---|---|
| storage/src/tree/mod.rs | Key hashing, value hashing, put_at_version |
| storage/src/shard/keys.rs | to_storage_key byte layout |
| storage/src/test_helpers.rs | make_mapped_database_update — how tests build writes |
| storage-memory/…/tests.rs | Insert + update + historical read test |
| storage-rocksdb/…/core.rs | Genesis install_genesis, finalize_genesis_jmt |
| storage/src/shard/chain_writer.rs | Prepare → commit contract |
| simulation/tests/scenarios.rs | End-to-end single-shard tx (genesis → transfer → finalize) |
Lab tie-in: after the simulation E2E lab, set a breakpoint in storage commit or grep logs for state_root across two consecutive heights during single_shard_tx_sim.
state_root vs transaction_rootdatabase_updates are producedBuildProposal reads storage for roots