Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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(&current, &current);
    }
    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.