Account Model
The rollup uses an account-based state model backed by a Sparse Merkle Tree (SMT). Each account maps a 32-byte public key to a balance, and the entire state is summarized by a single 32-byte root hash.
Sparse Merkle Tree
The SMT has 8 levels supporting up to 256 accounts — sufficient for the PoC. The tree uses SHA-256 with domain-separated hashing at every level to prevent second-preimage attacks across domains.
graph TB
ROOT["Root (level 8)"]
B0["Branch"]
B1["Branch"]
B00["Branch"]
B01["Branch"]
B10["Branch"]
B11["Branch"]
L0["Leaf 0"]
L1["Leaf 1"]
L2["Leaf 2"]
L3["..."]
L255["Leaf 255"]
ROOT --- B0
ROOT --- B1
B0 --- B00
B0 --- B01
B1 --- B10
B1 --- B11
B00 --- L0
B00 --- L1
B01 --- L2
B01 --- L3
B11 --- L255
Hash functions
All SMT hashes use SHA-256 with distinct domain prefixes:
#![allow(unused)]
fn main() {
/// Compute the hash of an account leaf: sha256("SMTLeaf" || pubkey || balance_le_bytes)
pub fn leaf_hash(pubkey: &[u32; 8], balance: u64) -> [u32; 8] {
let mut hasher = sha2::Sha256::new_with_prefix(LEAF_DOMAIN);
hasher.update(bytemuck::bytes_of(pubkey));
hasher.update(balance.to_le_bytes());
let result: [u8; 32] = hasher.finalize().into();
crate::bytes_to_words(result)
}
}
#![allow(unused)]
fn main() {
/// Compute the hash of two sibling nodes: sha256("SMTBranch" || left || right)
pub fn branch_hash(left: &[u32; 8], right: &[u32; 8]) -> [u32; 8] {
let mut hasher = sha2::Sha256::new_with_prefix(BRANCH_DOMAIN);
hasher.update(bytemuck::bytes_of(left));
hasher.update(bytemuck::bytes_of(right));
let result: [u8; 32] = hasher.finalize().into();
crate::bytes_to_words(result)
}
}
#![allow(unused)]
fn main() {
/// Compute the hash of an empty leaf
pub fn empty_leaf_hash() -> [u32; 8] {
let hasher = sha2::Sha256::new_with_prefix(EMPTY_DOMAIN);
let result: [u8; 32] = hasher.finalize().into();
crate::bytes_to_words(result)
}
}
Key mapping
Account position in the tree is determined by the first byte of the public key:
#![allow(unused)]
fn main() {
/// Get the key index (0-255) from a pubkey (uses first byte)
pub fn key_to_index(pubkey: &[u32; 8]) -> u8 {
bytemuck::bytes_of(pubkey)[0]
}
}
This means two accounts whose pubkeys share the same first byte would collide. For a 256-slot PoC this is acceptable; a production system would use a deeper tree with a proper hash-based index.
SMT proof
A proof consists of 8 sibling hashes — one per tree level. Verification walks from leaf to root, choosing left/right based on the key bits:
#![allow(unused)]
fn main() {
/// SMT proof structure for 8-level tree
/// Contains sibling hashes at each level from leaf to root
#[derive(Clone, Copy, Debug, Eq, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
#[repr(C)]
pub struct SmtProof {
/// Sibling hashes from leaf (level 0) to root (level 7)
pub siblings: [[u32; 8]; SMT_DEPTH],
}
}
Account and AccountWitness
An Account is a (pubkey, balance) pair (40 bytes). An AccountWitness adds the SMT proof so the guest can verify membership and compute updated roots:
#![allow(unused)]
fn main() {
/// Account structure (40 bytes)
#[derive(Clone, Copy, Debug, Eq, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
#[repr(C)]
pub struct Account {
/// Account pubkey (32 bytes as [u32; 8])
pub pubkey: [u32; 8],
/// Account balance (8 bytes)
pub balance: u64,
}
}
#![allow(unused)]
fn main() {
/// Witness for a single account in the SMT
#[derive(Clone, Copy, Debug, Eq, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
#[repr(C)]
pub struct AccountWitness {
/// Account pubkey (32 bytes as [u32; 8])
pub pubkey: [u32; 8],
/// Account balance (8 bytes)
pub balance: u64,
/// SMT proof for this account
pub proof: SmtProof,
}
}
Empty tree root
The empty tree root is computed deterministically by hashing empty leaves upward through all 8 levels:
#![allow(unused)]
fn main() {
/// Compute empty tree root
pub fn empty_tree_root() -> StateRoot {
let empty_leaf = smt::empty_leaf_hash();
let mut current = empty_leaf;
for _ in 0..SMT_DEPTH {
current = smt::branch_hash(¤t, ¤t);
}
current
}
}
State root type
The state root is simply [u32; 8] — a 32-byte SHA-256 hash stored as 8 words for zkVM alignment efficiency. See core/src/state.rs:93 for the type alias.
Design rationale
Why SHA-256? The RISC Zero zkVM has accelerated SHA-256 support, making it the cheapest hash function inside the guest. All account-state hashing uses SHA-256 for this reason.
Why [u32; 8] instead of [u8; 32]? The zkVM operates on 32-bit words. Using [u32; 8] avoids alignment issues and unnecessary byte shuffling. The bytemuck crate provides zero-copy conversion when byte-level access is needed.
Why 8 levels? The PoC targets a small number of accounts for demonstration. The depth is a constant (SMT_DEPTH) and could be increased for production use.