In this article we will build a cryptographic virtual machine (VM) in Rust, inspired by the TinyRAM model, using a reduced instruction set to keep things straightforward and efficient.
This VM will be able to execute computations while generating zero-knowledge proofs (ZKPs) at the same time, natively. It’s a practical tool for anyone working on cryptographic systems or exploring secure and verifiable processing.
Let’s get started!
Architecture
The project is divided into the following modules:
Core Virtual Machine (vm.rs)
- Implements the VM execution, including the instruction set and state capture.
- Handles runtime errors gracefully.
Zero-Knowledge Proof Module (zk_proof.rs)
- Encapsulates proof generation and verification logic using Groth16.
- Abstracts cryptographic operations to ensure modularity.
Utilities (utils.rs)
- Provides helper functions, including file management and field conversion utilities.
Program Loader (program_loader.rs)
- Reads and parses assembly-like programs for execution on the VM.
Circuit Implementation (execution_circuit.rs)
- Models the VM’s state and operations as a constraint system for ZKP purposes.
Workflow
The Provable VM operates through a streamlined yet comprehensive workflow designed to enable program execution, traceability, and zero-knowledge proof generation. This approach ensures transparency, security, and privacy while maintaining computational integrity.
Program Input
The workflow begins with a program written in a simple, assembly-like language specifically designed for the Provable VM. This program comprises a series of instructions, such as PUSH, ADD, and STORE, which describe the computational operations to be performed. Each line of the program corresponds to an Instruction struct that encapsulates the opcode and its optional operand. The program is parsed and loaded into the VM, ready for execution.
Execution and State Capture
Once the program is loaded, the Provable VM begins executing instructions sequentially, starting from the first line. The VM interprets each instruction, updates its internal components — such as the program counter (pc), stack, and heap—and performs the required operation. At every step, the VM captures its current state, recording the pc, stack content, heap data, and flags into a ProvableState object. These snapshots form a detailed trace of the execution, ensuring that every intermediate computation step is recorded and traceable.
Trace Commitment Generation
The captured states during execution collectively form an execution trace, which represents the complete operational history of the program. This trace is then hashed using a cryptographic hash function, such as SHA-256, to produce a trace commitment. The trace commitment serves as a compact representation of the entire execution trace, enabling external entities to verify the validity of the trace without requiring access to the underlying data. This commitment enhances privacy and efficiency in trace validation.
Zero-Knowledge Proof (ZKP) Generation
With the execution trace and its commitment generated, the next step involves the creation of a zero-knowledge succinct non-interactive argument of knowledge (zkSNARK) proof. This proof asserts that the execution trace was generated by faithfully running the given program. Importantly, the proof guarantees that no sensitive information — such as the program’s internal states or specific values in the stack or heap — is revealed. The zkSNARK proof is generated using the cryptographic commitment, program, and captured states as inputs, ensuring that the entire computational process is represented without compromising privacy.
Proof Verification
The final step in the workflow is proof verification. A verifier — typically an external party — uses the zkSNARK proof, program code, and trace commitment to validate that the execution trace aligns with the program’s prescribed behavior. If the proof is valid, the verifier can confidently conclude that the program was executed correctly, even without seeing the actual trace or internal states. This verification process ensures the integrity of the computation and establishes trust between parties.
Dependencies and Setup
Project Dependencies
The Provable VM relies on the following crates to provide cryptographic operations, serialization, and logging:
ark-ff
Enables finite field arithmetic, a fundamental component for elliptic curve and zk-SNARK computations.ark-bls12-381
Implements the BLS12-381 elliptic curve, which is crucial for pairing-based cryptography used in zk-SNARKs.ark-groth16
Provides the Groth16 zk-SNARK implementation for generating and verifying zero-knowledge proofs.ark-relations
Defines relation and constraint systems for zk-SNARK circuits, supporting the VM's cryptographic proofs of execution.ark-snark
Supplies standardized interfaces for zk-SNARK systems, integrating Groth16 with other Arkworks components.ark-serialize
Handles serialization and deserialization of cryptographic objects like proofs and keys, enabling persistence.ark-std
Offers utility traits and functions used across Arkworks crates.bincode
An efficient binary serialization library used for encoding VM state data into cryptographic trace commitments.serde
Facilitates serialization and deserialization of VM states and instructions in JSON format for debugging and sharing.sha2
Implements the SHA-256 hash function for secure trace commitments, ensuring trace immutability.hex
Provides encoding and decoding of hexadecimal values, used for presenting trace commitments in a readable format.rand_chacha
Supplies the ChaCha20 random number generator, ensuring cryptographically secure randomness for proof generation.rand_core
Offers core traits and utilities for random number generation, supporting cryptographic operations.tracing-subscriber
Adds advanced logging and structured debugging capabilities for tracking VM operations and circuit constraints.tracing
Enables structured and scoped logging for program execution and zk-SNARK constraint generation.
Rust Implementation
While this post highlights key snippets of the implementation, the entire codebase is available on Github. If you’re curious about the finer details or want to dive deeper into the code, be sure to check out the repository.
To make this easier to follow, I’m focusing on some of the most important parts of the implementation that power the VM and its unique capabilities. These snippets will give you a good idea of how the VM works and how the components interact. Let’s explore each major piece of the system, from how instructions are executed to how zero-knowledge proofs are generated. For the full implementation, with all the nuances and helper functions, visit the provided Github link.
Virtual Machine Execution
The execute_instruction function manages the execution of individual instructions in the virtual machine. Each opcode defines a unique behavior. For instance, the PUSH opcode adds a value to the stack, while ADD pops the top two values from the stack, computes their sum, and pushes the result back. The function also updates the program counter after executing an instruction. When encountering the HALT opcode, the function stops execution by returning false.
fn execute_instruction(&mut self, instruction: &Instruction) -> bool {
match instruction.opcode {
Opcode::PUSH => {
if let Some(value) = instruction.operand {
self.stack.push(value);
}
}
Opcode::ADD => {
if let (Some(a), Some(b)) = (self.stack.pop(), self.stack.pop()) {
self.stack.push(a + b);
} else {
panic!("ADD requires two stack elements.");
}
}
Opcode::HALT => return false,
_ => unimplemented!(),
}
self.pc += 1;
true
}
This modular design enables new opcodes to be added with minimal changes, making the virtual machine extensible. As the program executes, the state of the virtual machine evolves dynamically, capturing a faithful simulation of computation.
State Capture and Trace Commitment
To ensure that program execution can be audited and verified, the Provable VM captures its state at each step using the capture_state function. The captured states include the program counter, stack, heap, and flags, forming a complete snapshot of the VM's state. These states are then used to generate a cryptographic trace commitment.
The generate_trace_commitment function hashes the entire execution trace into a single cryptographic commitment. This hash acts as a secure fingerprint of the execution trace, ensuring its integrity and immutability.
fn capture_state(&self) -> ProvableState {
ProvableState {
pc: self.pc,
stack: self.stack.clone(),
heap: self.heap.clone(),
flags: self.flags,
}
}
fn generate_trace_commitment(&self, trace_file: &str) -> Result<Vec<u8>, String> {
let mut hasher = Sha256::new();
for state in &self.trace {
let serialized = bincode::serialize(state).map_err(|e| e.to_string())?;
hasher.update(serialized);
}
let hash = hasher.finalize().to_vec();
File::create(trace_file)
.and_then(|mut file| writeln!(file, "{}", hex::encode(&hash)))
.map_err(|e| e.to_string())?;
Ok(hash)
}
The commitment is stored in a file (trace_file) for later use, such as zero-knowledge proof verification. The commitment enables users to verify the trace without exposing its details, preserving privacy while maintaining trust.


