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

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:

  1. Validity — Every state transition is backed by a ZK proof. The on-chain script rejects any update that fails verification.
  2. 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.
  3. 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 architectureChapter 2: Architecture
Learn the data modelChapter 3: Account Model
See how proofs workChapter 5: Guest Proof Pipeline
Understand on-chain scriptsChapter 7: State Verification
Audit security propertiesChapter 11: Security Model
Run the demo yourselfChapter 12: Running the Demo
Look up a domain separatorAppendix 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:

CratePathTargetRole
zk-covenant-rollup-corecore/no_std (RISC-V + native)Shared types, hash functions, script construction
zk-covenant-rollup-guestmethods/guest/RISC-V (riscv32im-risc0-zkvm-elf)ZK proof program
zk-covenant-rollup-methodsmethods/nativeBuild harness for guest ELF
zk-covenant-rollup-hosthost/nativeDemo 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 typesPublicInput, 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_std compatible)
  • 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

ComponentEnvironmentTrusted?Verified by
Core types & hashesEverywhereN/A (library)
Guest proof programRISC Zero zkVMYes (proven)ZK verifier on-chain
State verification scriptKaspa nodeYes (consensus)All full nodes
Permission scriptKaspa nodeYes (consensus)All full nodes
Delegate scriptKaspa nodeYes (consensus)All full nodes
Host / operatorOff-chainNoGuest + 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(&current, &current);
    }
    current
}
}

State root type

The state root is simply [u32; 8] — a 32-byte SHA-256 hash stored as 8 words for zkVM alignment efficiency. See core/src/state.rs:93 for the type alias.

Design rationale

Why SHA-256? The RISC Zero zkVM has accelerated SHA-256 support, making it the cheapest hash function inside the guest. All account-state hashing uses SHA-256 for this reason.

Why [u32; 8] instead of [u8; 32]? The zkVM operates on 32-bit words. Using [u32; 8] avoids alignment issues and unnecessary byte shuffling. The bytemuck crate provides zero-copy conversion when byte-level access is needed.

Why 8 levels? The PoC targets a small number of accounts for demonstration. The depth is a constant (SMT_DEPTH) and could be increased for production use.

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

OpcodeNameValuePayload sizeDescription
OP_TRANSFERTransfer080 bytes (header + 72)L2-to-L2 balance transfer
OP_ENTRYEntry140 bytes (header + 32)L1-to-L2 deposit
OP_EXITExit288 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_digesttx_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_digesttx_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 batch
  • prev_seq_commitment — the sequence commitment before this batch
  • covenant_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:

  1. Reads the payload bytes from stdin
  2. Reads the full rest_preimage (length-prefixed) and computes rest_digest = hash(rest_preimage) — the guest never trusts a host-provided digest
  3. Computes payload_digest from the raw payload bytes
  4. Computes tx_id = blake3(payload_digest || rest_digest)
  5. Checks if the tx_id starts with ACTION_TX_ID_PREFIX (0x41)
  6. 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’s rest_preimage is already read at the V1TxData level and passed down.
  • PrevTxV1WitnessData no longer includes prev_tx_id or output_index. These are derived from the current action transaction’s first input outpoint, which is committed via rest_preimagerest_digesttx_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:

  1. Guest parses the current action transaction’s rest_preimage to extract the first input’s outpoint (prev_tx_id, output_index) — this is committed via rest_digesttx_id, so tamper-proof
  2. Host provides PrevTxV1Witness (rest_preimage + payload_digest of the previous transaction)
  3. Guest recomputes the previous tx_id from the witness and asserts it matches the first input’s prev_tx_id — mismatch means the host is cheating (proof fails)
  4. Guest parses the output at the first input’s output_index from the previous tx’s rest_preimage
  5. Guest checks the output SPK is Schnorr P2PK format (34 bytes) — if not, the action is skipped (user error)
  6. 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:

