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

Sequence Commitment

The sequence commitment chains blocks together, ensuring no transactions are skipped, reordered, or replayed between proof batches.

Block chaining

Each proof batch processes a sequence of blocks. The guest maintains a running seq_commitment value that starts at prev_seq_commitment (from PublicInput) and is updated after each block:

seq_commitment' = merkle_hash(seq_commitment, block_root)
flowchart LR
    PREV["prev_seq_commitment"]
    B1["Block 1 root"]
    B2["Block 2 root"]
    B3["Block 3 root"]
    S1["seq₁"]
    S2["seq₂"]
    FINAL["new_seq_commitment"]

    PREV -->|"hash(prev, B1)"| S1
    B1 --> S1
    S1 -->|"hash(seq₁, B2)"| S2
    B2 --> S2
    S2 -->|"hash(seq₂, B3)"| FINAL
    B3 --> FINAL

This is computed using:

#![allow(unused)]
fn main() {
pub fn calc_accepted_id_merkle_root(
    selected_parent_accepted_id_merkle_root: &[u32; 8],
    accepted_id_merkle_root: &[u32; 8],
) -> [u32; 8] {
    merkle_hash(selected_parent_accepted_id_merkle_root, accepted_id_merkle_root, blake3::Hasher::new_keyed(&KEY))
}
}

The on-chain script verifies the new seq_commitment by using OpChainblockSeqCommit, which returns the same value computed by the consensus layer. This anchors the proof to the actual Kaspa block DAG.

Transaction leaf hashing

Within each block, every transaction contributes a leaf to the block’s Merkle tree:

#![allow(unused)]
fn main() {
pub fn seq_commitment_leaf(tx_id: &[u32; 8], tx_version: u16) -> [u32; 8] {
    const DOMAIN_SEP: &[u8] = b"SeqCommitmentMerkleLeafHash";
    const KEY: [u8; blake3::KEY_LEN] = domain_to_key(DOMAIN_SEP);

    let mut hasher = blake3::Hasher::new_keyed(&KEY);
    hasher.update(bytemuck::bytes_of(tx_id));
    hasher.update(&tx_version.to_le_bytes());
    let mut out = [0u32; 8];
    bytemuck::bytes_of_mut(&mut out).copy_from_slice(hasher.finalize().as_bytes());
    out
}
}

The leaf hash includes both the tx_id and the transaction version. This matches Kaspa’s consensus-level sequence commitment computation.

Streaming Merkle builder

The block Merkle tree is built incrementally using a streaming algorithm that requires no heap allocation:

#![allow(unused)]
fn main() {
    /// Add a pre-hashed leaf.
    pub fn add_leaf(&mut self, hash: [u32; 8]) {
        let mut level = 0u32;
        let mut current = hash;

        while self.stack_len > 0 {
            let (top_level, top_hash) = self.stack[self.stack_len - 1];
            if top_level != level {
                break;
            }
            self.stack_len -= 1;
            current = H::branch(&top_hash, &current);
            level += 1;
        }

        self.stack[self.stack_len] = (level, current);
        self.stack_len += 1;
        self.leaf_count += 1;
    }
}
#![allow(unused)]
fn main() {
    /// Finalize and return the Merkle root.
    ///
    /// Pads incomplete subtrees with the domain-specific empty subtree
    /// hashes.
    pub fn finalize(self) -> [u32; 8] {
        if self.leaf_count == 0 {
            return H::empty_subtree(0);
        }

        if self.leaf_count == 1 {
            return H::branch(&self.stack[0].1, &H::empty_subtree(0));
        }

        // Stack represents a binary decomposition of the leaf count.
        // Process from right (top of stack) to left, padding as needed.
        let mut result_hash = [0u32; 8];
        let mut result_level = 0u32;
        let mut first = true;

        for i in (0..self.stack_len).rev() {
            let (level, hash) = self.stack[i];

            if first {
                result_hash = hash;
                result_level = level;
                first = false;
                continue;
            }

            // Pad result from its current level up to this node's level
            while result_level < level {
                result_hash = H::branch(&result_hash, &H::empty_subtree(result_level as usize));
                result_level += 1;
            }

            // Merge: this node (left) with padded result (right)
            result_hash = H::branch(&hash, &result_hash);
            result_level += 1;
        }

        result_hash
    }
}
graph TB
    subgraph Block["Block Merkle Tree"]
        ROOT["Block root"]
        B01["hash(L0, L1)"]
        B23["hash(L2, pad)"]
        L0["leaf(tx₀)"]
        L1["leaf(tx₁)"]
        L2["leaf(tx₂)"]
        PAD["zero_hash"]

        ROOT --- B01
        ROOT --- B23
        B01 --- L0
        B01 --- L1
        B23 --- L2
        B23 --- PAD
    end

The streaming builder uses a fixed-size stack of (level, hash) pairs. When two entries at the same level are adjacent, they are merged into a parent. Incomplete subtrees are padded with zero hashes during finalization.

Hash operations

The sequence commitment tree uses Blake3 with keyed hashing:

#![allow(unused)]
fn main() {
/// Seq-commitment Merkle hash operations — blake3 with zero-padding.
pub struct SeqCommitHashOps;

impl MerkleHashOps for SeqCommitHashOps {
    fn branch(left: &[u32; 8], right: &[u32; 8]) -> [u32; 8] {
        merkle_hash(left, right, blake3::Hasher::new_keyed(&KEY))
    }

    fn empty_subtree(_level: usize) -> [u32; 8] {
        ZERO_HASH
    }
}
}
OperationDomain separatorHash function
Branch hashSeqCommitmentMerkleBranchHashBlake3 (keyed)
Leaf hashSeqCommitmentMerkleLeafHashBlake3 (keyed)
Empty subtreeZero hash [0u32; 8]

The zero-padding for empty subtrees matches Kaspa’s calc_merkle_root behavior with the PREPEND_ZERO_HASH flag.

Kaspa compatibility

The implementation is tested against Kaspa’s native kaspa-merkle crate to ensure identical output for all tree sizes. See core/src/seq_commit.rs tests for compatibility verification against kaspa_merkle::calc_merkle_root_with_hasher.

Why this matters

Without sequence commitment verification, a malicious host could:

  • Skip blocks — omit blocks containing unfavorable transactions
  • Reorder blocks — change the order of state transitions
  • Replay blocks — process the same transactions twice

The sequence commitment anchors the proof to the actual Kaspa DAG. The on-chain OpChainblockSeqCommit opcode recomputes the same value from consensus data, ensuring the guest processed exactly the right blocks.