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

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.