ConditionResponseRationale
Prev tx witness doesn’t hash to first input’s tx_idAssert (proof fails)Host provided fake witness data
SMT proof doesn’t verify against rootAssert (proof fails)Every pubkey has a valid proof (empty leaf by default)
Witness pubkey doesn’t match action sourceAssert (proof fails)Host should always provide matching witness
SPK is not Schnorr P2PKSkip (action rejected)User submitted action with wrong SPK type
SPK pubkey doesn’t match action sourceSkip (action rejected)User made a mistake in the action payload
Insufficient balanceSkip (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:

  1. Debit source — assert SMT proof verifies and witness pubkey matches (host cheating if not), check balance (skip if insufficient), compute intermediate root
  2. 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:

OffsetSizeField
032Bprev_state_hash
3232Bprev_seq_commitment
6432Bnew_state_root
9632Bnew_seq_commitment
12832Bcovenant_id
16032Bpermission_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:

  1. Each successful exit adds perm_leaf_hash(spk, amount) to a StreamingPermTreeBuilder
  2. 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_std builder in core)
    • Guest asserts the built script length matches the host-provided value
    • Guest computes blake2b(redeem_script) → the permission SPK hash
  3. 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, &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.

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:

  1. Start with an estimated length (e.g., 200)
  2. Build the script with that length
  3. If the actual length differs, rebuild with the new length
  4. 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:

  1. prev_state and prev_seq (from alt stack, originally embedded in prefix)
  2. new_state and new_seq (computed during execution)
  3. covenant_id (via OpInputCovenantId introspection)
  4. 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:

  1. Reads OpCovOutCount — if 2, a permission output exists
  2. Extracts the script hash from output 1’s P2SH SPK (bytes 4..36 of to_bytes())
  3. Verifies output 1 is actually P2SH format (reconstructs and compares)
  4. 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:

DomainSuffix bytesScript
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)
}
}
ExitsDepthCapacity
012
1-212
3-424
5-838
9-16416
129-2568256

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(&current, &self.siblings[level]);
            } else {
                current = perm_branch_hash(&self.siblings[level], &current);
            }
        }
        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:

  1. The host provides the expected redeem script length (private input)
  2. Guest computes required_depth(perm_count)
  3. Guest finalizes the streaming builder and pads to depth
  4. Guest builds the entire permission redeem script in no_std (see core/src/permission_script.rs)
  5. Guest asserts the script length matches the host-provided value
  6. Guest computes blake2b(redeem_script)script_hash
  7. 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):

  1. Reconstruct the 42-byte new prefix from new_root and new_uncl
  2. Extract the script body+suffix from the current sig_script (self-introspection)
  3. Concatenate → new redeem script
  4. Hash to P2SH SPK and verify output 1 matches
  5. Verify CovOutCount == 1 and CovOutputIdx(0) == 1 (the single covenant output is at output index 1)

Phase 10: Verify delegate balance

Enforces the delegate input/output balance equation:

  1. input_count <= MAX_DELEGATE_INPUTS + 2
  2. Reconstructs the expected delegate P2SH SPK from covenant_id
  3. Sums amounts from inputs 1..N whose SPK matches the delegate SPK
  4. Guards that input N+1 does NOT have the delegate SPK (prevents overcounting)
  5. Computes expected_change = total_input - deduct >= 0
  6. If expected_change > 0: verifies output[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:

  1. Host (host/src/bridge.rs) — uses ScriptBuilder from kaspa-txscript
  2. Core (core/src/permission_script.rs) — no_std byte-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:

  1. Only the covenant system can spend them
  2. 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:

OffsetSizeContent
0-34BIndex check: OpTxInputIndex Op0 OpGreaterThan OpVerify
4-63BCovenant preamble: Op0 OpInputCovenantId OpData32
7-3832BEmbedded covenant_id
391BOpEqualVerify
40-5112BSuffix check: extract last 2 bytes of input 0’s sig_script, compare with [0x51, 0x75]
521BOpTrue

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 inputBound byAttack if omitted
Block transaction dataOpChainblockSeqCommit on-chainHost could fabricate transactions
Current tx rest_preimagerest_digesttx_id (guest computes)Host could hide inputs/outputs
Previous tx preimageFirst input outpoint (from current tx rest_preimage)Host could claim false output SPKs
Account SMT witnessesRoot hash chain + assertHost could fabricate balances
Permission redeem lengthAssert in guestGuest script wouldn’t match on-chain hash
Action ordering within blockSeq commitment leaf hashOrder 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).

