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, ¤t);
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
}
}
}
| Operation | Domain separator | Hash function |
|---|---|---|
| Branch hash | SeqCommitmentMerkleBranchHash | Blake3 (keyed) |
| Leaf hash | SeqCommitmentMerkleLeafHash | Blake3 (keyed) |
| Empty subtree | — | Zero 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.