Introduction
This book documents the ZK Covenant Rollup, a proof-of-concept Layer 2 rollup bridge built on Kaspa using covenants and zero-knowledge proofs.
What is this?
The ZK Covenant Rollup demonstrates how off-chain state transitions can be proven with RISC Zero and verified on-chain through Kaspa’s covenant mechanism. Users deposit funds into a covenant-controlled UTXO, perform off-chain transfers on an account-based L2, and withdraw back to L1 — all enforced cryptographically without trusting the rollup operator.
The system has three core properties:
- Validity — Every state transition is backed by a ZK proof. The on-chain script rejects any update that fails verification.
- Liveness — Withdrawals are processed through a permission tree. Once the guest proof commits an exit, the on-chain permission script allows anyone to claim it.
- Soundness — The guest proof pipeline verifies every action’s authorization, balance sufficiency, and SMT consistency. A malicious host cannot forge state updates.
High-level flow
flowchart LR
subgraph L1["Kaspa L1"]
UTXO["Covenant UTXO<br/>(state root + seq commitment)"]
PERM["Permission UTXO<br/>(withdrawal claims)"]
end
subgraph OffChain["Off-Chain"]
HOST["Host / Operator"]
GUEST["RISC Zero Guest<br/>(ZK proof)"]
end
HOST -->|"blocks + witnesses"| GUEST
GUEST -->|"proof + journal"| HOST
HOST -->|"state tx<br/>(proof, new state)"| UTXO
UTXO -->|"spawns if exits"| PERM
PERM -->|"withdrawal claim"| USER["User L1 wallet"]
Deposit (Entry): A user sends funds to the delegate script address. The next proof batch picks up the deposit transaction, verifies the output pays the correct covenant-bound P2SH, and credits the L2 account.
Transfer: An L2 user signs a transfer payload. The guest verifies the signature via previous-transaction output introspection and updates the SMT.
Withdrawal (Exit): An L2 user creates an exit action. The guest debits their account and adds a leaf to the permission tree. The on-chain permission script lets anyone claim the withdrawal by presenting a Merkle proof.
Scope
This book covers the PoC logic: the core library, guest proof program, and on-chain script construction. See Chapter 12: Running the Demo for how to build and run it.
Reading guide
| You want to… | Start at |
|---|---|
| Understand the architecture | Chapter 2: Architecture |
| Learn the data model | Chapter 3: Account Model |
| See how proofs work | Chapter 5: Guest Proof Pipeline |
| Understand on-chain scripts | Chapter 7: State Verification |
| Audit security properties | Chapter 11: Security Model |
| Run the demo yourself | Chapter 12: Running the Demo |
| Look up a domain separator | Appendix A |
Architecture
The ZK Covenant Rollup is organized into three layers, each running in a different environment. All cryptographic invariants are enforced at layer boundaries.
Three-layer design
flowchart TB
subgraph Layer1["Layer 1: On-Chain Scripts"]
direction LR
REDEEM["State Verification<br/>redeem script"]
PERM_SCRIPT["Permission<br/>redeem script"]
DELEGATE["Delegate/Entry<br/>script"]
end
subgraph Layer2["Layer 2: ZK Guest (RISC-V)"]
direction LR
GUEST["Guest main()"]
BLOCK["Block processor"]
STATE["State updater"]
JOURNAL["Journal writer"]
end
subgraph Layer3["Layer 3: Host / Core"]
direction LR
CORE["Core library<br/>(no_std)"]
HOST_BIN["Host binary<br/>(script builders)"]
end
Layer3 -->|"witnesses + blocks"| Layer2
Layer2 -->|"proof + journal"| Layer1
CORE -.->|"shared types"| Layer2
CORE -.->|"shared types"| Layer1
Crate map
The project consists of four crates:
| Crate | Path | Target | Role |
|---|---|---|---|
zk-covenant-rollup-core | core/ | no_std (RISC-V + native) | Shared types, hash functions, script construction |
zk-covenant-rollup-guest | methods/guest/ | RISC-V (riscv32im-risc0-zkvm-elf) | ZK proof program |
zk-covenant-rollup-methods | methods/ | native | Build harness for guest ELF |
zk-covenant-rollup-host | host/ | native | Demo runner, script builders, tests |
graph TD
GUEST["guest<br/><i>methods/guest/</i>"]
CORE["core<br/><i>core/</i>"]
METHODS["methods<br/><i>methods/</i>"]
HOST["host<br/><i>host/</i>"]
GUEST --> CORE
GUEST --> RISC0_ZKVM["risc0-zkvm"]
HOST --> CORE
HOST --> METHODS
HOST --> KASPA["kaspa-txscript"]
METHODS --> GUEST
CORE --> SHA2["sha2"]
CORE --> BLAKE3["blake3"]
CORE --> BLAKE2B["blake2b-simd"]
CORE --> BYTEMUCK["bytemuck"]
Core (no_std)
The core crate runs in both the RISC-V guest and on native. It is no_std with alloc support. Key responsibilities:
- Data types —
PublicInput,Account,AccountWitness,ActionHeader, action payloads - SMT — 8-level Sparse Merkle Tree with SHA-256 domain-separated hashing
- Sequence commitment — Blake3-based streaming Merkle tree for block chaining
- Permission tree — SHA-256 Merkle tree of withdrawal claims
- Permission script — Byte-level redeem script construction (
no_stdcompatible) - P2SH / P2PK — Script public key helpers
- Transaction ID — V0 (blake2b) and V1 (blake3 payload + rest) computation
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, Debug, Eq, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
#[repr(C, align(4))]
pub struct PublicInput {
pub prev_state_hash: [u32; 8],
pub prev_seq_commitment: [u32; 8],
pub covenant_id: [u32; 8],
}
}
Guest (RISC-V)
The guest runs inside the RISC Zero zkVM. It reads PublicInput and witness data from stdin, processes all blocks, and writes a journal that the on-chain script verifies.
pub fn main() {
let mut stdin = env::stdin();
// Read and verify public input
let public_input = input::read_public_input(&mut stdin);
let mut state_root = public_input.prev_state_hash;
// Process all blocks
let chain_len = input::read_u32(&mut stdin);
let mut seq_commitment = public_input.prev_seq_commitment;
let mut perm_builder = StreamingPermTreeBuilder::new();
for _ in 0..chain_len {
let block_root = block::process_block(&mut stdin, &mut state_root, &public_input.covenant_id, &mut perm_builder);
seq_commitment = calc_accepted_id_merkle_root(&seq_commitment, &block_root);
}
// Build permission output if exits occurred
let perm_count = perm_builder.leaf_count();
let permission_spk_hash = if perm_count > 0 {
// Read expected redeem script length from host (private input)
let perm_redeem_script_len = input::read_u32(&mut stdin) as i64;
let depth = required_depth(perm_count as usize);
let perm_root = pad_to_depth(perm_builder.finalize(), perm_count, depth);
// Build once with host-provided length, then assert
let perm_redeem =
build_permission_redeem_bytes(&perm_root, perm_count as u64, depth, perm_redeem_script_len, MAX_DELEGATE_INPUTS);
assert_eq!(perm_redeem.len() as i64, perm_redeem_script_len, "permission redeem script length mismatch");
// blake2b hash → script_hash
let script_hash = blake2b_script_hash(&perm_redeem);
Some(bytes_to_words(script_hash))
} else {
None
};
// Write journal output
journal::write_output(&public_input, &state_root, &seq_commitment, permission_spk_hash.as_ref());
}
The guest is deterministic: given the same inputs, it always produces the same journal. The host cannot influence the output except by providing different (but valid) witness data.
Host
The host crate builds transactions and runs the demo. It uses kaspa-txscript’s ScriptBuilder for the state verification and permission redeem scripts. The host is not trusted — everything it produces is verified either by the guest (inside the ZK proof) or by the on-chain script.
What runs where
| Component | Environment | Trusted? | Verified by |
|---|---|---|---|
| Core types & hashes | Everywhere | N/A (library) | — |
| Guest proof program | RISC Zero zkVM | Yes (proven) | ZK verifier on-chain |
| State verification script | Kaspa node | Yes (consensus) | All full nodes |
| Permission script | Kaspa node | Yes (consensus) | All full nodes |
| Delegate script | Kaspa node | Yes (consensus) | All full nodes |
| Host / operator | Off-chain | No | Guest + on-chain scripts |
The host can:
- Choose the range of L1 blocks to process (committed to seq commitment, verified against L1)
- Filter which transactions within those blocks are L2 actions
- Provide witness data (SMT proofs, prev tx preimages)
Action order is inherited from L1 transaction order — the host cannot reorder or skip actions.
The host cannot:
- Forge a valid ZK proof for an invalid state transition
- Steal funds (covenant enforcement)
- Credit accounts without real deposits (SPK verification)
- Process withdrawals without proper authorization (prev tx proof)
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.
Action Types
Actions are the state-transition primitives of the rollup. Each action is encoded in a Kaspa transaction payload and identified by its operation code.
Action identification
A transaction is recognized as an action when its tx_id starts with the two-byte prefix 0x41 0x43 ("AC"):
#![allow(unused)]
fn main() {
/// Two-byte prefix for action transaction IDs (0x41 0x43 = "AC").
/// Using two bytes reduces accidental collisions from ~1/256 to ~1/65536.
pub const ACTION_TX_ID_PREFIX: &[u8; 2] = b"AC";
/// Check if a tx_id represents an action transaction (first two bytes match prefix)
#[inline]
pub fn is_action_tx_id(tx_id: &[u32; 8]) -> bool {
tx_id[0].to_le_bytes()[..2] == *ACTION_TX_ID_PREFIX
}
}
The host mines a nonce in the ActionHeader until the resulting tx_id starts with this prefix. Using two bytes means roughly 1 in 65,536 nonces will match — still fast enough for testing, while reducing accidental collisions from random testnet transactions (compared to 1 in 256 with a single-byte prefix).
Action header
Every action payload starts with an 8-byte header:
#![allow(unused)]
fn main() {
/// Action header (common to all actions)
///
/// Layout:
/// - version: u16 (2 bytes)
/// - operation: u16 (2 bytes) - determines action type and data size
/// - nonce: u32 (4 bytes) - for tx_id matching
///
/// Total: 8 bytes = 2 words
///
/// After reading the header, read action-specific data based on operation.
#[derive(Clone, Copy, Debug, Eq, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
#[repr(C)]
pub struct ActionHeader {
/// Format version
pub version: u16,
/// Operation type - determines what data follows
pub operation: u16,
/// Nonce for tx_id matching
pub nonce: u32,
}
}
The operation field determines what data follows and how much to read.
Three operation codes
| Opcode | Name | Value | Payload size | Description |
|---|---|---|---|---|
OP_TRANSFER | Transfer | 0 | 80 bytes (header + 72) | L2-to-L2 balance transfer |
OP_ENTRY | Entry | 1 | 40 bytes (header + 32) | L1-to-L2 deposit |
OP_EXIT | Exit | 2 | 88 bytes (header + 80) | L2-to-L1 withdrawal |
Transfer (opcode 0)
Moves funds between two L2 accounts.
#![allow(unused)]
fn main() {
/// Transfer action data (follows ActionHeader when operation == OP_TRANSFER)
///
/// Layout:
/// - source: [u32; 8] (32 bytes) - sender pubkey
/// - destination: [u32; 8] (32 bytes) - recipient pubkey
/// - amount: u64 (8 bytes)
///
/// Total: 72 bytes = 18 words
#[derive(Clone, Copy, Debug, Eq, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
#[repr(C)]
pub struct TransferAction {
/// Sender pubkey (committed to tx_id via payload_digest)
pub source: [u32; 8],
/// Recipient pubkey
pub destination: [u32; 8],
/// Amount to transfer
pub amount: u64,
}
}
flowchart LR
subgraph Payload
HDR["Header<br/>op=0, nonce"]
SRC["source<br/>32B pubkey"]
DST["destination<br/>32B pubkey"]
AMT["amount<br/>8B u64"]
end
HDR --> SRC --> DST --> AMT
subgraph Guest["Guest verification"]
AUTH["Verify source<br/>owns prev tx output"]
DEBIT["Debit source<br/>balance -= amount"]
CREDIT["Credit destination<br/>balance += amount"]
end
SRC -.-> AUTH
AMT -.-> DEBIT
AMT -.-> CREDIT
Authorization: The guest verifies the source pubkey by checking the previous transaction output spent by the current action tx. The guest extracts the first input’s outpoint (prev_tx_id, output_index) from the current transaction’s rest_preimage (committed via rest_digest → tx_id). The host provides a PrevTxV1Witness containing the previous tx’s rest_preimage and payload_digest. The guest asserts that the witness hashes to the committed prev_tx_id, then extracts the output SPK and confirms it is a Schnorr P2PK matching source.
State update: The guest verifies the source’s SMT proof against the current root, debits the balance, computes an intermediate root, then verifies the destination’s proof against that intermediate root and credits the balance.
Entry / Deposit (opcode 1)
Credits an L2 account from an L1 deposit.
#![allow(unused)]
fn main() {
/// Entry (deposit) action data (follows ActionHeader when operation == OP_ENTRY)
///
/// Layout:
/// - destination: [u32; 8] (32 bytes) - recipient pubkey on L2
///
/// Total: 32 bytes = 8 words
///
/// The deposit amount is NOT in the payload — it comes from the tx output value,
/// verified by the guest using the rest_preimage.
/// Destination can be zeros (no validation on zero pubkey).
#[derive(Clone, Copy, Debug, Eq, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
#[repr(C)]
pub struct EntryAction {
/// Recipient pubkey on L2
pub destination: [u32; 8],
}
}
flowchart LR
subgraph Payload
HDR["Header<br/>op=1, nonce"]
DST["destination<br/>32B pubkey"]
end
HDR --> DST
subgraph Guest["Guest verification"]
SUFFIX["Reject if input 0<br/>has permission suffix"]
OUTPUT["Parse output 0<br/>from rest_preimage"]
SPK["Verify output SPK<br/>is P2SH(delegate)"]
CREDIT["Credit destination<br/>balance += value"]
end
DST -.-> CREDIT
Key design: The deposit amount is not in the payload. It comes from the transaction’s first output value, parsed from the rest_preimage. The rest_preimage is read at the V1TxData level (shared by all action types) and its hash is verified by the guest as part of tx_id computation. This prevents the host from inflating deposit amounts — the value is cryptographically committed via rest_digest → tx_id.
SPK verification: The guest verifies that output 0’s SPK is P2SH(delegate_script(covenant_id)). This ensures the deposited funds are actually locked in the covenant, not sent to an arbitrary address.
Permission suffix guard: The guest rejects entry actions whose transaction’s first input has the permission domain suffix ([0x51, 0x75]). This prevents delegate change outputs (from withdrawal transactions) from being misinterpreted as new deposits.
Exit / Withdrawal (opcode 2)
Debits an L2 account and creates a withdrawal claim on L1.
#![allow(unused)]
fn main() {
/// Exit (withdrawal) action data (follows ActionHeader when operation == OP_EXIT)
///
/// Layout:
/// - source: [u32; 8] (32 bytes) - sender L2 pubkey (authorized via prev tx output)
/// - destination_spk: [u32; 10] (40 bytes) - L1 SPK in first 35 bytes, last 5 zero-padded
/// - amount: u64 (8 bytes) - amount to withdraw
///
/// Total: 80 bytes = 20 words
#[derive(Clone, Copy, Debug, Eq, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
#[repr(C)]
pub struct ExitAction {
/// Sender L2 pubkey (authorized via prev tx output, same as TransferAction)
pub source: [u32; 8],
/// Destination L1 SPK stored as words. First 35 bytes = SPK, last 5 = zero padding.
pub destination_spk: [u32; EXIT_SPK_WORDS],
/// Amount to withdraw
pub amount: u64,
}
}
flowchart LR
subgraph Payload
HDR["Header<br/>op=2, nonce"]
SRC["source<br/>32B pubkey"]
SPK["destination_spk<br/>40B (35B + pad)"]
AMT["amount<br/>8B u64"]
end
HDR --> SRC --> SPK --> AMT
subgraph Guest["Guest verification"]
AUTH["Verify source<br/>owns prev tx output"]
DEBIT["Debit source<br/>balance -= amount"]
PERM["Add permission leaf<br/>hash(spk, amount)"]
end
SRC -.-> AUTH
AMT -.-> DEBIT
SPK -.-> PERM
AMT -.-> PERM
SPK encoding: The destination_spk field holds up to 35 bytes of L1 script public key, padded to 40 bytes (10 words) for alignment. The actual length is inferred from the first byte: 0x20 (OP_DATA_32) means 34-byte Schnorr P2PK; anything else means 35 bytes (ECDSA P2PK or P2SH).
Permission leaf: On successful debit, the guest adds perm_leaf_hash(spk, amount) to the streaming permission tree builder. After all blocks are processed, this tree’s root is committed to the journal, enabling on-chain withdrawal claims.
The Action enum
All three types are unified under a single enum for dispatch:
#![allow(unused)]
fn main() {
/// Parsed action types
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Action {
Transfer(TransferAction),
Entry(EntryAction),
Exit(ExitAction),
}
}
The source() method returns None for entry actions (deposits come from L1, not an L2 account). This distinction drives the authorization logic in the guest — entries skip source verification entirely.
Guest Proof Pipeline
The guest program is the heart of the rollup. It runs inside the RISC Zero zkVM, reads blocks and witness data from the host, processes every transaction, updates the state root, and writes a journal that the on-chain script can verify.
Overview
flowchart TD
INPUT["Read PublicInput<br/>(prev_state, prev_seq, covenant_id)"]
BLOCKS["For each block"]
TXS["For each transaction"]
CLASSIFY["Classify: V0 or V1?"]
V0["V0: read tx_id directly"]
V1["V1: read payload + rest_preimage<br/>compute tx_id"]
ACTION["Is action?<br/>(prefix + valid header)"]
DISPATCH["Dispatch by opcode"]
TRANSFER["Transfer"]
ENTRY["Entry"]
EXIT["Exit"]
MERKLE["Add seq_commitment_leaf<br/>to block merkle"]
BLOCK_ROOT["Finalize block merkle root"]
SEQ["Update seq_commitment"]
PERM["Build permission tree<br/>(if exits occurred)"]
JOURNAL["Write journal"]
INPUT --> BLOCKS
BLOCKS --> TXS
TXS --> CLASSIFY
CLASSIFY -->|V0| V0
CLASSIFY -->|V1| V1
V1 --> ACTION
ACTION -->|Yes| DISPATCH
ACTION -->|No| MERKLE
DISPATCH --> TRANSFER --> MERKLE
DISPATCH --> ENTRY --> MERKLE
DISPATCH --> EXIT --> MERKLE
V0 --> MERKLE
MERKLE --> TXS
TXS -->|block done| BLOCK_ROOT
BLOCK_ROOT --> SEQ
SEQ --> BLOCKS
BLOCKS -->|all done| PERM
PERM --> JOURNAL
PublicInput
The guest begins by reading PublicInput — three 32-byte hashes that anchor the proof to the chain:
prev_state_hash— the SMT root before this batchprev_seq_commitment— the sequence commitment before this batchcovenant_id— identifies this specific covenant instance
These values are written to the journal so the on-chain script can verify they match the previous UTXO.
Block processing
#![allow(unused)]
fn main() {
/// Process all transactions in a block, updating state and building merkle tree
pub fn process_block(
stdin: &mut impl WordRead,
state_root: &mut [u32; 8],
covenant_id: &[u32; 8],
perm_builder: &mut StreamingPermTreeBuilder,
) -> [u32; 8] {
let tx_count = input::read_u32(stdin);
let mut merkle_builder = StreamingMerkleBuilder::new();
for _ in 0..tx_count {
let (tx_id, version) = process_transaction(stdin, state_root, covenant_id, perm_builder);
let leaf = seq_commitment_leaf(&tx_id, version);
merkle_builder.add_leaf(leaf);
}
merkle_builder.finalize()
}
}
Each block contains a list of transactions. The guest processes them sequentially, building a streaming Merkle tree of seq_commitment_leaf(tx_id, version) values. The finalized block root is then combined with the running seq_commitment via calc_accepted_id_merkle_root.
Transaction classification
#![allow(unused)]
fn main() {
/// Read V1 transaction data and compute tx_id
///
/// The guest determines whether this is an action transaction based on:
/// 1. tx_id starts with ACTION_TX_ID_PREFIX
/// 2. header version is valid and operation is known
/// 3. action data is valid (e.g., non-zero amount for transfer)
///
/// This is computed from the cryptographic data, NOT from a host flag.
///
/// Payload handling:
/// - Host sends payload_len in BYTES (not words)
/// - We read ceil(payload_len/4) words (padded to word boundary)
/// - Compute payload_digest from actual bytes (trimmed to payload_len)
/// - Only parse as action if payload is 4-byte aligned (required for our format)
///
/// Rest preimage:
/// - Host sends the full rest_preimage (length-prefixed).
/// - Guest computes rest_digest = hash(rest_preimage) — never trusts host-provided digest.
/// - rest_preimage is stored for use by action processing (input verification, output parsing).
pub fn read_v1_tx_data(stdin: &mut impl WordRead) -> V1TxData {
// Read payload length in BYTES
let payload_byte_len = input::read_u32(stdin) as usize;
// Calculate words needed (round up to word boundary)
let payload_word_len = payload_byte_len.div_ceil(4);
// Read as words (guaranteed 4-byte aligned)
let mut payload_words = vec![0u32; payload_word_len];
stdin.read_words(&mut payload_words).unwrap();
// View as bytes for payload_digest
let payload_bytes: &[u8] = bytemuck::cast_slice(&payload_words);
let payload_bytes = &payload_bytes[..payload_byte_len]; // trim padding
// Read rest_preimage (length-prefixed) and compute rest_digest
let rest_preimage = input::read_aligned_bytes(stdin);
let rest_digest = rest_digest_bytes(rest_preimage.as_bytes());
// Compute tx_id from payload bytes and rest_digest
let pd = payload_digest_bytes(payload_bytes);
let tx_id = tx_id_v1(&pd, &rest_digest);
// Only parse action if payload is 4-byte aligned (required for our action format)
let action = if payload_byte_len.is_multiple_of(4) {
parse_action(&payload_words)
} else {
None // Unaligned payload - not a valid action format
};
// Only valid if tx_id has action prefix AND action parsed successfully AND is valid
let valid_action = if is_action_tx_id(&tx_id) { action.filter(|a| a.is_valid()) } else { None };
V1TxData { tx_id, action: valid_action, rest_preimage }
}
}
For V1 transactions, the guest:
- Reads the payload bytes from stdin
- Reads the full
rest_preimage(length-prefixed) and computesrest_digest = hash(rest_preimage)— the guest never trusts a host-provided digest - Computes
payload_digestfrom the raw payload bytes - Computes
tx_id = blake3(payload_digest || rest_digest) - Checks if the
tx_idstarts withACTION_TX_ID_PREFIX(0x41) - If so, parses the payload as an action header + data
The rest_preimage is stored in V1TxData and passed to action handlers. For transfer/exit actions, it is used to extract the first input’s outpoint (proving which UTXO the transaction actually spends). For entry actions, it is used to parse the deposit output.
The action is only considered valid if the prefix matches and the header version and operation are recognized and the action-specific validity check passes (e.g., non-zero amount).
Action parsing
#![allow(unused)]
fn main() {
/// Parse action from payload words
fn parse_action(payload: &[u32]) -> Option<Action> {
let (header_words, rest) = payload.split_first_chunk::<{ ActionHeader::WORDS }>()?;
let header = ActionHeader::from_words_ref(header_words);
if !header.is_valid_version() {
return None;
}
// Parse action data based on operation
match header.operation {
OP_TRANSFER => {
let transfer_words = rest.first_chunk()?;
let transfer = TransferAction::from_words(*transfer_words);
Some(Action::Transfer(transfer))
}
OP_ENTRY => {
let entry_words = rest.first_chunk()?;
let entry = EntryAction::from_words(*entry_words);
Some(Action::Entry(entry))
}
OP_EXIT => {
let exit_words = rest.first_chunk()?;
let exit = ExitAction::from_words(*exit_words);
Some(Action::Exit(exit))
}
_ => None, // Unknown operation
}
}
}
Action dispatch
#![allow(unused)]
fn main() {
/// Process a valid action transaction
///
/// Called only when guest has cryptographically determined this is a valid action.
/// Host must provide witness data for verification.
///
/// The rest_preimage of the current transaction is used to:
/// - Extract first input outpoint (for transfer/exit source verification)
/// - Parse output data (for entry deposit amount)
fn process_action(
stdin: &mut impl WordRead,
state_root: &mut [u32; 8],
action: Action,
rest_preimage: &AlignedBytes,
covenant_id: &[u32; 8],
perm_builder: &mut StreamingPermTreeBuilder,
) {
match action {
Action::Transfer(transfer) => process_transfer(stdin, state_root, transfer, rest_preimage),
Action::Entry(entry) => process_entry(stdin, state_root, entry, rest_preimage, covenant_id),
Action::Exit(exit) => process_exit(stdin, state_root, exit, rest_preimage, perm_builder),
}
}
}
Witness structures
Each action type requires different witness data from the host:
#![allow(unused)]
fn main() {
/// Witness data for an entry (deposit) action.
///
/// Entry deposits don't need source authorization (no source account).
/// The rest_preimage (for extracting deposit amount from output 0) is now
/// provided via V1TxData — no longer part of this witness.
pub struct EntryWitness {
/// Destination account SMT proof (for crediting the deposit)
pub dest: AccountWitness,
}
impl EntryWitness {
pub fn read_from_stdin(stdin: &mut impl WordRead) -> Self {
Self { dest: input::read_account_witness(stdin) }
}
}
}
Key simplifications:
- Entry witness no longer includes
rest_preimage. The current transaction’srest_preimageis already read at theV1TxDatalevel and passed down. - PrevTxV1WitnessData no longer includes
prev_tx_idoroutput_index. These are derived from the current action transaction’s first input outpoint, which is committed viarest_preimage→rest_digest→tx_id. This prevents the host from substituting a fake previous transaction.
Conditional witness reading
For transfer and exit actions, the guest reads witness data conditionally based on the source account’s balance:
Host writes: Guest reads:
─────────── ───────────
source AccountWitness source AccountWitness
(always) verify SMT proof
check balance >= amount?
├─ NO → return (nothing more to read)
└─ YES ↓
PrevTxV1Witness PrevTxV1Witness
(if balance sufficient) verify auth
├─ FAIL → return (nothing more to read)
└─ OK ↓
dest AccountWitness dest AccountWitness [transfer only]
(if balance sufficient) verify SMT proof
update state_root
This conditional reading reduces the witness data the host must provide for transactions that fail the balance check. Unknown accounts are represented as empty leaves in the SMT — the guest verifies the empty-leaf proof against the current root, confirms the account has zero balance, and skips the action without needing auth or destination witnesses.
#![allow(unused)]
fn main() {
/// Verify the source account and compute the intermediate root after debit.
///
/// Handles both existing accounts and empty leaves (unknown accounts).
/// Returns `None` when the balance is insufficient (always true for empty leaves),
/// signalling the caller to skip the rest of the action (no auth/dest reads).
///
/// Asserts (host cheating — proof fails):
/// - SMT proof doesn't verify against root
/// - existing account: witness pubkey doesn't match source
///
/// Skips (user error):
/// - insufficient balance (including empty leaf with balance=0)
pub fn verify_and_debit_source(
source: &[u32; 8],
source_witness: &AccountWitness,
amount: u64,
current_root: &[u32; 8],
) -> Option<[u32; 8]> {
let key = key_to_index(source);
let is_empty = source_witness.balance == 0 && source_witness.pubkey == [0u32; 8];
if is_empty {
// Empty leaf — the source key has never been inserted.
let empty_leaf = empty_leaf_hash();
assert!(
source_witness.proof.verify(current_root, key, &empty_leaf),
"host cheating: source empty slot SMT proof invalid"
);
return None; // balance=0 always insufficient
}
// Existing account — witness pubkey must match source.
assert_eq!(source_witness.pubkey, *source, "host cheating: source witness pubkey mismatch");
let leaf = leaf_hash(source, source_witness.balance);
assert!(source_witness.proof.verify(current_root, key, &leaf), "host cheating: source SMT proof invalid");
if source_witness.balance < amount {
return None;
}
let new_balance = source_witness.balance - amount;
let new_leaf = leaf_hash(source, new_balance);
Some(source_witness.proof.compute_root(&new_leaf, key))
}
}
Source authorization
For transfers and exits, the guest verifies that the action’s source pubkey matches the public key in a previous transaction output:
#![allow(unused)]
fn main() {
/// Verify source authorization for transfer and exit actions.
///
/// Takes the first input's prev_tx_id from the current action transaction's
/// rest_preimage (committed via rest_digest → tx_id). This ensures the host
/// cannot substitute a fake prev_tx — the prev_tx_id is derived from committed data.
///
/// Asserts (host cheating, proof fails):
/// - prev_tx witness doesn't hash to the committed prev_tx_id
///
/// Skips (user error, action rejected but tx_id still committed):
/// - SPK is not Schnorr P2PK
/// - SPK pubkey doesn't match action source
///
/// Returns the verified source pubkey if authorization succeeds.
pub fn verify_source(source: &[u32; 8], prev_tx: &PrevTxV1WitnessData, first_input_prev_tx_id: &[u32; 8]) -> Option<[u32; 8]> {
// Step 1: Compute prev_tx_id from the witness preimage and ASSERT it matches
// the first input outpoint from the current tx. If mismatch, host is cheating.
let wrapped = PrevTxWitness::V1(prev_tx.witness.clone());
let computed_tx_id = wrapped.compute_tx_id();
assert_eq!(computed_tx_id, *first_input_prev_tx_id, "host cheating: prev_tx witness does not match first input outpoint");
// Step 2: Extract output from the verified prev_tx
let output_data = wrapped.extract_output()?;
// Step 3: Get SPK as Schnorr P2PK (34 bytes). Rejects P2SH and ECDSA.
let verified_spk = output_data.spk_as_p2pk()?;
// Step 4: Extract 32-byte pubkey from the verified SPK
let spk_pubkey = extract_pubkey_from_spk(&verified_spk)?;
// Step 5: Verify action source matches the SPK's pubkey (skip if user error)
if spk_pubkey != *source {
return None;
}
Some(spk_pubkey)
}
}
The verification chain is:
- Guest parses the current action transaction’s
rest_preimageto extract the first input’s outpoint(prev_tx_id, output_index)— this is committed viarest_digest→tx_id, so tamper-proof - Host provides
PrevTxV1Witness(rest_preimage + payload_digest of the previous transaction) - Guest recomputes the previous
tx_idfrom the witness and asserts it matches the first input’sprev_tx_id— mismatch means the host is cheating (proof fails) - Guest parses the output at the first input’s
output_indexfrom the previous tx’srest_preimage - Guest checks the output SPK is Schnorr P2PK format (34 bytes) — if not, the action is skipped (user error)
- Guest extracts the 32-byte pubkey and compares with
action.source— mismatch is a skip (user error)
Only Schnorr P2PK sources are accepted — ECDSA and P2SH sources are rejected.
Assert vs skip
The guest distinguishes between host cheating and user error:
| Condition | Response | Rationale |
|---|---|---|
| Prev tx witness doesn’t hash to first input’s tx_id | Assert (proof fails) | Host provided fake witness data |
| SMT proof doesn’t verify against root | Assert (proof fails) | Every pubkey has a valid proof (empty leaf by default) |
| Witness pubkey doesn’t match action source | Assert (proof fails) | Host should always provide matching witness |
| SPK is not Schnorr P2PK | Skip (action rejected) | User submitted action with wrong SPK type |
| SPK pubkey doesn’t match action source | Skip (action rejected) | User made a mistake in the action payload |
| Insufficient balance | Skip (action rejected) | User tried to spend more than they have |
State updates
#![allow(unused)]
fn main() {
}
#![allow(unused)]
fn main() {
/// Verify destination account and compute new root after credit.
///
/// Used by both transfers and entries. For new accounts, the witness has
/// pubkey=[0;8] and balance=0, and the SMT slot must be empty.
///
/// Asserts (host cheating):
/// - existing account: witness pubkey doesn't match destination
/// - SMT proof doesn't verify (empty slot or existing account)
pub fn verify_and_update_dest(
destination: &[u32; 8],
dest_witness: &AccountWitness,
amount: u64,
intermediate_root: &[u32; 8],
) -> Option<[u32; 8]> {
let dest_key = key_to_index(destination);
let is_new_account = dest_witness.balance == 0 && dest_witness.pubkey == [0u32; 8];
if is_new_account {
// Assert: empty slot proof must verify (host cheating if not)
let empty_leaf = empty_leaf_hash();
assert!(
dest_witness.proof.verify(intermediate_root, dest_key, &empty_leaf),
"host cheating: dest empty slot SMT proof invalid"
);
} else {
// Assert: witness pubkey must match destination (host cheating if not)
assert_eq!(dest_witness.pubkey, *destination, "host cheating: dest witness pubkey mismatch");
// Assert: existing account proof must verify (host cheating if not)
let dest_leaf = leaf_hash(&dest_witness.pubkey, dest_witness.balance);
assert!(
dest_witness.proof.verify(intermediate_root, dest_key, &dest_leaf),
"host cheating: dest existing account SMT proof invalid"
);
}
// Compute final root after credit
let new_balance = dest_witness.balance + amount;
let new_leaf = leaf_hash(destination, new_balance);
Some(dest_witness.proof.compute_root(&new_leaf, dest_key))
}
}
For transfers, the state update is two-phase:
- Debit source — assert SMT proof verifies and witness pubkey matches (host cheating if not), check balance (skip if insufficient), compute intermediate root
- Credit destination — assert SMT proof verifies against intermediate root (host cheating if not), compute final root
For entries, only the credit phase runs (no source debit).
For exits, only the debit phase runs, and a permission leaf is added.
All SMT proof verifications use assert! rather than returning None, because every pubkey has a valid proof in the sparse Merkle tree (empty leaf by default). If the host provides an invalid proof, it is provably cheating.
Journal output
#![allow(unused)]
fn main() {
/// Write the proof output to the journal.
///
/// Journal layout:
/// Base (160 bytes = 40 words):
/// prev_state_hash(32) | prev_seq_commitment(32) | new_state(32) | new_seq(32) | covenant_id(32)
/// With permission (192 bytes = 48 words):
/// ... base ... | permission_spk_hash(32)
#[inline]
pub fn write_output(
public_input: &PublicInput,
final_state_root: &[u32; 8],
final_seq_commitment: &[u32; 8],
permission_spk_hash: Option<&[u32; 8]>,
) {
let mut journal = env::journal();
// Write prev_state_hash and prev_seq_commitment individually
// (covenant_id is written at the end, not adjacent to them)
journal.write_words(&public_input.prev_state_hash).unwrap();
journal.write_words(&public_input.prev_seq_commitment).unwrap();
// Write new state root
journal.write_words(final_state_root).unwrap();
// Write final sequence commitment
journal.write_words(final_seq_commitment).unwrap();
// Write covenant_id
journal.write_words(&public_input.covenant_id).unwrap();
// Write permission SPK hash when exits are present
if let Some(hash_words) = permission_spk_hash {
journal.write_words(hash_words).unwrap();
}
}
}
The journal is the proof’s public output — the only data the on-chain script can see. Its layout:
| Offset | Size | Field |
|---|---|---|
| 0 | 32B | prev_state_hash |
| 32 | 32B | prev_seq_commitment |
| 64 | 32B | new_state_root |
| 96 | 32B | new_seq_commitment |
| 128 | 32B | covenant_id |
| 160 | 32B | permission_spk_hash (optional) |
Base journal: 160 bytes (40 words) — always present.
Extended journal: 192 bytes (48 words) — when exits occurred. The extra 32 bytes contain the blake2b hash of the permission redeem script’s P2SH SPK.
Permission tree construction
When exit actions occur, the guest builds a permission tree:
- Each successful exit adds
perm_leaf_hash(spk, amount)to aStreamingPermTreeBuilder - After all blocks, if any exits occurred:
- The host provides the expected redeem script length
- Guest computes the tree root with
pad_to_depth - Guest builds the permission redeem script bytes (using the
no_stdbuilder in core) - Guest asserts the built script length matches the host-provided value
- Guest computes
blake2b(redeem_script)→ the permission SPK hash
- This hash is appended to the journal
The on-chain state verification script uses the journal’s permission SPK hash to verify that the second covenant output (if present) pays to the correct permission script.
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.
State Verification Script
The state verification script is the on-chain redeem script that guards the covenant UTXO. It verifies ZK proofs, enforces state transitions, and ensures the covenant UTXO chain continues correctly.
Script structure
The redeem script embeds the previous state directly in its bytecode:
[OpData32, prev_seq(32B)] [OpData32, prev_state(32B)]
[... verification logic ...]
[OpTrue] [domain_suffix(2B)]
The 66-byte prefix contains:
- 1 + 32 bytes:
OpData32 || prev_seq_commitment - 1 + 32 bytes:
OpData32 || prev_state_hash
The 2-byte domain suffix [OP_0(0x00), OP_DROP(0x75)] at the end identifies this as a state verification script (see Domain tagging below).
See host/src/covenant.rs:15 for the REDEEM_PREFIX_LEN constant.
Self-referential length convergence
The script embeds its own length (as a parameter to OpTxInputScriptSigSubstr). Since changing the length changes the script, which changes the length, the build process iterates until convergence:
- Start with an estimated length (e.g., 200)
- Build the script with that length
- If the actual length differs, rebuild with the new length
- Repeat until stable (typically 2-3 iterations)
This same pattern is used by the permission script (see Chapter 9).
Script data flow
flowchart TD
PREFIX["Prefix: prev_seq, prev_state"]
STASH["Stash prev values to alt stack"]
SEQ["OpChainblockSeqCommit<br/>→ new_seq_commitment"]
BUILD_PREFIX["Build new 66-byte prefix<br/>(new_seq || new_state)"]
EXTRACT["Extract suffix from own sig_script"]
CONCAT["Concat prefix + suffix<br/>→ new redeem script"]
HASH["blake2b(new_redeem) → P2SH SPK"]
VERIFY_OUT["Verify output 0 SPK matches"]
JOURNAL["Build journal preimage"]
PERM["Verify CovOutCount (1 or 2)<br/>Optionally append perm hash"]
SHA["SHA256(journal)"]
PROGRAM_ID["Push program_id"]
ZK["ZK verify (Succinct or Groth16)"]
GUARD["Verify input index == 0"]
PREFIX --> STASH --> SEQ --> BUILD_PREFIX --> EXTRACT --> CONCAT --> HASH --> VERIFY_OUT --> JOURNAL --> PERM --> SHA --> PROGRAM_ID --> ZK --> GUARD
Key operations
Sequence commitment verification
The script uses OpChainblockSeqCommit — a Kaspa-specific opcode that computes the sequence commitment from consensus data. The guest’s new_seq_commitment must match what the chain reports.
See host/src/covenant.rs:77-83 for the obtain_new_seq_commitment implementation.
Output SPK verification
After building the new redeem script, the script hashes it to a P2SH SPK and verifies that output 0 pays to that address. This ensures the covenant UTXO chain continues with the updated state.
Journal construction
The script builds the journal preimage from:
prev_stateandprev_seq(from alt stack, originally embedded in prefix)new_stateandnew_seq(computed during execution)covenant_id(viaOpInputCovenantIdintrospection)- Optionally:
permission_spk_hash(if 2 covenant outputs exist)
See host/src/covenant.rs:132-161 for the build_and_hash_journal implementation.
Output branching (1 vs 2 covenant outputs)
flowchart TD
CHECK["OpCovOutCount"]
ONE["count == 1<br/>No exits in this batch"]
TWO["count == 2<br/>Exits occurred"]
BASE["160-byte journal<br/>(base only)"]
EXT["192-byte journal<br/>(base + perm hash)"]
VERIFY_P2SH["Extract script hash from<br/>output 1 SPK (bytes 4..36)<br/>Verify P2SH format"]
CHECK -->|"== 1"| ONE --> BASE
CHECK -->|"== 2"| TWO --> VERIFY_P2SH --> EXT
When exits occur, the guest includes a permission_spk_hash in the journal. The on-chain script:
- Reads
OpCovOutCount— if 2, a permission output exists - Extracts the script hash from output 1’s P2SH SPK (bytes 4..36 of
to_bytes()) - Verifies output 1 is actually P2SH format (reconstructs and compares)
- Appends the 32-byte script hash to the journal preimage
See host/src/covenant.rs:163-206 for verify_outputs_and_append_perm_hash.
ZK proof verification
The final SHA-256 hash of the journal, plus the program_id, are consumed by the ZK verification opcode (OpRisc0Succinct or OpRisc0Groth16). This verifies that a valid RISC Zero proof exists in the transaction witness data whose journal matches the on-chain computed value.
Domain tagging
The script ends with [OP_0, OP_DROP] — a 2-byte no-op suffix that serves as a domain tag (placed after OP_TRUE). The last 2 bytes of the sig_script (which equal the last 2 bytes of the redeem script) are [0x00, 0x75]. Other scripts in the covenant system check these bytes via OpTxInputScriptSigSubstr to distinguish script types:
| Domain | Suffix bytes | Script |
|---|---|---|
| State verification | [0x00, 0x75] | This script |
| Permission | [0x51, 0x75] | Chapter 9 |
The delegate script (Chapter 10) checks that input 0’s suffix is [0x51, 0x75] to verify it co-spends with a permission input.
Permission Tree
The permission tree is a SHA-256 Merkle tree that accumulates withdrawal claims from exit actions. It bridges the ZK proof (which commits the tree root) to the on-chain permission script (which verifies individual claims).
Lifecycle
flowchart LR
subgraph Guest["ZK Guest"]
EXIT1["Exit action 1<br/>spk₁, amount₁"]
EXIT2["Exit action 2<br/>spk₂, amount₂"]
BUILD["StreamingPermTreeBuilder"]
ROOT["Permission root"]
REDEEM["Build permission<br/>redeem script"]
HASH["blake2b(redeem)<br/>→ spk_hash"]
JOURNAL["Write to journal"]
end
subgraph OnChain["On-Chain"]
STATE["State verification script<br/>verifies spk_hash in journal"]
PERM_UTXO["Permission UTXO<br/>(P2SH of redeem)"]
CLAIM["Permission script<br/>verifies Merkle proof"]
WITHDRAW["Withdrawal output"]
end
EXIT1 --> BUILD
EXIT2 --> BUILD
BUILD --> ROOT
ROOT --> REDEEM
REDEEM --> HASH
HASH --> JOURNAL
JOURNAL --> STATE
STATE -->|"spawns"| PERM_UTXO
PERM_UTXO --> CLAIM
CLAIM --> WITHDRAW
Leaf hashing
Each exit action produces one leaf:
#![allow(unused)]
fn main() {
/// Compute the hash of a permission leaf: sha256("PermLeaf" || spk_bytes || amount_le_bytes)
pub fn perm_leaf_hash(spk: &[u8], amount: u64) -> [u32; 8] {
let mut hasher = sha2::Sha256::new_with_prefix(PERM_LEAF_DOMAIN);
hasher.update(spk);
hasher.update(amount.to_le_bytes());
let result: [u8; 32] = hasher.finalize().into();
crate::bytes_to_words(result)
}
}
The SPK is variable-length (34 or 35 bytes depending on address type). The amount is encoded as 8-byte little-endian.
Tree depth
The tree depth is variable based on the number of exits:
#![allow(unused)]
fn main() {
/// Compute the required depth for a given leaf count.
/// Returns `ceil(log2(count))`, minimum 1, maximum `PERM_MAX_DEPTH`.
pub fn required_depth(count: usize) -> usize {
if count <= 1 {
return 1;
}
let bits = usize::BITS - (count - 1).leading_zeros();
(bits as usize).min(PERM_MAX_DEPTH)
}
}
| Exits | Depth | Capacity |
|---|---|---|
| 0 | 1 | 2 |
| 1-2 | 1 | 2 |
| 3-4 | 2 | 4 |
| 5-8 | 3 | 8 |
| 9-16 | 4 | 16 |
| … | … | … |
| 129-256 | 8 | 256 |
Maximum depth is 8 (256 leaves), matching the account SMT capacity.
Padding to depth
The streaming builder produces a root whose effective depth is ceil(log2(leaf_count)). If the target depth is larger, empty subtrees are appended on the right:
#![allow(unused)]
fn main() {
/// Pad a streaming builder result up to a target depth.
///
/// The streaming builder produces a root whose effective depth is
/// `ceil(log2(leaf_count))`. If `target_depth` is larger, we need to
/// extend with empty subtrees on the right.
pub fn pad_to_depth(mut hash: [u32; 8], leaf_count: u32, target_depth: usize) -> [u32; 8] {
if leaf_count == 0 {
return perm_empty_subtree_hash(target_depth);
}
let effective_depth = required_depth(leaf_count as usize);
for level in effective_depth..target_depth {
hash = perm_branch_hash(&hash, &perm_empty_subtree_hash(level));
}
hash
}
}
Streaming builder
The permission tree uses the same StreamingMerkle<H> generic builder as the sequence commitment tree, but parameterized with SHA-256 permission-tree hash operations:
#![allow(unused)]
fn main() {
/// Permission tree proof: variable-depth array of siblings + leaf index.
///
/// Max depth is 8 (256 leaves). Siblings are stored from leaf (level 0)
/// to root (level depth-1).
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PermProof {
/// Sibling hashes from leaf to root
pub siblings: [[u32; 8]; PERM_MAX_DEPTH],
/// Number of valid siblings (= tree depth)
pub depth: usize,
/// Leaf index in the tree
pub index: usize,
}
impl PermProof {
/// Create a new proof
pub fn new(siblings: [[u32; 8]; PERM_MAX_DEPTH], depth: usize, index: usize) -> Self {
Self { siblings, depth, index }
}
/// Compute the root from a leaf hash using this proof
pub fn compute_root(&self, leaf_hash: &[u32; 8]) -> [u32; 8] {
let mut current = *leaf_hash;
for level in 0..self.depth {
let bit = (self.index >> level) & 1;
if bit == 0 {
current = perm_branch_hash(¤t, &self.siblings[level]);
} else {
current = perm_branch_hash(&self.siblings[level], ¤t);
}
}
current
}
/// Verify that a leaf with given hash exists at this proof's index under given root
pub fn verify(&self, root: &[u32; 8], leaf_hash: &[u32; 8]) -> bool {
self.compute_root(leaf_hash) == *root
}
/// Compute a new root after replacing the leaf at this proof's index.
/// Uses the same siblings and index, just a different leaf hash.
pub fn compute_new_root(&self, new_leaf_hash: &[u32; 8]) -> [u32; 8] {
self.compute_root(new_leaf_hash)
}
}
}
Guest builds the permission script
After all blocks are processed, if any exits occurred:
- The host provides the expected redeem script length (private input)
- Guest computes
required_depth(perm_count) - Guest finalizes the streaming builder and pads to depth
- Guest builds the entire permission redeem script in
no_std(seecore/src/permission_script.rs) - Guest asserts the script length matches the host-provided value
- Guest computes
blake2b(redeem_script)→script_hash - Guest writes the script hash to the journal
The host-provided length is needed because the script embeds its own length (self-referential). The guest builds the script once and asserts — it does not iterate. The host must have converged the length beforehand.
On-chain usage
The state verification script checks if CovOutCount == 2. If so, it verifies that the second covenant output’s P2SH script hash matches the journal’s permission_spk_hash. This spawns a permission UTXO that can be spent by the permission script (see Chapter 9).
The permission script embeds the root and unclaimed count. Each withdrawal claim walks the Merkle tree twice (old root verification + new root computation) and updates the embedded state.
Permission Script — 11 Phases
The permission script governs L2→L1 withdrawals. It verifies a Merkle proof against the embedded root, pays the withdrawal to the correct address, and optionally continues the permission UTXO with updated state.
Transaction layout
flowchart LR
subgraph Inputs
I0["Input 0: Permission script<br/>(this script)"]
I1["Input 1: Delegate input<br/>(bridge reserve)"]
I2["Input 2: Delegate input<br/>(optional)"]
I3["Input N+1: Collateral<br/>(optional, for fees)"]
end
subgraph Outputs
O0["Output 0: Withdrawal<br/>to leaf's SPK"]
O1["Output 1: Continuation<br/>(if unclaimed > 0)"]
O2["Output 2: Delegate change<br/>(if change > 0)"]
O3["Output 3: Collateral change<br/>(optional, unchecked)"]
end
I0 --> O0
I0 --> O1
I1 --> O2
I2 --> O2
Sig_script push order
The sig_script pushes values in this order (bottom of stack first):
G2_sib_{d-1}, G2_dir_{d-1}, ..., G2_sib_0, G2_dir_0,
G1_sib_{d-1}, G1_dir_{d-1}, ..., G1_sib_0, G1_dir_0,
spk(var), amount(8B LE), deduct(i64),
redeem_script
G1 and G2 are the same Merkle path (siblings and direction bits), used twice: once to verify the old root and once to compute the new root.
Phase sequence
flowchart TD
P1["Phase 1: Prefix<br/>Push root(32B) + unclaimed(8B)"]
P2["Phase 2: Stash embedded<br/>root → alt, uncl → alt"]
P3["Phase 3: Validate amounts<br/>deduct > 0, amount ≥ deduct"]
P4["Phase 4: Verify withdrawal<br/>output 0 SPK == leaf SPK"]
P5["Phase 5: Compute leaf hashes<br/>old_leaf, new_leaf"]
P6["Phase 6: Verify old root<br/>Merkle walk G1 → equalverify(root)"]
P7["Phase 7: Compute new root<br/>Merkle walk G2 → new_root"]
P8["Phase 8: Compute new unclaimed<br/>uncl - (1 if fully claimed)"]
P9["Phase 9: Verify outputs<br/>continuation SPK or cleanup"]
P10["Phase 10: Verify delegate balance<br/>sum inputs ≥ deduct"]
P11["Phase 11: Domain suffix<br/>[OP_1, OP_DROP]"]
P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7 --> P8 --> P9 --> P10 --> P11
Phase details
Phase 1: Prefix (42 bytes)
The prefix embeds the permission root and unclaimed count directly in the script bytecode:
OpData32(1B) || root(32B) || OP_DATA_8(1B) || unclaimed_count(8B)
After execution, the stack has [root(32B), unclaimed_count(8B)] on top.
Phase 2: Stash embedded
Moves root and unclaimed_count to the alt stack for later comparison/update.
Main: [...G2, ...G1, spk, amount, deduct]
Alt: [uncl_emb, root_emb]
Phase 3: Validate amounts
Verifies deduct > 0 and computes new_amount = amount - deduct >= 0. Stashes deduct to alt for Phase 10 (delegate balance).
Phase 4: Verify withdrawal
Prepends a 2-byte version prefix to spk and compares with OpTxOutputSpk(0). This ensures the withdrawal output pays to the leaf’s designated address.
Phase 5: Compute leaf hashes
Computes both the old leaf hash (SHA256("PermLeaf" || spk || amount)) and the new leaf hash. If new_amount == 0, the new leaf is the empty leaf hash (SHA256("PermEmpty")), indicating the claim is fully consumed.
Phase 6: Verify old root
Walks the G1 Merkle path (depth steps), each consuming a (sibling, direction) pair. The computed root is compared against the embedded root via OpEqualVerify.
Each Merkle step:
SWAP → IF → [sib||current] → ELSE → SWAP → [current||sib] → ENDIF
→ CAT → push("PermBranch") → SWAP → CAT → SHA256
Phase 7: Compute new root
Identical Merkle walk using G2 siblings, but starting from the new leaf hash. The result is the updated permission root.
Phase 8: Compute new unclaimed
If the leaf was fully consumed (new_amount == 0), decrements unclaimed_count by 1. Converts to 8-byte LE for prefix reconstruction.
Phase 9: Verify outputs
Two branches based on new_unclaimed:
All claimed (new_uncl == 0): Drop the new root and unclaimed. Verify CovOutCount == 0 — no continuation output should exist.
Unclaimed remain (new_uncl > 0):
- Reconstruct the 42-byte new prefix from
new_rootandnew_uncl - Extract the script body+suffix from the current sig_script (self-introspection)
- Concatenate → new redeem script
- Hash to P2SH SPK and verify output 1 matches
- Verify
CovOutCount == 1andCovOutputIdx(0) == 1(the single covenant output is at output index 1)
Phase 10: Verify delegate balance
Enforces the delegate input/output balance equation:
input_count <= MAX_DELEGATE_INPUTS + 2- Reconstructs the expected delegate P2SH SPK from
covenant_id - Sums amounts from inputs 1..N whose SPK matches the delegate SPK
- Guards that input N+1 does NOT have the delegate SPK (prevents overcounting)
- Computes
expected_change = total_input - deduct >= 0 - If
expected_change > 0: verifiesoutput[1 + CovOutCount]has delegate SPK and correct amount
Phase 11: Domain suffix
Appends [OP_TRUE(0x51), OP_DROP(0x75)] after the final OP_TRUE. This is a no-op that tags the script for cross-script introspection by the delegate script.
Implementation
The permission script has two implementations that produce identical bytecode:
- Host (
host/src/bridge.rs) — usesScriptBuilderfromkaspa-txscript - Core (
core/src/permission_script.rs) —no_stdbyte-level builder
The guest uses the core implementation to build the script inside the ZK proof, then hashes it to produce the permission_spk_hash for the journal.
See core/src/permission_script.rs:139-209 for build_permission_redeem_bytes and the converging length loop.
Delegate/Entry Script
The delegate script is a 53-byte P2SH redeem script that allows funds to ride alongside a permission input. It serves dual duty: it locks deposit outputs for entries and provides bridge reserve inputs for withdrawals.
Purpose
When a user deposits funds (entry action), the funds must be locked in a way that:
- Only the covenant system can spend them
- The guest can verify the deposit is genuine
When a withdrawal is processed, the permission script needs additional inputs to fund the withdrawal. Delegate inputs provide this liquidity.
Script structure
flowchart TD
CHECK_IDX["Step 1: OpTxInputIndex > 0?<br/>Self is not at input 0"]
CHECK_COV["Step 2: Input 0 has<br/>expected covenant_id?"]
CHECK_SUFFIX["Step 3: Input 0's sig_script<br/>ends with [0x51, 0x75]?"]
RESULT["OpTrue"]
CHECK_IDX -->|"Verify"| CHECK_COV
CHECK_COV -->|"EqualVerify"| CHECK_SUFFIX
CHECK_SUFFIX -->|"EqualVerify"| RESULT
The script is 53 bytes:
| Offset | Size | Content |
|---|---|---|
| 0-3 | 4B | Index check: OpTxInputIndex Op0 OpGreaterThan OpVerify |
| 4-6 | 3B | Covenant preamble: Op0 OpInputCovenantId OpData32 |
| 7-38 | 32B | Embedded covenant_id |
| 39 | 1B | OpEqualVerify |
| 40-51 | 12B | Suffix check: extract last 2 bytes of input 0’s sig_script, compare with [0x51, 0x75] |
| 52 | 1B | OpTrue |
See core/src/p2sh.rs:103-156 for build_delegate_entry_script_bytes.
Three verification steps
Step 1: Not at input 0
OpTxInputIndex Op0 OpGreaterThan OpVerify
The delegate script must not be at input index 0. Index 0 is reserved for the permission script (or state verification script). This prevents the delegate from being used as the primary covenant input.
Step 2: Covenant ID match
Op0 OpInputCovenantId OpData32 <covenant_id> OpEqualVerify
Checks that input 0 carries the expected covenant_id. This binds the delegate to a specific covenant instance — it cannot be co-spent with a different covenant’s permission script.
Step 3: Permission domain suffix
Op0 Op0 OpTxInputScriptSigLen OpDup Op2 OpSub OpSwap OpTxInputScriptSigSubstr
push-2 0x51 0x75 OpEqualVerify
Extracts the last 2 bytes of input 0’s sig_script and verifies they are [0x51, 0x75] (the permission domain suffix). This ensures the delegate is co-spending with a permission input specifically, not a state verification input.
Deposit SPK verification
When a user creates an entry (deposit) transaction, the output must pay to P2SH(delegate_script(covenant_id)). The guest verifies this:
#![allow(unused)]
fn main() {
/// Verify that an entry (deposit) transaction output SPK is a P2SH wrapping the
/// correct delegate/entry script for the given covenant.
///
/// This ensures deposited funds are actually locked in the covenant, preventing
/// a malicious host from crediting L2 accounts for funds sent to arbitrary addresses.
pub fn verify_entry_output_spk(spk: &[u8], covenant_id: &[u32; 8]) -> bool {
let delegate = build_delegate_entry_script_bytes(covenant_id);
verify_p2sh_spk(spk, &delegate)
}
}
This reconstructs the expected delegate script from the covenant_id, hashes it with blake2b, and compares with the output’s P2SH script hash.
Entry input guard
The guest also checks that entry transactions do NOT have a permission script as input 0:
#![allow(unused)]
fn main() {
/// Check if the first input's sig_script ends with the permission domain suffix.
///
/// Returns `true` if input 0's sig_script ends with `[0x51, 0x75]` (`OP_TRUE OP_DROP`),
/// which identifies the permission script domain. This is used by the guest to reject
/// entry (deposit) transactions whose first input is a permission script, preventing
/// delegate change outputs from being counted as new deposits.
///
/// The transaction byte format is the same as documented on [`parse_output_at_index`].
pub fn input0_has_permission_suffix(tx_bytes: &[u8]) -> bool {
let mut cursor = 0;
// Skip version (2 bytes)
cursor += 2;
if cursor > tx_bytes.len() {
return false;
}
// Read num_inputs
let num_inputs = match read_u64(tx_bytes, &mut cursor) {
Some(n) => n,
None => return false,
};
if num_inputs == 0 {
return false;
}
// Skip prev_tx_id (32 bytes) + prev_index (4 bytes)
cursor += 36;
if cursor > tx_bytes.len() {
return false;
}
// Read sig_script_len
let sig_len = match read_u64(tx_bytes, &mut cursor) {
Some(n) => n as usize,
None => return false,
};
if sig_len < 2 || cursor + sig_len > tx_bytes.len() {
return false;
}
// Check last 2 bytes of sig_script: [0x51, 0x75] = OP_TRUE, OP_DROP
let sig_end = cursor + sig_len;
tx_bytes[sig_end - 2] == 0x51 && tx_bytes[sig_end - 1] == 0x75
}
}
This prevents a subtle attack: without this guard, the delegate change output from a withdrawal transaction could be misinterpreted as a new deposit. The permission suffix check distinguishes withdrawal change from genuine deposits.
Design rationale
Why a separate script? The delegate script allows bridge reserve funds to be pre-positioned in UTXOs that can only be spent alongside a permission input. This avoids requiring the entire bridge reserve in a single UTXO.
Why check the suffix? The covenant_id check alone is insufficient — it would allow co-spending with a state verification input (which has suffix [0x00, 0x75]). The suffix check ensures delegates only participate in withdrawal transactions.
Why MAX_DELEGATE_INPUTS = 2? The permission script unrolls the delegate input loop. More inputs mean a larger script. Two delegate inputs are sufficient for the PoC while keeping the script compact.
Security Model
This chapter catalogues every check in the system, the attack it prevents, and where trust boundaries lie.
Trust boundaries
flowchart LR
subgraph Untrusted["Untrusted (Host)"]
HOST[Host binary]
WITNESS[Witness data]
HINT[Hints: redeem length,<br/>block ordering]
end
subgraph Crypto["Cryptographically Enforced"]
ZK[ZK proof verifier]
SCRIPT[On-chain scripts]
TXID[Transaction ID binding]
end
subgraph Enforced["Guest (ZK) Guarantees"]
STATE[State transitions correct]
BALANCE[Balances conserved]
AUTH[Source authorized]
SEQ[Block sequence valid]
PERM[Permission tree correct]
end
HOST -->|"provides witnesses"| ZK
ZK -->|"verifies guest execution"| STATE
ZK -->|"verifies guest execution"| BALANCE
ZK -->|"verifies guest execution"| AUTH
ZK -->|"verifies guest execution"| SEQ
ZK -->|"verifies guest execution"| PERM
SCRIPT -->|"verifies ZK proof"| ZK
TXID -->|"binds witnesses to chain"| SCRIPT
What the host can lie about
The host provides private inputs (witnesses) to the guest. Some are trusted hints, others are cryptographically bound.
| Host input | Bound by | Attack if omitted |
|---|---|---|
| Block transaction data | OpChainblockSeqCommit on-chain | Host could fabricate transactions |
| Current tx rest_preimage | rest_digest → tx_id (guest computes) | Host could hide inputs/outputs |
| Previous tx preimage | First input outpoint (from current tx rest_preimage) | Host could claim false output SPKs |
| Account SMT witnesses | Root hash chain + assert | Host could fabricate balances |
| Permission redeem length | Assert in guest | Guest script wouldn’t match on-chain hash |
| Action ordering within block | Seq commitment leaf hash | Order inherited from L1 transaction order; host cannot reorder or skip actions |
Check catalogue
Guest-side checks
Checks are categorized as assert (host cheating — proof fails entirely) or skip (user error — action rejected but tx_id committed).
| Check | Location | Response | Attack prevented |
|---|---|---|---|
is_action_tx_id(tx_id) | guest/src/block.rs | gate | Non-action transactions processed as actions |
tx_id[0..2] == "AC" | core/src/lib.rs | gate | Random transactions misclassified (~1/65536 collision) |
Action version == ACTION_VERSION | guest/src/tx.rs | gate | Future/incompatible action formats |
rest_digest == hash(rest_preimage) | guest/src/tx.rs | computed | Host cannot forge rest_digest (guest computes it) |
| First input outpoint matches prev_tx witness | guest/src/auth.rs | assert | Host substitutes fake prev_tx |
| Prev tx witness hashes to first input’s tx_id | guest/src/auth.rs | assert | Host claims false output SPK/amounts |
| Witness pubkey matches action source | guest/src/state.rs | assert | Host provides wrong witness for action |
| SMT proof verifies against root | guest/src/state.rs | assert | Fabricated account balances (every pubkey has valid proof) |
| Source pubkey matches prev tx SPK | guest/src/auth.rs | skip | User submitted action with wrong SPK |
| Insufficient balance | guest/src/state.rs | skip | User tried to spend more than they have |
deduct > 0 | guest/src/state.rs | skip | Zero-value withdrawal claims |
| Balance conservation (transfer) | guest/src/state.rs | enforced | Value creation from nothing |
| Entry output SPK is P2SH(delegate) | core/src/p2sh.rs | skip | Deposits to unrelated addresses credited |
| Entry tx input 0 not permission suffix | core/src/prev_tx.rs | skip | Delegate change output misinterpreted as deposit |
Exit SPK length <= EXIT_SPK_MAX | core/src/action.rs | enforced | Oversized SPK overflows |
| Seq commitment matches chain | On-chain OpChainblockSeqCommit | on-chain | Fabricated block data |
| Permission root matches exits | guest/src/main.rs | assert | Incorrect withdrawal tree committed |
| Redeem script length matches host hint | guest/src/main.rs | assert | Script hash mismatch |
On-chain checks (state verification script)
| Check | Phase | Attack prevented |
|---|---|---|
Domain prefix [0x00, 0x75] | Start | Script type confusion |
| Prev state embedded in script | Prefix | State rollback or skip |
OpChainblockSeqCommit | Seq commit | Fabricated block sequence |
| Output 0 SPK == P2SH(new redeem) | SPK verify | Covenant chain broken |
| Journal SHA-256 matches ZK proof | ZK verify | Proof for different state transition |
| Program ID matches | ZK verify | Proof from different program |
| Input index == 0 | Guard | Script used at wrong position |
CovOutCount == 1 or 2 | Output branch | Unexpected covenant outputs |
| Output 1 P2SH format (if 2 outputs) | Perm verify | Non-P2SH permission output |
On-chain checks (permission script)
| Check | Phase | Attack prevented |
|---|---|---|
deduct > 0 | Phase 3 | Zero-value claims drain UTXO |
amount >= deduct | Phase 3 | Balance underflow |
| Output 0 SPK == leaf SPK | Phase 4 | Withdrawal to wrong address |
| Old leaf hash verifies under root | Phase 6 | Fabricated Merkle proof |
| New root correctly computed | Phase 7 | State corruption after claim |
| Unclaimed decremented correctly | Phase 8 | Count desync |
| Continuation SPK matches | Phase 9 | Permission UTXO chain broken |
CovOutCount correct | Phase 9 | Unexpected covenant outputs |
output_count <= 4 | Phase 9 | Transaction stuffing |
input_count <= N+2 | Phase 10 | Overcounting delegate inputs |
| Delegate SPK matches covenant | Phase 10 | Spending unrelated UTXOs as delegates |
| Input N+1 not delegate SPK | Phase 10 | Collateral miscounted as delegate |
total_input >= deduct | Phase 10 | Insufficient delegate funds |
| Delegate change output correct | Phase 10 | Change amount/address incorrect |
On-chain checks (delegate/entry script)
| Check | Step | Attack prevented |
|---|---|---|
| Self not at input index 0 | Step 1 | Delegate used as primary covenant input |
| Input 0 covenant_id matches | Step 2 | Co-spent with wrong covenant |
Input 0 suffix [0x51, 0x75] | Step 3 | Co-spent with state verification (not permission) |
Attack scenarios and mitigations
Malicious host substitutes fake prev_tx
Attack: The host provides a fabricated previous transaction witness with the “correct” pubkey in the output, but the action transaction doesn’t actually spend that UTXO. This would let the host authorize transfers/exits from any account.
Mitigation: The guest reads the current action transaction’s rest_preimage at the V1TxData level and computes rest_digest from it (never trusting a host-provided digest). It then parses the first input’s outpoint (prev_tx_id, output_index) from the rest_preimage. The host must provide a prev_tx witness that hashes to this exact prev_tx_id. Since the rest_preimage is committed via rest_digest → tx_id, and the tx_id is bound to the chain via sequence commitment, the host cannot forge the first input. Mismatch causes an assertion failure (proof cannot be generated).
Malicious host provides invalid SMT proof
Attack: The host provides a fabricated SMT proof that claims a different balance for an account, enabling unauthorized spending.
Mitigation: Every pubkey in the sparse Merkle tree has a valid proof — either for the account’s actual leaf or for the empty leaf at that index. The guest asserts that every SMT proof verifies against the current root. Since a valid proof always exists, any verification failure means the host is provably lying. This is enforced with assert! (not skip), so the proof cannot be generated with invalid witnesses.
Malicious host credits fake deposits
Attack: Host provides a witness claiming a deposit output SPK that doesn’t actually pay to P2SH(delegate_script(covenant_id)).
Mitigation: Guest calls verify_entry_output_spk() which reconstructs the expected delegate script from the covenant_id, hashes it, and compares with the output’s P2SH hash. The tx_id binding ensures the output actually exists on-chain.
Delegate change output mistaken for deposit
Attack: A withdrawal transaction creates a delegate change output paying to the delegate script. A malicious host presents this as a new deposit in the next batch.
Mitigation: Guest calls input0_has_permission_suffix() on the entry transaction’s preimage. If input 0 ends with [0x51, 0x75] (permission domain), the transaction is a withdrawal — the output is delegate change, not a deposit.
Cross-covenant co-spending
Attack: A delegate input from covenant A is co-spent with a permission input from covenant B, draining A’s bridge reserve.
Mitigation: The delegate script embeds a specific covenant_id and verifies input 0 carries that same ID. Different covenants have different IDs, so cross-covenant co-spending fails.
Proof replay
Attack: A valid ZK proof from a previous state transition is replayed to revert state.
Mitigation: The journal includes prev_state_hash and prev_seq_commitment, both embedded in the redeem script prefix. The on-chain script verifies these match. Since the seq_commitment changes with every block, a replayed proof would fail the sequence check.
Collateral input overcounting
Attack: An attacker provides extra inputs beyond the delegate slots and hopes they’re counted toward the delegate balance.
Mitigation: Phase 10 enforces input_count <= MAX_DELEGATE_INPUTS + 2 and explicitly guards that input N+1 does NOT have the delegate SPK. The unrolled loop only sums inputs at indices 1..N.
Conservation properties
The system maintains two conservation invariants:
-
L2 balance conservation: Every transfer preserves total L2 balance (amount sent equals amount received). Entries increase total L2 balance by the deposit amount. Exits decrease it by the withdrawal amount.
-
Bridge reserve conservation: The permission script enforces
delegate_input_total >= deductand verifies the change output amount equalstotal - deduct. No value is created or destroyed during withdrawals.
Running the Demo
Note: The demo currently simulates the rollup at the transaction engine level — it does not connect to a live Kaspa network. The host builds a mock chain with mock blocks and transactions, generates real ZK proofs over them, and verifies the proofs through the actual on-chain script logic (covenant + permission scripts executed via
kaspa-txscript). This validates the full proof pipeline and script verification end-to-end, without requiring a running node.
Prerequisites
- Rust stable toolchain
- RISC Zero toolchain (
rzup)
For CUDA-accelerated proving (optional):
- NVIDIA GPU with CUDA support
- CUDA toolkit installed
Building and running
All commands must be run from the host/ directory:
cd examples/zk-covenant-rollup/host
CPU proving (default)
cargo run --release
CUDA-accelerated proving
cargo run --release --features cuda
Adding non-activity blocks
The --non-activity-blocks=N flag adds N blocks filled with 3000 non-action (V0) transactions each. This stress-tests the sequence commitment logic — the guest must iterate through all transactions to verify the seq commitment even though none of them are L2 actions.
cargo run --release -- --non-activity-blocks=5
Combined with CUDA:
cargo run --release --features cuda -- --non-activity-blocks=5
What the demo does
- Builds an initial empty SMT (sparse Merkle tree) state
- Constructs a mock chain with L2 action transactions (deposits, transfers, exits)
- Generates a STARK (succinct) proof via RISC Zero and verifies it
- Simulates on-chain verification of the STARK proof through the covenant script
- Generates a Groth16 proof and verifies it
- Simulates on-chain verification of the Groth16 proof through the covenant script
- If exits occurred, verifies the permission script flow as well
Appendix A: Domain Separators
Every hash in the system uses domain separation to prevent cross-protocol collisions. This appendix lists all domain tags, their hash function, and purpose.
Hash domain tags
| Domain string | Hash | Keyed? | Module | Purpose |
|---|---|---|---|---|
"SMTLeaf" | SHA-256 | No (prefix) | core/src/smt.rs | Account leaf: sha256("SMTLeaf" || pubkey || balance) |
"SMTEmpty" | SHA-256 | No (prefix) | core/src/smt.rs | Empty account slot sentinel: sha256("SMTEmpty") |
"SMTBranch" | SHA-256 | No (prefix) | core/src/smt.rs | SMT internal node: sha256("SMTBranch" || left || right) |
"PermLeaf" | SHA-256 | No (prefix) | core/src/permission_tree.rs | Withdrawal leaf: sha256("PermLeaf" || spk || amount) |
"PermEmpty" | SHA-256 | No (prefix) | core/src/permission_tree.rs | Empty withdrawal slot: sha256("PermEmpty") |
"PermBranch" | SHA-256 | No (prefix) | core/src/permission_tree.rs | Permission tree node: sha256("PermBranch" || left || right) |
"SeqCommitmentMerkleLeafHash" | BLAKE3 | Yes (key) | core/src/seq_commit.rs | Seq commitment leaf: blake3_keyed(key, tx_id || version) |
"SeqCommitmentMerkleBranchHash" | BLAKE3 | Yes (key) | core/src/seq_commit.rs | Seq commitment node: blake3_keyed(key, left || right) |
"PayloadDigest" | BLAKE3 | Yes (key) | core/src/lib.rs | V1 tx payload hash |
"TransactionRest" | BLAKE3 | Yes (key) | core/src/lib.rs | V1 tx rest-of-data hash |
"TransactionV1Id" | BLAKE3 | Yes (key) | core/src/lib.rs | V1 tx_id: blake3_keyed(key, payload_digest || rest_digest) |
"TransactionID" | BLAKE2b-256 | Yes (.key()) | core/src/lib.rs | V0 tx_id: blake2b_keyed(key, full_preimage) |
Non-hash domain tags
| Tag | Value | Type | Module | Purpose |
|---|---|---|---|---|
ACTION_TX_ID_PREFIX | 0x41 ('A') | Byte prefix | core/src/lib.rs | First byte of tx_id identifies action transactions |
| State verification suffix | [0x00, 0x75] | Opcode pair | host/src/bridge.rs | [OP_0, OP_DROP] tags state verification scripts |
| Permission suffix | [0x51, 0x75] | Opcode pair | host/src/bridge.rs | [OP_1, OP_DROP] tags permission scripts |
Hashing strategy
The system uses three hash functions, chosen to match Kaspa’s protocol:
SHA-256 — Used for Merkle trees that must be replicated on-chain via OP_SHA256. Both the account SMT and permission tree use SHA-256 with domain-prefix separation (sha256(tag || data)).
BLAKE3 — Used for transaction IDs and sequence commitments. Kaspa’s V1 transaction ID scheme uses BLAKE3 with keyed hashing. The domain_to_key() function zero-pads a domain string into a 32-byte BLAKE3 key:
#![allow(unused)]
fn main() {
}
BLAKE2b-256 — Used for V0 transaction IDs (legacy) and P2SH script hashing. V0 tx_id uses keyed BLAKE2b; P2SH uses unkeyed BLAKE2b matching kaspa_txscript::pay_to_script_hash_script.
Why separate tree domains?
The SMT and permission tree intentionally use different domain strings ("SMTLeaf" vs "PermLeaf", etc.) even though both use SHA-256. This prevents a valid proof in one tree from being accepted in the other. A test in permission_tree.rs explicitly asserts:
#![allow(unused)]
fn main() {
assert_ne!(perm_empty_leaf_hash(), crate::smt::empty_leaf_hash());
}
Appendix B: Transaction ID Schemes
Kaspa supports two transaction ID formats. The rollup guest must handle both to verify previous transaction outputs.
V0: BLAKE2b full preimage
V0 transactions compute their ID by hashing the entire serialized transaction:
#![allow(unused)]
fn main() {
/// Compute V0 transaction ID using blake2b.
/// This uses the same domain separator as Kaspa's TransactionID hasher.
pub fn tx_id_v0(preimage: &[u8]) -> [u32; 8] {
const DOMAIN_SEP: &[u8] = b"TransactionID";
let hash = blake2b_simd::Params::new().hash_length(32).key(DOMAIN_SEP).hash(preimage);
let mut out = [0u32; 8];
bytemuck::bytes_of_mut(&mut out).copy_from_slice(hash.as_bytes());
out
}
}
The domain key "TransactionID" matches Kaspa’s kaspa_hashes::TransactionID hasher exactly. The full transaction bytes (version, inputs, outputs, locktime, subnetwork, gas, payload) are hashed in one pass.
Verification in the guest:
#![allow(unused)]
fn main() {
/// Compute the tx_id from the preimage
pub fn compute_tx_id(&self) -> [u32; 8] {
crate::tx_id_v0(&self.preimage)
}
}
The host provides the full transaction preimage. The guest hashes it, compares against the claimed tx_id, then parses the preimage to extract the output SPK at the claimed index.
V1: BLAKE3 split digest
V1 transactions split the hash into two components:
tx_id = blake3_keyed("TransactionV1Id", payload_digest || rest_digest)
where:
payload_digest = blake3_keyed("PayloadDigest", payload_bytes)rest_digest = blake3_keyed("TransactionRest", rest_preimage)
#![allow(unused)]
fn main() {
pub fn payload_digest(payload: &[u32]) -> [u32; 8] {
payload_digest_bytes(bytemuck::cast_slice(payload))
}
pub fn payload_digest_bytes(payload: &[u8]) -> [u32; 8] {
const DOMAIN_SEP: &[u8] = b"PayloadDigest";
const KEY: [u8; blake3::KEY_LEN] = domain_to_key(DOMAIN_SEP);
let mut out = [0u32; 8];
bytemuck::bytes_of_mut(&mut out).copy_from_slice(blake3::keyed_hash(&KEY, payload).as_bytes());
out
}
}
#![allow(unused)]
fn main() {
pub fn tx_id_v1(payload_digest: &[u32; 8], rest_digest: &[u32; 8]) -> [u32; 8] {
const DOMAIN_SEP: &[u8] = b"TransactionV1Id";
const KEY: [u8; blake3::KEY_LEN] = domain_to_key(DOMAIN_SEP);
let mut hasher = blake3::Hasher::new_keyed(&KEY);
hasher.update(bytemuck::cast_slice(payload_digest));
hasher.update(bytemuck::cast_slice(rest_digest));
let mut out = [0u32; 8];
bytemuck::bytes_of_mut(&mut out).copy_from_slice(hasher.finalize().as_bytes());
out
}
}
Verification in the guest:
#![allow(unused)]
fn main() {
/// Compute the tx_id from rest_preimage and payload_digest
pub fn compute_tx_id(&self) -> [u32; 8] {
let rest_digest = crate::rest_digest_bytes(self.rest_preimage.as_bytes());
crate::tx_id_v1(&self.payload_digest, &rest_digest)
}
}
The host provides:
- The pre-computed
payload_digest(32 bytes) — the guest does not need the full payload - The
rest_preimage— the guest hashes this to getrest_digestand parses it for output data
Why the split matters
The V1 split design benefits the ZK guest:
-
Smaller witness: For previous transaction verification, the guest only needs
payload_digest(32 bytes) instead of the full payload (variable, potentially large). The payload contains the action data which the guest already has from block processing. -
Output and input extraction: The
rest_preimagecontains both outputs and inputs. The guest extracts output SPKs for deposit verification and first-input outpoints for source authorization. Since the guest reads the fullrest_preimageand computesrest_digestitself (never trusting a host-provided digest), all parsed data is tamper-proof. -
Efficient hashing: BLAKE3 is faster than BLAKE2b in the RISC-V guest, and the split allows partial preimage reuse.
Witness structures
The PrevTxWitness enum handles both versions:
| Version | Witness type | Host provides | Guest computes |
|---|---|---|---|
| V0 | PrevTxV0Witness | Full preimage | blake2b(preimage) |
| V1 | PrevTxV1Witness | rest_preimage + payload_digest | blake3(payload_digest || blake3(rest)) |
Kaspa compatibility
Both implementations are tested against Kaspa’s canonical hashers:
tx_id_v0matcheskaspa_hashes::TransactionIDpayload_digestmatcheskaspa_hashes::PayloadDigestrest_digestmatcheskaspa_hashes::TransactionResttx_id_v1matcheskaspa_hashes::TransactionV1Id
See core/src/lib.rs tests for the compatibility assertions.