Deep Dive: JMT & Shard State Storage

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.

⏱️ ~50–70 min read 📊 Deep dive 🎯 Hyperscale-rs storage

Reading segments (~15–20 min each)

SegmentTopicYou should be able to…
1Scope & two layersSay “one JMT per shard, versioned over time” and name RocksDB vs JMT roles
2Keys & hashingBuild a storage_key, explain why JMT hashes it again
3Genesis insertTrace install_genesis → substates written → first JMT root
4Transfer timelineSeparate “tx in block N” from “state_root bump on block N+1”
5Insert vs update tablesFor each step, say what changed in Node DB vs JMT
6prepare_block_commitExplain speculative root + PreparedCommit at propose time

Prerequisite: skim state_root example in the block-fields module.

Segment 1 — What JMT is (and what it is not)

QuestionAnswer 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.

Segment 2 — Two layers on each validator (Node DB + JMT)

Every substate slot has the same logical address in both layers, but different on-disk encoding:

LayerStoresKey formatValue format
Node DB (RocksDB state CF)Actual substate bytes you read during executionentity_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 mapjmt_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.

Segment 3 — Keys in real life (entity, partition, sort_key)

A token balance (or any substate row) is not “Alice” as a string. It is a triple:

PartMeaningExample
entity_key (db_node_key)Which account / component (NodeId via SpreadPrefixKeyMapper)50-byte mapped key for Alice’s account component
partition_numWhich “table” inside that entity (vault, metadata, …)0x58 = internal fungible vault partition in engine sharding
sort_keyWhich 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 = delete

Toy 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.

Segment 4 — Genesis: first INSERT (empty → Alice & Bob funded)

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
    }
}

Teaching cast (shard 0)

AccountLogical slotGenesis value
Aliceentity_alice ‖ partition_vault ‖ sort_GOLD100 GOLD
Bobentity_bob ‖ partition_vault ‖ sort_GOLD50 GOLD

After genesis — Node DB vs JMT

StoreAfter genesis (INSERT × 2)
Node DBTwo new rows: Alice→100, Bob→50 (raw bytes in state CF)
JMT v0Two new leaf paths; sparse tree; state_root = R₀
Block 0 headerstate_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)

Segment 5 — Real transfer timeline (simulation path)

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.

WhenChain eventNode DB (shard 0)JMT / header
GenesisFund accountsINSERT Alice=100, Bob=50Build v0 → R₀ in block 0
Block 1 proposedBody includes transfer tx; certificates = []Unchangedstate_root(1) = R₀ (no certs to apply)
Block 1 committedQC → commit; Phase 3 runs txUPDATE Alice=70, Bob=80 (live storage)Execution version moves; header still R₀
Block 2 proposedStaples FinalizedWave for block 1’s waveAlready 70 / 80prepare_block_commit(R₀, certs)R₁ in header
Block 2 committedQC on header claiming R₁Same values; history CF records prior valuesJMT 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)

Segment 6 — UPDATE walk (same keys, new values)

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");

Map test → Alice/Bob transfer

StepOperationNode DB (same storage_key?)JMT
GenesisINSERTNew rows for Alice & Bob slotsNew leaves; root R₀
Block 2 commit (after block 1 exec)UPDATESame keys; overwrite 100→70, 50→80Same paths; new leaf hashes; root R₀→R₁

Rehash cost (toy vs production)

Changed keysRehashes per keyTotal (order of magnitude)
Toy 4-bit paths2 (Alice, Bob)~4 (= depth)≈ 2 × 4 = 8
Production2~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.

Segment 7 — prepare_block_commit at propose time

When 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 scratch

Inside 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.

Code map (study order)

FileRead for
storage/src/tree/mod.rsKey hashing, value hashing, put_at_version
storage/src/shard/keys.rsto_storage_key byte layout
storage/src/test_helpers.rsmake_mapped_database_update — how tests build writes
storage-memory/…/tests.rsInsert + update + historical read test
storage-rocksdb/…/core.rsGenesis install_genesis, finalize_genesis_jmt
storage/src/shard/chain_writer.rsPrepare → commit contract
simulation/tests/scenarios.rsEnd-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.

Quick check

Related modules