Build a complete verifiable program from scratch: define a state schema, write transition logic in Rust, execute off-chain, generate a ZK proof, and submit to L1 for verification.
This tutorial follows the vProgs execution model: off-chain compute, on-chain verification.
What You Will Build
A simple counter vProg that maintains a global counter. Users can increment the counter by any positive amount. The state transition is executed off-chain and verified on-chain via a ZK proof.
Prerequisites
- Completed Dev Environment Setup
- vProgs repo cloned and built
- Basic Rust knowledge
Step 1: Understand the Execution Model
Every vProg follows this flow:
1. Define state schema (what data the vProg owns)
2. Define transition function (how state changes)
3. Execute transition off-chain (prover runs the logic)
4. Generate ZK proof (attest to correct execution)
5. Submit proof + new state commitment to L1
6. L1 validates proof cryptographically (no re-execution)
7. State finalized via DagKnight consensus
The key insight: L1 never runs your code. It only checks the cryptographic proof that your code ran correctly.
Step 2: Define the State Schema
Create a new crate for your vProg:
cd vprogs
cargo new --lib examples/counter-vprog
Define the account state in examples/counter-vprog/src/lib.rs:
use borsh::{BorshDeserialize, BorshSerialize};
use vprogs_core::types::{AccountId, StateRoot};
/// The state of our counter vProg.
/// Each vProg owns exclusive accounts (S_p) and manages
/// its own state independently.
#[derive(BorshSerialize, BorshDeserialize, Clone, Debug)]
pub struct CounterState {
/// The current counter value
pub value: u64,
/// Total number of increments performed
pub num_increments: u64,
/// The account that last modified the counter
pub last_modifier: Option<AccountId>,
}
impl CounterState {
pub fn new() -> Self {
Self {
value: 0,
num_increments: 0,
last_modifier: None,
}
}
}
Step 3: Define the Transition Function
The transition function is the core logic of your vProg. It takes the current state and an action, and returns the new state.
use vprogs_core::types::AccountId;
/// Actions that can be performed on the counter
#[derive(BorshSerialize, BorshDeserialize, Clone, Debug)]
pub enum CounterAction {
/// Increment the counter by a given amount
Increment { amount: u64, caller: AccountId },
/// Reset the counter (only allowed by the creator)
Reset { caller: AccountId },
}
/// The state transition function.
/// This runs OFF-CHAIN in the prover. L1 never executes this code.
/// Instead, L1 verifies the ZK proof that this function was
/// executed correctly.
pub fn transition(
state: &CounterState,
action: &CounterAction,
) -> Result<CounterState, TransitionError> {
match action {
CounterAction::Increment { amount, caller } => {
if *amount == 0 {
return Err(TransitionError::InvalidAmount);
}
Ok(CounterState {
value: state.value.checked_add(*amount)
.ok_or(TransitionError::Overflow)?,
num_increments: state.num_increments + 1,
last_modifier: Some(caller.clone()),
})
}
CounterAction::Reset { caller } => {
// In a real vProg, you would check authorization here
Ok(CounterState {
value: 0,
num_increments: state.num_increments + 1,
last_modifier: Some(caller.clone()),
})
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum TransitionError {
#[error("increment amount must be positive")]
InvalidAmount,
#[error("counter overflow")]
Overflow,
}
Step 4: Build the State Commitment
vProgs use hierarchical Merkle roots to commit to state. The commitment C_p^t is a Merkle root over per-step state roots since the last proof.
use vprogs_core::hash::blake3_hash;
use vprogs_core::types::StateRoot;
/// Compute a state commitment from the current state.
/// This commitment is what gets submitted to L1 alongside the proof.
pub fn compute_state_root(state: &CounterState) -> StateRoot {
let serialized = borsh::to_vec(state)
.expect("serialization should not fail");
StateRoot(blake3_hash(&serialized))
}
/// Compute the transition commitment: hash of (old_state, action, new_state).
/// This binds the proof to a specific state transition.
pub fn compute_transition_commitment(
old_root: &StateRoot,
new_root: &StateRoot,
action: &CounterAction,
) -> [u8; 32] {
let action_bytes = borsh::to_vec(action)
.expect("serialization should not fail");
let mut hasher = blake3::Hasher::new();
hasher.update(&old_root.0);
hasher.update(&new_root.0);
hasher.update(&action_bytes);
*hasher.finalize().as_bytes()
}
Step 5: Execute Off-Chain and Generate Proof
[Coming Soon] The proving infrastructure is under active development. The following shows the intended workflow using RISC Zero as the ZK backend (the “based ZK apps” tier):
// [Coming Soon] -- Proving API is not yet finalized.
// This pseudocode illustrates the intended workflow.
use vprogs_proving::{Prover, ProofRequest};
fn prove_transition(
old_state: &CounterState,
action: &CounterAction,
) -> Result<Proof, ProvingError> {
// 1. Execute the transition
let new_state = transition(old_state, action)?;
// 2. Compute state roots
let old_root = compute_state_root(old_state);
let new_root = compute_state_root(&new_state);
// 3. Build the proof request
let request = ProofRequest {
program_id: COUNTER_VPROG_ID,
old_state_root: old_root,
new_state_root: new_root,
public_inputs: compute_transition_commitment(
&old_root, &new_root, action
),
};
// 4. Generate the ZK proof
// For based ZK apps, this uses RISC Zero / SP1
// Expected proof time: 10-30 seconds
let prover = Prover::new(ZkBackend::RiscZero);
let proof = prover.prove(request)?;
Ok(proof)
}
Step 6: Submit to L1
The proof and state commitment are submitted to L1 as a transaction. L1 validates the proof cryptographically without re-executing your transition function.
// [Coming Soon] -- Submission API is not yet finalized.
use vprogs_client::VprogsClient;
async fn submit_to_l1(proof: Proof) -> Result<TxId, SubmitError> {
let client = VprogsClient::connect("grpc://127.0.0.1:16210").await?;
// Submit the proof. L1 will:
// 1. Verify the ZK proof cryptographically (KIP-16 opcodes)
// 2. Update the vProg's state commitment on-chain
// 3. Finalize via DagKnight consensus (instant finality)
let tx_id = client.submit_proof(proof).await?;
println!("Transaction submitted: {}", tx_id);
println!("State finalized on L1 with DagKnight consensus");
Ok(tx_id)
}
Step 7: Query State
After the proof is verified and finalized, the new state is accessible on-chain:
// [Coming Soon] -- RPC API is not yet finalized.
async fn query_counter(client: &VprogsClient) -> Result<CounterState, QueryError> {
let state = client.get_state(
COUNTER_VPROG_ID,
COUNTER_ACCOUNT_ID,
).await?;
let counter: CounterState = borsh::from_slice(&state.data)?;
println!("Counter value: {}", counter.value);
println!("Total increments: {}", counter.num_increments);
Ok(counter)
}
Putting It All Together
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Initialize state
let state = CounterState::new();
// 2. Define action
let action = CounterAction::Increment {
amount: 42,
caller: my_account_id(),
};
// 3. Execute and prove (off-chain)
let proof = prove_transition(&state, &action)?;
// 4. Submit to L1 (on-chain verification)
let tx_id = submit_to_l1(proof).await?;
// 5. Query updated state
let client = VprogsClient::connect("grpc://127.0.0.1:16210").await?;
let updated = query_counter(&client).await?;
assert_eq!(updated.value, 42);
Ok(())
}
Key Concepts Recap
| Concept | Description |
|---|---|
| Sovereign state | Your vProg owns exclusive accounts; no other vProg can modify them |
| Off-chain execution | Transition logic runs off-chain in the prover |
| ZK proof | Cryptographic attestation that the transition was executed correctly |
| State commitment | Merkle root representing the vProg’s current state |
| L1 verification | L1 validates the proof without re-executing your code |
| Instant finality | DagKnight consensus provides immediate finality |
Next Steps
- Create a Native Asset – Issue tokens using covenant primitives
- Inline ZK Covenant – Sub-second ZK proofs with Noir
- API Reference – RPC endpoints for interacting with vProgs