Action Types
Actions are the state-transition primitives of the rollup. Each action is encoded in a Kaspa transaction payload and identified by its operation code.
Action identification
A transaction is recognized as an action when its tx_id starts with the two-byte prefix 0x41 0x43 ("AC"):
#![allow(unused)]
fn main() {
/// Two-byte prefix for action transaction IDs (0x41 0x43 = "AC").
/// Using two bytes reduces accidental collisions from ~1/256 to ~1/65536.
pub const ACTION_TX_ID_PREFIX: &[u8; 2] = b"AC";
/// Check if a tx_id represents an action transaction (first two bytes match prefix)
#[inline]
pub fn is_action_tx_id(tx_id: &[u32; 8]) -> bool {
tx_id[0].to_le_bytes()[..2] == *ACTION_TX_ID_PREFIX
}
}
The host mines a nonce in the ActionHeader until the resulting tx_id starts with this prefix. Using two bytes means roughly 1 in 65,536 nonces will match — still fast enough for testing, while reducing accidental collisions from random testnet transactions (compared to 1 in 256 with a single-byte prefix).
Action header
Every action payload starts with an 8-byte header:
#![allow(unused)]
fn main() {
/// Action header (common to all actions)
///
/// Layout:
/// - version: u16 (2 bytes)
/// - operation: u16 (2 bytes) - determines action type and data size
/// - nonce: u32 (4 bytes) - for tx_id matching
///
/// Total: 8 bytes = 2 words
///
/// After reading the header, read action-specific data based on operation.
#[derive(Clone, Copy, Debug, Eq, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
#[repr(C)]
pub struct ActionHeader {
/// Format version
pub version: u16,
/// Operation type - determines what data follows
pub operation: u16,
/// Nonce for tx_id matching
pub nonce: u32,
}
}
The operation field determines what data follows and how much to read.
Three operation codes
| Opcode | Name | Value | Payload size | Description |
|---|---|---|---|---|
OP_TRANSFER | Transfer | 0 | 80 bytes (header + 72) | L2-to-L2 balance transfer |
OP_ENTRY | Entry | 1 | 40 bytes (header + 32) | L1-to-L2 deposit |
OP_EXIT | Exit | 2 | 88 bytes (header + 80) | L2-to-L1 withdrawal |
Transfer (opcode 0)
Moves funds between two L2 accounts.
#![allow(unused)]
fn main() {
/// Transfer action data (follows ActionHeader when operation == OP_TRANSFER)
///
/// Layout:
/// - source: [u32; 8] (32 bytes) - sender pubkey
/// - destination: [u32; 8] (32 bytes) - recipient pubkey
/// - amount: u64 (8 bytes)
///
/// Total: 72 bytes = 18 words
#[derive(Clone, Copy, Debug, Eq, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
#[repr(C)]
pub struct TransferAction {
/// Sender pubkey (committed to tx_id via payload_digest)
pub source: [u32; 8],
/// Recipient pubkey
pub destination: [u32; 8],
/// Amount to transfer
pub amount: u64,
}
}
flowchart LR
subgraph Payload
HDR["Header<br/>op=0, nonce"]
SRC["source<br/>32B pubkey"]
DST["destination<br/>32B pubkey"]
AMT["amount<br/>8B u64"]
end
HDR --> SRC --> DST --> AMT
subgraph Guest["Guest verification"]
AUTH["Verify source<br/>owns prev tx output"]
DEBIT["Debit source<br/>balance -= amount"]
CREDIT["Credit destination<br/>balance += amount"]
end
SRC -.-> AUTH
AMT -.-> DEBIT
AMT -.-> CREDIT
Authorization: The guest verifies the source pubkey by checking the previous transaction output spent by the current action tx. The guest extracts the first input’s outpoint (prev_tx_id, output_index) from the current transaction’s rest_preimage (committed via rest_digest → tx_id). The host provides a PrevTxV1Witness containing the previous tx’s rest_preimage and payload_digest. The guest asserts that the witness hashes to the committed prev_tx_id, then extracts the output SPK and confirms it is a Schnorr P2PK matching source.
State update: The guest verifies the source’s SMT proof against the current root, debits the balance, computes an intermediate root, then verifies the destination’s proof against that intermediate root and credits the balance.
Entry / Deposit (opcode 1)
Credits an L2 account from an L1 deposit.
#![allow(unused)]
fn main() {
/// Entry (deposit) action data (follows ActionHeader when operation == OP_ENTRY)
///
/// Layout:
/// - destination: [u32; 8] (32 bytes) - recipient pubkey on L2
///
/// Total: 32 bytes = 8 words
///
/// The deposit amount is NOT in the payload — it comes from the tx output value,
/// verified by the guest using the rest_preimage.
/// Destination can be zeros (no validation on zero pubkey).
#[derive(Clone, Copy, Debug, Eq, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
#[repr(C)]
pub struct EntryAction {
/// Recipient pubkey on L2
pub destination: [u32; 8],
}
}
flowchart LR
subgraph Payload
HDR["Header<br/>op=1, nonce"]
DST["destination<br/>32B pubkey"]
end
HDR --> DST
subgraph Guest["Guest verification"]
SUFFIX["Reject if input 0<br/>has permission suffix"]
OUTPUT["Parse output 0<br/>from rest_preimage"]
SPK["Verify output SPK<br/>is P2SH(delegate)"]
CREDIT["Credit destination<br/>balance += value"]
end
DST -.-> CREDIT
Key design: The deposit amount is not in the payload. It comes from the transaction’s first output value, parsed from the rest_preimage. The rest_preimage is read at the V1TxData level (shared by all action types) and its hash is verified by the guest as part of tx_id computation. This prevents the host from inflating deposit amounts — the value is cryptographically committed via rest_digest → tx_id.
SPK verification: The guest verifies that output 0’s SPK is P2SH(delegate_script(covenant_id)). This ensures the deposited funds are actually locked in the covenant, not sent to an arbitrary address.
Permission suffix guard: The guest rejects entry actions whose transaction’s first input has the permission domain suffix ([0x51, 0x75]). This prevents delegate change outputs (from withdrawal transactions) from being misinterpreted as new deposits.
Exit / Withdrawal (opcode 2)
Debits an L2 account and creates a withdrawal claim on L1.
#![allow(unused)]
fn main() {
/// Exit (withdrawal) action data (follows ActionHeader when operation == OP_EXIT)
///
/// Layout:
/// - source: [u32; 8] (32 bytes) - sender L2 pubkey (authorized via prev tx output)
/// - destination_spk: [u32; 10] (40 bytes) - L1 SPK in first 35 bytes, last 5 zero-padded
/// - amount: u64 (8 bytes) - amount to withdraw
///
/// Total: 80 bytes = 20 words
#[derive(Clone, Copy, Debug, Eq, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
#[repr(C)]
pub struct ExitAction {
/// Sender L2 pubkey (authorized via prev tx output, same as TransferAction)
pub source: [u32; 8],
/// Destination L1 SPK stored as words. First 35 bytes = SPK, last 5 = zero padding.
pub destination_spk: [u32; EXIT_SPK_WORDS],
/// Amount to withdraw
pub amount: u64,
}
}
flowchart LR
subgraph Payload
HDR["Header<br/>op=2, nonce"]
SRC["source<br/>32B pubkey"]
SPK["destination_spk<br/>40B (35B + pad)"]
AMT["amount<br/>8B u64"]
end
HDR --> SRC --> SPK --> AMT
subgraph Guest["Guest verification"]
AUTH["Verify source<br/>owns prev tx output"]
DEBIT["Debit source<br/>balance -= amount"]
PERM["Add permission leaf<br/>hash(spk, amount)"]
end
SRC -.-> AUTH
AMT -.-> DEBIT
SPK -.-> PERM
AMT -.-> PERM
SPK encoding: The destination_spk field holds up to 35 bytes of L1 script public key, padded to 40 bytes (10 words) for alignment. The actual length is inferred from the first byte: 0x20 (OP_DATA_32) means 34-byte Schnorr P2PK; anything else means 35 bytes (ECDSA P2PK or P2SH).
Permission leaf: On successful debit, the guest adds perm_leaf_hash(spk, amount) to the streaming permission tree builder. After all blocks are processed, this tree’s root is committed to the journal, enabling on-chain withdrawal claims.
The Action enum
All three types are unified under a single enum for dispatch:
#![allow(unused)]
fn main() {
/// Parsed action types
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Action {
Transfer(TransferAction),
Entry(EntryAction),
Exit(ExitAction),
}
}
The source() method returns None for entry actions (deposits come from L1, not an L2 account). This distinction drives the authorization logic in the guest — entries skip source verification entirely.