CheckLocationResponseAttack prevented
is_action_tx_id(tx_id)guest/src/block.rsgateNon-action transactions processed as actions
tx_id[0..2] == "AC"core/src/lib.rsgateRandom transactions misclassified (~1/65536 collision)
Action version == ACTION_VERSIONguest/src/tx.rsgateFuture/incompatible action formats
rest_digest == hash(rest_preimage)guest/src/tx.rscomputedHost cannot forge rest_digest (guest computes it)
First input outpoint matches prev_tx witnessguest/src/auth.rsassertHost substitutes fake prev_tx
Prev tx witness hashes to first input’s tx_idguest/src/auth.rsassertHost claims false output SPK/amounts
Witness pubkey matches action sourceguest/src/state.rsassertHost provides wrong witness for action
SMT proof verifies against rootguest/src/state.rsassertFabricated account balances (every pubkey has valid proof)
Source pubkey matches prev tx SPKguest/src/auth.rsskipUser submitted action with wrong SPK
Insufficient balanceguest/src/state.rsskipUser tried to spend more than they have
deduct > 0guest/src/state.rsskipZero-value withdrawal claims
Balance conservation (transfer)guest/src/state.rsenforcedValue creation from nothing
Entry output SPK is P2SH(delegate)core/src/p2sh.rsskipDeposits to unrelated addresses credited
Entry tx input 0 not permission suffixcore/src/prev_tx.rsskipDelegate change output misinterpreted as deposit
Exit SPK length <= EXIT_SPK_MAXcore/src/action.rsenforcedOversized SPK overflows
Seq commitment matches chainOn-chain OpChainblockSeqCommiton-chainFabricated block data
Permission root matches exitsguest/src/main.rsassertIncorrect withdrawal tree committed
Redeem script length matches host hintguest/src/main.rsassertScript hash mismatch

On-chain checks (state verification script)

CheckPhaseAttack prevented
Domain prefix [0x00, 0x75]StartScript type confusion
Prev state embedded in scriptPrefixState rollback or skip
OpChainblockSeqCommitSeq commitFabricated block sequence
Output 0 SPK == P2SH(new redeem)SPK verifyCovenant chain broken
Journal SHA-256 matches ZK proofZK verifyProof for different state transition
Program ID matchesZK verifyProof from different program
Input index == 0GuardScript used at wrong position
CovOutCount == 1 or 2Output branchUnexpected covenant outputs
Output 1 P2SH format (if 2 outputs)Perm verifyNon-P2SH permission output

On-chain checks (permission script)

CheckPhaseAttack prevented
deduct > 0Phase 3Zero-value claims drain UTXO
amount >= deductPhase 3Balance underflow
Output 0 SPK == leaf SPKPhase 4Withdrawal to wrong address
Old leaf hash verifies under rootPhase 6Fabricated Merkle proof
New root correctly computedPhase 7State corruption after claim
Unclaimed decremented correctlyPhase 8Count desync
Continuation SPK matchesPhase 9Permission UTXO chain broken
CovOutCount correctPhase 9Unexpected covenant outputs
output_count <= 4Phase 9Transaction stuffing
input_count <= N+2Phase 10Overcounting delegate inputs
Delegate SPK matches covenantPhase 10Spending unrelated UTXOs as delegates
Input N+1 not delegate SPKPhase 10Collateral miscounted as delegate
total_input >= deductPhase 10Insufficient delegate funds
Delegate change output correctPhase 10Change amount/address incorrect

On-chain checks (delegate/entry script)

