Guest Proof Pipeline
The guest program is the heart of the rollup. It runs inside the RISC Zero zkVM, reads blocks and witness data from the host, processes every transaction, updates the state root, and writes a journal that the on-chain script can verify.
Overview
flowchart TD
INPUT["Read PublicInput<br/>(prev_state, prev_seq, covenant_id)"]
BLOCKS["For each block"]
TXS["For each transaction"]
CLASSIFY["Classify: V0 or V1?"]
V0["V0: read tx_id directly"]
V1["V1: read payload + rest_preimage<br/>compute tx_id"]
ACTION["Is action?<br/>(prefix + valid header)"]
DISPATCH["Dispatch by opcode"]
TRANSFER["Transfer"]
ENTRY["Entry"]
EXIT["Exit"]
MERKLE["Add seq_commitment_leaf<br/>to block merkle"]
BLOCK_ROOT["Finalize block merkle root"]
SEQ["Update seq_commitment"]
PERM["Build permission tree<br/>(if exits occurred)"]
JOURNAL["Write journal"]
INPUT --> BLOCKS
BLOCKS --> TXS
TXS --> CLASSIFY
CLASSIFY -->|V0| V0
CLASSIFY -->|V1| V1
V1 --> ACTION
ACTION -->|Yes| DISPATCH
ACTION -->|No| MERKLE
DISPATCH --> TRANSFER --> MERKLE
DISPATCH --> ENTRY --> MERKLE
DISPATCH --> EXIT --> MERKLE
V0 --> MERKLE
MERKLE --> TXS
TXS -->|block done| BLOCK_ROOT
BLOCK_ROOT --> SEQ
SEQ --> BLOCKS
BLOCKS -->|all done| PERM
PERM --> JOURNAL
PublicInput
The guest begins by reading PublicInput — three 32-byte hashes that anchor the proof to the chain:
prev_state_hash— the SMT root before this batchprev_seq_commitment— the sequence commitment before this batchcovenant_id— identifies this specific covenant instance
These values are written to the journal so the on-chain script can verify they match the previous UTXO.
Block processing
#![allow(unused)]
fn main() {
/// Process all transactions in a block, updating state and building merkle tree
pub fn process_block(
stdin: &mut impl WordRead,
state_root: &mut [u32; 8],
covenant_id: &[u32; 8],
perm_builder: &mut StreamingPermTreeBuilder,
) -> [u32; 8] {
let tx_count = input::read_u32(stdin);
let mut merkle_builder = StreamingMerkleBuilder::new();
for _ in 0..tx_count {
let (tx_id, version) = process_transaction(stdin, state_root, covenant_id, perm_builder);
let leaf = seq_commitment_leaf(&tx_id, version);
merkle_builder.add_leaf(leaf);
}
merkle_builder.finalize()
}
}
Each block contains a list of transactions. The guest processes them sequentially, building a streaming Merkle tree of seq_commitment_leaf(tx_id, version) values. The finalized block root is then combined with the running seq_commitment via calc_accepted_id_merkle_root.
Transaction classification
#![allow(unused)]
fn main() {
/// Read V1 transaction data and compute tx_id
///
/// The guest determines whether this is an action transaction based on:
/// 1. tx_id starts with ACTION_TX_ID_PREFIX
/// 2. header version is valid and operation is known
/// 3. action data is valid (e.g., non-zero amount for transfer)
///
/// This is computed from the cryptographic data, NOT from a host flag.
///
/// Payload handling:
/// - Host sends payload_len in BYTES (not words)
/// - We read ceil(payload_len/4) words (padded to word boundary)
/// - Compute payload_digest from actual bytes (trimmed to payload_len)
/// - Only parse as action if payload is 4-byte aligned (required for our format)
///
/// Rest preimage:
/// - Host sends the full rest_preimage (length-prefixed).
/// - Guest computes rest_digest = hash(rest_preimage) — never trusts host-provided digest.
/// - rest_preimage is stored for use by action processing (input verification, output parsing).
pub fn read_v1_tx_data(stdin: &mut impl WordRead) -> V1TxData {
// Read payload length in BYTES
let payload_byte_len = input::read_u32(stdin) as usize;
// Calculate words needed (round up to word boundary)
let payload_word_len = payload_byte_len.div_ceil(4);
// Read as words (guaranteed 4-byte aligned)
let mut payload_words = vec![0u32; payload_word_len];
stdin.read_words(&mut payload_words).unwrap();
// View as bytes for payload_digest
let payload_bytes: &[u8] = bytemuck::cast_slice(&payload_words);
let payload_bytes = &payload_bytes[..payload_byte_len]; // trim padding
// Read rest_preimage (length-prefixed) and compute rest_digest
let rest_preimage = input::read_aligned_bytes(stdin);
let rest_digest = rest_digest_bytes(rest_preimage.as_bytes());
// Compute tx_id from payload bytes and rest_digest
let pd = payload_digest_bytes(payload_bytes);
let tx_id = tx_id_v1(&pd, &rest_digest);
// Only parse action if payload is 4-byte aligned (required for our action format)
let action = if payload_byte_len.is_multiple_of(4) {
parse_action(&payload_words)
} else {
None // Unaligned payload - not a valid action format
};
// Only valid if tx_id has action prefix AND action parsed successfully AND is valid
let valid_action = if is_action_tx_id(&tx_id) { action.filter(|a| a.is_valid()) } else { None };
V1TxData { tx_id, action: valid_action, rest_preimage }
}
}
For V1 transactions, the guest:
- Reads the payload bytes from stdin
- Reads the full
rest_preimage(length-prefixed) and computesrest_digest = hash(rest_preimage)— the guest never trusts a host-provided digest - Computes
payload_digestfrom the raw payload bytes - Computes
tx_id = blake3(payload_digest || rest_digest) - Checks if the
tx_idstarts withACTION_TX_ID_PREFIX(0x41) - If so, parses the payload as an action header + data
The rest_preimage is stored in V1TxData and passed to action handlers. For transfer/exit actions, it is used to extract the first input’s outpoint (proving which UTXO the transaction actually spends). For entry actions, it is used to parse the deposit output.
The action is only considered valid if the prefix matches and the header version and operation are recognized and the action-specific validity check passes (e.g., non-zero amount).
Action parsing
#![allow(unused)]
fn main() {
/// Parse action from payload words
fn parse_action(payload: &[u32]) -> Option<Action> {
let (header_words, rest) = payload.split_first_chunk::<{ ActionHeader::WORDS }>()?;
let header = ActionHeader::from_words_ref(header_words);
if !header.is_valid_version() {
return None;
}
// Parse action data based on operation
match header.operation {
OP_TRANSFER => {
let transfer_words = rest.first_chunk()?;
let transfer = TransferAction::from_words(*transfer_words);
Some(Action::Transfer(transfer))
}
OP_ENTRY => {
let entry_words = rest.first_chunk()?;
let entry = EntryAction::from_words(*entry_words);
Some(Action::Entry(entry))
}
OP_EXIT => {
let exit_words = rest.first_chunk()?;
let exit = ExitAction::from_words(*exit_words);
Some(Action::Exit(exit))
}
_ => None, // Unknown operation
}
}
}
Action dispatch
#![allow(unused)]
fn main() {
/// Process a valid action transaction
///
/// Called only when guest has cryptographically determined this is a valid action.
/// Host must provide witness data for verification.
///
/// The rest_preimage of the current transaction is used to:
/// - Extract first input outpoint (for transfer/exit source verification)
/// - Parse output data (for entry deposit amount)
fn process_action(
stdin: &mut impl WordRead,
state_root: &mut [u32; 8],
action: Action,
rest_preimage: &AlignedBytes,
covenant_id: &[u32; 8],
perm_builder: &mut StreamingPermTreeBuilder,
) {
match action {
Action::Transfer(transfer) => process_transfer(stdin, state_root, transfer, rest_preimage),
Action::Entry(entry) => process_entry(stdin, state_root, entry, rest_preimage, covenant_id),
Action::Exit(exit) => process_exit(stdin, state_root, exit, rest_preimage, perm_builder),
}
}
}
Witness structures
Each action type requires different witness data from the host:
#![allow(unused)]
fn main() {
/// Witness data for an entry (deposit) action.
///
/// Entry deposits don't need source authorization (no source account).
/// The rest_preimage (for extracting deposit amount from output 0) is now
/// provided via V1TxData — no longer part of this witness.
pub struct EntryWitness {
/// Destination account SMT proof (for crediting the deposit)
pub dest: AccountWitness,
}
impl EntryWitness {
pub fn read_from_stdin(stdin: &mut impl WordRead) -> Self {
Self { dest: input::read_account_witness(stdin) }
}
}
}
Key simplifications:
- Entry witness no longer includes
rest_preimage. The current transaction’srest_preimageis already read at theV1TxDatalevel and passed down. - PrevTxV1WitnessData no longer includes
prev_tx_idoroutput_index. These are derived from the current action transaction’s first input outpoint, which is committed viarest_preimage→rest_digest→tx_id. This prevents the host from substituting a fake previous transaction.
Conditional witness reading
For transfer and exit actions, the guest reads witness data conditionally based on the source account’s balance:
Host writes: Guest reads:
─────────── ───────────
source AccountWitness source AccountWitness
(always) verify SMT proof
check balance >= amount?
├─ NO → return (nothing more to read)
└─ YES ↓
PrevTxV1Witness PrevTxV1Witness
(if balance sufficient) verify auth
├─ FAIL → return (nothing more to read)
└─ OK ↓
dest AccountWitness dest AccountWitness [transfer only]
(if balance sufficient) verify SMT proof
update state_root
This conditional reading reduces the witness data the host must provide for transactions that fail the balance check. Unknown accounts are represented as empty leaves in the SMT — the guest verifies the empty-leaf proof against the current root, confirms the account has zero balance, and skips the action without needing auth or destination witnesses.
#![allow(unused)]
fn main() {
/// Verify the source account and compute the intermediate root after debit.
///
/// Handles both existing accounts and empty leaves (unknown accounts).
/// Returns `None` when the balance is insufficient (always true for empty leaves),
/// signalling the caller to skip the rest of the action (no auth/dest reads).
///
/// Asserts (host cheating — proof fails):
/// - SMT proof doesn't verify against root
/// - existing account: witness pubkey doesn't match source
///
/// Skips (user error):
/// - insufficient balance (including empty leaf with balance=0)
pub fn verify_and_debit_source(
source: &[u32; 8],
source_witness: &AccountWitness,
amount: u64,
current_root: &[u32; 8],
) -> Option<[u32; 8]> {
let key = key_to_index(source);
let is_empty = source_witness.balance == 0 && source_witness.pubkey == [0u32; 8];
if is_empty {
// Empty leaf — the source key has never been inserted.
let empty_leaf = empty_leaf_hash();
assert!(
source_witness.proof.verify(current_root, key, &empty_leaf),
"host cheating: source empty slot SMT proof invalid"
);
return None; // balance=0 always insufficient
}
// Existing account — witness pubkey must match source.
assert_eq!(source_witness.pubkey, *source, "host cheating: source witness pubkey mismatch");
let leaf = leaf_hash(source, source_witness.balance);
assert!(source_witness.proof.verify(current_root, key, &leaf), "host cheating: source SMT proof invalid");
if source_witness.balance < amount {
return None;
}
let new_balance = source_witness.balance - amount;
let new_leaf = leaf_hash(source, new_balance);
Some(source_witness.proof.compute_root(&new_leaf, key))
}
}
Source authorization
For transfers and exits, the guest verifies that the action’s source pubkey matches the public key in a previous transaction output:
#![allow(unused)]
fn main() {
/// Verify source authorization for transfer and exit actions.
///
/// Takes the first input's prev_tx_id from the current action transaction's
/// rest_preimage (committed via rest_digest → tx_id). This ensures the host
/// cannot substitute a fake prev_tx — the prev_tx_id is derived from committed data.
///
/// Asserts (host cheating, proof fails):
/// - prev_tx witness doesn't hash to the committed prev_tx_id
///
/// Skips (user error, action rejected but tx_id still committed):
/// - SPK is not Schnorr P2PK
/// - SPK pubkey doesn't match action source
///
/// Returns the verified source pubkey if authorization succeeds.
pub fn verify_source(source: &[u32; 8], prev_tx: &PrevTxV1WitnessData, first_input_prev_tx_id: &[u32; 8]) -> Option<[u32; 8]> {
// Step 1: Compute prev_tx_id from the witness preimage and ASSERT it matches
// the first input outpoint from the current tx. If mismatch, host is cheating.
let wrapped = PrevTxWitness::V1(prev_tx.witness.clone());
let computed_tx_id = wrapped.compute_tx_id();
assert_eq!(computed_tx_id, *first_input_prev_tx_id, "host cheating: prev_tx witness does not match first input outpoint");
// Step 2: Extract output from the verified prev_tx
let output_data = wrapped.extract_output()?;
// Step 3: Get SPK as Schnorr P2PK (34 bytes). Rejects P2SH and ECDSA.
let verified_spk = output_data.spk_as_p2pk()?;
// Step 4: Extract 32-byte pubkey from the verified SPK
let spk_pubkey = extract_pubkey_from_spk(&verified_spk)?;
// Step 5: Verify action source matches the SPK's pubkey (skip if user error)
if spk_pubkey != *source {
return None;
}
Some(spk_pubkey)
}
}
The verification chain is:
- Guest parses the current action transaction’s
rest_preimageto extract the first input’s outpoint(prev_tx_id, output_index)— this is committed viarest_digest→tx_id, so tamper-proof - Host provides
PrevTxV1Witness(rest_preimage + payload_digest of the previous transaction) - Guest recomputes the previous
tx_idfrom the witness and asserts it matches the first input’sprev_tx_id— mismatch means the host is cheating (proof fails) - Guest parses the output at the first input’s
output_indexfrom the previous tx’srest_preimage - Guest checks the output SPK is Schnorr P2PK format (34 bytes) — if not, the action is skipped (user error)
- Guest extracts the 32-byte pubkey and compares with
action.source— mismatch is a skip (user error)
Only Schnorr P2PK sources are accepted — ECDSA and P2SH sources are rejected.
Assert vs skip
The guest distinguishes between host cheating and user error:
| Condition | Response | Rationale |
|---|---|---|
| Prev tx witness doesn’t hash to first input’s tx_id | Assert (proof fails) | Host provided fake witness data |
| SMT proof doesn’t verify against root | Assert (proof fails) | Every pubkey has a valid proof (empty leaf by default) |
| Witness pubkey doesn’t match action source | Assert (proof fails) | Host should always provide matching witness |
| SPK is not Schnorr P2PK | Skip (action rejected) | User submitted action with wrong SPK type |
| SPK pubkey doesn’t match action source | Skip (action rejected) | User made a mistake in the action payload |
| Insufficient balance | Skip (action rejected) | User tried to spend more than they have |
State updates
#![allow(unused)]
fn main() {
}
#![allow(unused)]
fn main() {
/// Verify destination account and compute new root after credit.
///
/// Used by both transfers and entries. For new accounts, the witness has
/// pubkey=[0;8] and balance=0, and the SMT slot must be empty.
///
/// Asserts (host cheating):
/// - existing account: witness pubkey doesn't match destination
/// - SMT proof doesn't verify (empty slot or existing account)
pub fn verify_and_update_dest(
destination: &[u32; 8],
dest_witness: &AccountWitness,
amount: u64,
intermediate_root: &[u32; 8],
) -> Option<[u32; 8]> {
let dest_key = key_to_index(destination);
let is_new_account = dest_witness.balance == 0 && dest_witness.pubkey == [0u32; 8];
if is_new_account {
// Assert: empty slot proof must verify (host cheating if not)
let empty_leaf = empty_leaf_hash();
assert!(
dest_witness.proof.verify(intermediate_root, dest_key, &empty_leaf),
"host cheating: dest empty slot SMT proof invalid"
);
} else {
// Assert: witness pubkey must match destination (host cheating if not)
assert_eq!(dest_witness.pubkey, *destination, "host cheating: dest witness pubkey mismatch");
// Assert: existing account proof must verify (host cheating if not)
let dest_leaf = leaf_hash(&dest_witness.pubkey, dest_witness.balance);
assert!(
dest_witness.proof.verify(intermediate_root, dest_key, &dest_leaf),
"host cheating: dest existing account SMT proof invalid"
);
}
// Compute final root after credit
let new_balance = dest_witness.balance + amount;
let new_leaf = leaf_hash(destination, new_balance);
Some(dest_witness.proof.compute_root(&new_leaf, dest_key))
}
}
For transfers, the state update is two-phase:
- Debit source — assert SMT proof verifies and witness pubkey matches (host cheating if not), check balance (skip if insufficient), compute intermediate root
- Credit destination — assert SMT proof verifies against intermediate root (host cheating if not), compute final root
For entries, only the credit phase runs (no source debit).
For exits, only the debit phase runs, and a permission leaf is added.
All SMT proof verifications use assert! rather than returning None, because every pubkey has a valid proof in the sparse Merkle tree (empty leaf by default). If the host provides an invalid proof, it is provably cheating.
Journal output
#![allow(unused)]
fn main() {
/// Write the proof output to the journal.
///
/// Journal layout:
/// Base (160 bytes = 40 words):
/// prev_state_hash(32) | prev_seq_commitment(32) | new_state(32) | new_seq(32) | covenant_id(32)
/// With permission (192 bytes = 48 words):
/// ... base ... | permission_spk_hash(32)
#[inline]
pub fn write_output(
public_input: &PublicInput,
final_state_root: &[u32; 8],
final_seq_commitment: &[u32; 8],
permission_spk_hash: Option<&[u32; 8]>,
) {
let mut journal = env::journal();
// Write prev_state_hash and prev_seq_commitment individually
// (covenant_id is written at the end, not adjacent to them)
journal.write_words(&public_input.prev_state_hash).unwrap();
journal.write_words(&public_input.prev_seq_commitment).unwrap();
// Write new state root
journal.write_words(final_state_root).unwrap();
// Write final sequence commitment
journal.write_words(final_seq_commitment).unwrap();
// Write covenant_id
journal.write_words(&public_input.covenant_id).unwrap();
// Write permission SPK hash when exits are present
if let Some(hash_words) = permission_spk_hash {
journal.write_words(hash_words).unwrap();
}
}
}
The journal is the proof’s public output — the only data the on-chain script can see. Its layout:
| Offset | Size | Field |
|---|---|---|
| 0 | 32B | prev_state_hash |
| 32 | 32B | prev_seq_commitment |
| 64 | 32B | new_state_root |
| 96 | 32B | new_seq_commitment |
| 128 | 32B | covenant_id |
| 160 | 32B | permission_spk_hash (optional) |
Base journal: 160 bytes (40 words) — always present.
Extended journal: 192 bytes (48 words) — when exits occurred. The extra 32 bytes contain the blake2b hash of the permission redeem script’s P2SH SPK.
Permission tree construction
When exit actions occur, the guest builds a permission tree:
- Each successful exit adds
perm_leaf_hash(spk, amount)to aStreamingPermTreeBuilder - After all blocks, if any exits occurred:
- The host provides the expected redeem script length
- Guest computes the tree root with
pad_to_depth - Guest builds the permission redeem script bytes (using the
no_stdbuilder in core) - Guest asserts the built script length matches the host-provided value
- Guest computes
blake2b(redeem_script)→ the permission SPK hash
- This hash is appended to the journal
The on-chain state verification script uses the journal’s permission SPK hash to verify that the second covenant output (if present) pays to the correct permission script.