CheckStepAttack prevented
Self not at input index 0Step 1Delegate used as primary covenant input
Input 0 covenant_id matchesStep 2Co-spent with wrong covenant
Input 0 suffix [0x51, 0x75]Step 3Co-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_digesttx_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:

  1. 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.

  2. Bridge reserve conservation: The permission script enforces delegate_input_total >= deduct and verifies the change output amount equals total - 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

  1. Builds an initial empty SMT (sparse Merkle tree) state
  2. Constructs a mock chain with L2 action transactions (deposits, transfers, exits)
  3. Generates a STARK (succinct) proof via RISC Zero and verifies it
  4. Simulates on-chain verification of the STARK proof through the covenant script
  5. Generates a Groth16 proof and verifies it
  6. Simulates on-chain verification of the Groth16 proof through the covenant script
  7. 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 stringHashKeyed?ModulePurpose
"SMTLeaf"SHA-256No (prefix)core/src/smt.rsAccount leaf: sha256("SMTLeaf" || pubkey || balance)
"SMTEmpty"SHA-256No (prefix)core/src/smt.rsEmpty account slot sentinel: sha256("SMTEmpty")
"SMTBranch"SHA-256No (prefix)core/src/smt.rsSMT internal node: sha256("SMTBranch" || left || right)
"PermLeaf"SHA-256No (prefix)core/src/permission_tree.rsWithdrawal leaf: sha256("PermLeaf" || spk || amount)
"PermEmpty"SHA-256No (prefix)core/src/permission_tree.rsEmpty withdrawal slot: sha256("PermEmpty")
"PermBranch"SHA-256No (prefix)core/src/permission_tree.rsPermission tree node: sha256("PermBranch" || left || right)
"SeqCommitmentMerkleLeafHash"BLAKE3Yes (key)core/src/seq_commit.rsSeq commitment leaf: blake3_keyed(key, tx_id || version)
"SeqCommitmentMerkleBranchHash"BLAKE3Yes (key)core/src/seq_commit.rsSeq commitment node: blake3_keyed(key, left || right)
"PayloadDigest"BLAKE3Yes (key)core/src/lib.rsV1 tx payload hash
"TransactionRest"BLAKE3Yes (key)core/src/lib.rsV1 tx rest-of-data hash
"TransactionV1Id"BLAKE3Yes (key)core/src/lib.rsV1 tx_id: blake3_keyed(key, payload_digest || rest_digest)
"TransactionID"BLAKE2b-256Yes (.key())core/src/lib.rsV0 tx_id: blake2b_keyed(key, full_preimage)

Non-hash domain tags

TagValueTypeModulePurpose
ACTION_TX_ID_PREFIX0x41 ('A')Byte prefixcore/src/lib.rsFirst byte of tx_id identifies action transactions
State verification suffix[0x00, 0x75]Opcode pairhost/src/bridge.rs[OP_0, OP_DROP] tags state verification scripts
Permission suffix[0x51, 0x75]Opcode pairhost/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:

  1. The pre-computed payload_digest (32 bytes) — the guest does not need the full payload
  2. The rest_preimage — the guest hashes this to get rest_digest and parses it for output data

Why the split matters

The V1 split design benefits the ZK guest:

  1. 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.

  2. Output and input extraction: The rest_preimage contains both outputs and inputs. The guest extracts output SPKs for deposit verification and first-input outpoints for source authorization. Since the guest reads the full rest_preimage and computes rest_digest itself (never trusting a host-provided digest), all parsed data is tamper-proof.

  3. 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:

VersionWitness typeHost providesGuest computes
V0PrevTxV0WitnessFull preimageblake2b(preimage)
V1PrevTxV1Witnessrest_preimage + payload_digestblake3(payload_digest || blake3(rest))

Kaspa compatibility

Both implementations are tested against Kaspa’s canonical hashers:

  • tx_id_v0 matches kaspa_hashes::TransactionID
  • payload_digest matches kaspa_hashes::PayloadDigest
  • rest_digest matches kaspa_hashes::TransactionRest
  • tx_id_v1 matches kaspa_hashes::TransactionV1Id

See core/src/lib.rs tests for the compatibility assertions.