How a ZKVM Works

A Zero-Knowledge Virtual Machine (ZKVM) is a virtual machine that can create a ZK-proof that verifies it executed a set of machine instructions correctly. This allows us to take a program (a set of opcodes), a virtual machine specification (how the virtual machine behaves, what opcodes it uses, etc), and prove that the output generated is correct. A verifier does not have to re-run the program, but only check the generated ZK proof — this allows verification to be succinct. Such succinctness is the basis of what makes ZK layer 2 blockchains scalable. It also enables someone to check that a machine learning algorithm ran as claimed without re-running the entire algorithm.

Contrary to the name, ZKVMs are rarely “zero knowledge” in the sense that they keep the computation private. Rather, they use ZK algorithms to produce a succinct proof that the program executed correctly on a certain input so that a verifier can double-check the computation with exponentially less work. Even though revealing the program input is optional, avoiding accidental data leaks and having multiple parties agree upon private state are very challenging engineering problems that still have unsolved challenges and scaling limitations.

A ZKVM “computes” each step in the opcode, then constrains that the opcode was executed correctly. The constraints must be designed in such a way that we can work with an arbitrary but valid opcode sequence.

We can think of a ZKVM as a series of “state” transitions. The “state transition function” takes the previous state and the current opcode to be executed, an generates a new state. A ZKVM implements the “state transition function” and the circuit constraints that model its behavior. Note that “state” can include things like the “program counter” or other bookkeeping necessary for the VM to run properly.

This tutorial will build an extremely simple ZKVM that only supports basic arithmetic but is extensible for other opcodes. The VM presented here only has a stack and no memory or storage. We provide suggestions for further study of ZKVMs at the end of the article.

Prerequisites

This tutorial assumes basic knowledge of how the EVM works (or some other stack-based architecture, such as the Java Virtual Machine). We build heavily off of the stack example from the previous chapter, so we assume knowledge of that.

Simple stack-based ZKVM

We will build a simple stack-based ZKVM with one special-purpose signal that contains the result of the computation. The VM is fed a series of opcodes and numbers, then outputs the final result to a special signal we call out.

Our ZKVM only has the following opcodes:

  • PUSH (pushes the first argument onto the stack)
  • ADD (pops the top two items from the stack and pushes their sum)
  • MUL (pops the top two items from the stack and pushes their product)
  • NOP (no operation, don’t do anything)

For simplicity, all opcodes take a single argument, but only PUSH makes use of the argument. The rest of the instructions ignore the argument. The reason we supply arguments to opcodes that do not use them is so that we do not have to conditionally check if the argument is present or not, based on the opcode.

There is no STOP or RETURN opcode (the substitute is explained shortly). The VM takes a steps argument and returns whatever value is at the bottom of the stack after it executes steps many instructions.

The following animation gives a simple example of adding two numbers together in this architecture:

In Circom, loops cannot be of variable-length, they must always be executed for a fixed number of iterations, as the underlying Rank 1 Constraint System (R1CS) itself must be of fixed size. Similarly, programs cannot be of variable size. However, they must have the same number of opcodes regardless of the program run.

To run a program with fewer opcodes than the fixed number, we simply pad it with NOP until the program is of the maximum size. To know when to “stop executing,” the user must supply the above-mentioned steps argument to determine when the value at the bottom of the stack will be returned.

A couple of notes about our architecture:

  • The VM is stack-based, like the EVM, Java Virtual Machine, or (for those who know) a Reverse Polish Notation calculator.
  • There are no jump instructions, so the program counter only increments.
  • All opcodes take a single argument, but ADD, MUL, and NOP ignore the argument passed to them. That allows us to always increment the program counter by the same amount each time — we don’t have to update the program counter by 2 for a PUSH and 1 for an ADD and so on. We always move the counter up by 2.
  • To read the argument of PUSH, we simply “look ahead” one index from the program counter.
  • Addition and multiplication are done using modular arithmetic (the default in Circom). We use the order of Circom’s default field as our “word size” — we don’t try to emulate VMs which have traditional word sizes like 64 bits or 256 bits. Emulating computation with bits of a fixed size is the subject of the following chapter.

Updating Our Stack Code

We can make a few key changes to our stack code from the previous chapter to build a ZKVM that meets the spec described above.

  • We removed the POP opcode since it is no longer needed.
  • We add an ADD and MUL opcode.

Recall that the previous rules for copying the previous stack were:

  • A. If the sp is 1 or greater, and column j is 1 index below sp, and the current instruction is PUSH or NOP, we should copy column j
  • B. If the sp is 2 or greater, and column j is 2 indexes below sp, and the current instruction is POP, we should copy column j

Rule A remains unchanged, but B needs to be updated to the following:

  • B. if the sp is 2 or greater, and column j is 3 indexes below sp, and the current instruction is ADD or MUL, we should copy column j

The reason for this change is that the previous POP instruction didn’t alter the second-to-top item on the stack, it only removed the top item. However, ADD effectively pops the stack twice and pushes the sum. Likewise, MUL pops the stack twice and pushes the product.

The previous stack implementation only wrote new values to the stack pointer. However, the new implementation can write the sum or product two indexes below the stack pointer. For example, the 12 in the stack below will become 15 after addition, and that location is two indexes below the stack pointer:

Before addition:

[12 , 3, sp] (sp = 3)

After addition:

[15, sp] (sp = 2)

Here, we have 12 as the bottom of the stack and sp pointing to the empty space above the stack.

Therefore, we need a signal to indicate that a particular column is two elements below the stack pointer.

The code below is heavily derived from the stack of the previous chapter, but it implements the updates described in this chapter. Specifically:

  • We have replaced NOP, PUSH, and POP with NOP, PUSH, ADD, and MUL. ADD and MUL decrement the stack pointer by one, NOP keeps the stack pointer the same, and PUSH increases the stack pointer by one and copies its argument to the top of the stack.
pragma circom 2.1.6;

include "circomlib/comparators.circom";
include "circomlib/gates.circom";

template AND3() {
  signal input in[3];
  signal output out;

  signal temp;
  temp <== in[0] * in[1];
  out <== temp * in[2];
}

// i is the column number
// bits is how many bits we need
// for the LessEqThan component
template ShouldCopy(i, bits) {
  signal input sp;
  signal input is_push;
  signal input is_nop;
  signal input is_add;
  signal input is_mul;

  // out = 1 if should copy
  signal output out;

  // sanity checks
  is_add + is_mul + is_push + is_nop === 1;
  is_nop * (1 - is_nop) === 0;
  is_push * (1 - is_push) === 0;
  is_add * (1 - is_add) === 0;
  is_mul * (1 - is_mul) === 0;

  // it's cheaper to compute ≠ 0 than > 0 to avoid
  // converting the number to binary
  signal spEqZero;
  signal spGteOne;
  spEqZero <== IsZero()(sp);
  spGteOne <== 1 - spEqZero;

  // it's cheaper to compute ≠ 0 and ≠ 1 than ≥ 2
  signal spEqOne;
  signal spGteTwo;
  spEqOne <== IsEqual()([sp, 1]);
  spGteTwo <== 1 - spEqOne * spEqZero;

  // the current column is 1 or more 
  // below the stack pointer
  signal oneBelowSp <== LessEqThan(bits)([i, sp - 1]);

  // the current column is 3 or more
  // below the stack pointer
  signal threeBelowSP <== LessEqThan(bits)([i, sp - 3]);

  // condition A
  component a3A = AND3();
  a3A.in[0] <== spGteOne;
  a3A.in[1] <== oneBelowSp;
  a3A.in[2] <== is_push + is_nop;

  // condition B
  component a3B = AND3();
  a3B.in[0] <== spGteTwo;
  a3B.in[1] <== threeBelowSP;
  a3B.in[2] <== is_add + is_mul;

  component or = OR();
  or.a <== a3A.out;
  or.b <== a3B.out;  
  out <== or.out;
}

template CopyStack(m) {
  var nBits = 4;
    signal output out[m];
    signal input sp;
    signal input is_add;
    signal input is_mul;
    signal input is_push;
    signal input is_nop;

    component ShouldCopys[m];
    signal copy[m];

    // loop over the columns
    for (var i = 0; i < m; i++) {
      ShouldCopys[i] = ShouldCopy(i, nBits);
      ShouldCopys[i].sp <== sp;
      ShouldCopys[i].is_add <== is_add;
      ShouldCopys[i].is_mul <== is_mul;
      ShouldCopys[i].is_push <== is_push;
      ShouldCopys[i].is_nop <== is_nop;
      out[i] <== ShouldCopys[i].out;
    }
}

// n is how many instructions we can handle
// since all the instructions might be push,
// our stack needs capacity of up to n
template ZKVM(n) {
  var NOP = 0;
  var PUSH = 1;
  var ADD = 2;
  var MUL = 3;

  signal input instr[2 * n];

  // we add one extra row for sp because
  // our algorithm always writes to the
  // next row and we don't want to conditionally
  // check for an array-out-of-bounds
  signal output sp[n + 1];

  signal output stack[n][n];

  var IS_NOP = 0;
  var IS_PUSH = 1;
  var IS_ADD = 2;
  var IS_MUL = 3;
  var ARG = 4;
  signal metaTable[n][5];

  // first instruction must be PUSH or NOP
  (instr[0] - PUSH) * (instr[0] - NOP) === 0;

  signal first_op_is_push;
  first_op_is_push <== IsEqual()([instr[0], PUSH]);

  // if the first op is NOP, we are forcing the first
  // value to be zero, but this is where the stack
  // pointer is, so it doesn't matter
  stack[0][0] <== first_op_is_push * instr[1];

  // initialize the rest of the first stack to be zero
  for (var i = 1; i < n; i++) {
    stack[0][i] <== 0;
  }

  // we fill out the 0th elements to avoid
  // uninitialzed signals
  sp[0] <== 0;
  sp[1] <== first_op_is_push;
  metaTable[0][IS_PUSH] <== first_op_is_push;
  metaTable[0][IS_NOP] <== 1 - first_op_is_push;
  metaTable[0][IS_ADD] <== 0;
  metaTable[0][IS_MUL] <== 0;
  metaTable[0][ARG] <== instr[1];

  // spBranch is what we add to the previous stack pointer
  // based on the opcode. Could be 1, 0, or -1 depending on the
  // opcode. Since the first opcode cannot be POP, -1 is not
  // an option here.
  var SAME = 0;
  var INC = 1;
  var DEC = 2;
  signal spBranch[n][3];
  spBranch[0][INC] <== first_op_is_push * 1;
  spBranch[0][SAME] <== (1 - first_op_is_push) * 0;
  spBranch[0][DEC] <== 0;

  // populate the metaTable and the stack pointer
  component EqPush[n];
  component EqNop[n];
  component EqAdd[n];
  component EqMul[n];

  component eqSP[n][n];
  signal eqSPAndIsPush[n][n];
  for (var i = 0; i < n; i++) {
    eqSPAndIsPush[0][i] <== 0;
  }

  // signals and components for copying
  component CopyStack[n];
  signal previousCellIfShouldCopy[n][n];
  for (var i = 0; i < n; i++) {
    previousCellIfShouldCopy[0][i] <== 0;
  }

  component eqSPMinus2[n][n];
  signal eqSPMinus2AndIsAdd[n][n];
  signal eqSPMinus2AndIsMul[n][n];
  for (var i = 0; i < n; i++) {
    eqSPMinus2AndIsAdd[0][i] <== 0;
    eqSPMinus2AndIsMul[0][i] <== 0;
  }

  // (the current column = sp - 2 and is_add) * sum
  signal eqSPMinus2AndIsAddWithValue[n][n];
  signal eqSPMinus2AndIsMulWithValue[n][n];

  signal sum_result[n][n];
  signal mul_result[n][n];
  for (var i = 0; i < n; i++) {
    eqSPMinus2AndIsAddWithValue[0][i] <== 0;
    eqSPMinus2AndIsMulWithValue[0][i] <== 0;
    sum_result[0][i] <== 0;
    mul_result[0][i] <== 0; 
  }

  for (var i = 1; i < n; i++) {
    // check which opcode we are executing
    EqPush[i] = IsEqual();
    EqPush[i].in[0] <== instr[2 * i];
    EqPush[i].in[1] <== PUSH;
    metaTable[i][IS_PUSH] <== EqPush[i].out;

    EqNop[i] = IsEqual();
    EqNop[i].in[0] <== instr[2 * i];
    EqNop[i].in[1] <== NOP;
    metaTable[i][IS_NOP] <== EqNop[i].out;

    EqAdd[i] = IsEqual();
    EqAdd[i].in[0] <== instr[2 * i];
    EqAdd[i].in[1] <== ADD;
    metaTable[i][IS_ADD] <== EqAdd[i].out;

    EqMul[i] = IsEqual();
    EqMul[i].in[0] <== instr[2 * i];
    EqMul[i].in[1] <== MUL;
    metaTable[i][IS_MUL] <== EqMul[i].out;

    // carry out the sums and muls
    for (var j = 0; j < n - 1; j++) {
      sum_result[i][j] <== stack[i - 1][j] + stack[i - 1][j + 1];
      mul_result[i][j] <== stack[i - 1][j] * stack[i - 1][j + 1];
    }

    // these values cannot be used in practice because
    // the stack doesn't go that high.
    // However, we still need to initialize
    // them because every column checks
    // if it is sp - 1, even the last 2
    for (var j = n - 1; j < n; j++) {
      sum_result[i][j] <== 0;
      mul_result[i][j] <== 0;
    }

    // get the instruction argument
    metaTable[i][ARG] <== instr[2 * i + 1];

    // if it is a push, write to the stack
    // if it is a copy, write to the stack
    CopyStack[i] = CopyStack(n);
    CopyStack[i].sp <== sp[i];
    CopyStack[i].is_push <== metaTable[i][IS_PUSH];
    CopyStack[i].is_nop <== metaTable[i][IS_NOP];
    CopyStack[i].is_add <== metaTable[i][IS_ADD];
    CopyStack[i].is_mul <== metaTable[i][IS_MUL];
    for (var j = 0; j < n; j++) {
      previousCellIfShouldCopy[i][j] <== CopyStack[i].out[j] * stack[i - 1][j];

      eqSP[i][j] = IsEqual();
      eqSP[i][j].in[0] <== j;
      eqSP[i][j].in[1] <== sp[i];
      eqSPAndIsPush[i][j] <== eqSP[i][j].out * metaTable[i][IS_PUSH];

      // check if the column is two less
      // than the stack pointer
      // if so, we prepare to write the sum or
      // product here
      // if the current instruction is add or mul
      eqSPMinus2[i][j] = IsEqual();
      eqSPMinus2[i][j].in[0] <== j;
      eqSPMinus2[i][j].in[1] <== sp[i] - 2; // underflow doesn't matter

      eqSPMinus2AndIsAdd[i][j] <== eqSPMinus2[i][j].out * metaTable[i][IS_ADD];
      eqSPMinus2AndIsMul[i][j] <== eqSPMinus2[i][j].out * metaTable[i][IS_MUL];

      eqSPMinus2AndIsAddWithValue[i][j] <== eqSPMinus2AndIsAdd[i][j] * sum_result[i][j];
      eqSPMinus2AndIsMulWithValue[i][j] <== eqSPMinus2AndIsMul[i][j] * mul_result[i][j];
      // we will either
      // - PUSH 
      // - COPY or implicilty assign 0
      // - ADD
      // - MUL
      stack[i][j] <== eqSPAndIsPush[i][j] * metaTable[i][ARG] + previousCellIfShouldCopy[i][j] + eqSPMinus2AndIsAddWithValue[i][j] + eqSPMinus2AndIsMulWithValue[i][j];
    }

    // write to the next row's stack pointer
    spBranch[i][INC] <== metaTable[i][IS_PUSH] * (sp[i] + 1);
    spBranch[i][SAME] <== metaTable[i][IS_NOP] * (sp[i]);
    spBranch[i][DEC] <== (metaTable[i][IS_ADD] + metaTable[i][IS_MUL]) * (sp[i] - 1);
    sp[i + 1] <== spBranch[i][INC] + spBranch[i][SAME] + spBranch[i][DEC];
  }
}

component main = ZKVM(5);

/* INPUT = {
    "instr": [1,3,1,6,1,2,3,0,3,0]
} */

Isn’t it inefficient to have a constraint for every opcode if we only use one?

In our ZKVM, we conduct an addition and multiplication for every element in the stack, even though we actually use only one of them. This is not consequential for a very light operation like addition or multiplication. However, if we had an opcode for a heavy operation like hashing, this would generate significantly more constraints; we would have to populate a circuit for hashing for each item in the stack, even though only the top of the stack needs to be hashed. All this will result in unnecessary computations and high computational overhead.

We can improve the efficiency by using a Quin selector (or two) to determine which items on the stack will be inputs to the opcode, but this still means each iteration of the stack needs constraints for a hash even if it doesn’t use them.

We leave it as an exercise for the reader to implement two Quin selectors to only add or multiply the top two items on the stack instead of the entire stack.

This inefficiency of unnecessarily repeating unused constraints is a serious drawback of the vanilla R1CS, which does not allow for conditional use of constraints.

Solutions to Improve Efficiency

Two modern approaches for dramatically improving the efficiency are constraints based on lookup tables and recursive proofs.

  • A lookup table is an arithmetization scheme where only the constraints that are actually used are part of a table, and then the ZK proof proves that it used the correct entry from the table on each instruction.
  • A recursive proof creates a separate ZK proof for each instruction and then combines the proof with another ZK proof that verifies that the input proofs are valid. (Consider that the verification algorithm in ZK can itself be modeled with an arithmetic circuit).

These improvements are the subject of later parts of the ZK book. However, the thought process behind modeling what a valid state transition looks like in a VM generalizes to modern day VMs.

Learn more about about ZKVMs

The first proposal for a ZKVM was TinyRAM, proposed in a paper Snarks for C: verifying Program Executions Succinctly and in Zero Knowledge. The authors created, in their words “a minimalistic RISC random-access machine with a Harvard architecture and word-addressable random-access memory.” The TinyRAM specification requires only 29 opcodes.

Since this paper spawned the research into ZKVMs, it is a high-impact paper worth understanding.

Most modern ZKVMs are based in the RISC-V architecture, but MIPS architecture ZKVMs exist also. ZK layer 2 blockchains often use their own custom architecture.

Ye Zhang’s video on how Scroll created a ZKEVM is also fairly approachable for a high-level understanding.

ZK Proof of Selection Sort

ZK Proof of Selection Sort Most computations of interest are generally “stateful” — that is, they need to go through a series of steps to produce the final result. Sometimes, we do not need to show we executed the computation but only show the result. For example, if A is a list, we can prove […]

The Permutation Argument

The Permutation Argument A permutation argument is a proof that two lists hold the same elements, but possibly in a different order. For example, [2,3,1] is a permutation of [1,2,3] and vice-versa. The permutation argument is useful for proving one list is a sorted version of another. That is, if list B has the same […]

ZK Friendly Hash Functions

ZK Friendly Hash Functions ZK-friendly hash functions are hash functions that require much fewer constraints to prove and verify than traditional cryptographic hash functions. Hash functions such as SHA256 or keccak256 make heavy use of bitwise operators such as XOR or bit rotation. Proving the correct execution of XOR or bit rotation requires representing the […]

MD5 Hash In Circom

MD5 Hash In Circom In this tutorial, we will implement the MD5 hash in Circom both to compute the hash and to constrain in Circom that it was computed correctly. Although the MD5 hash function is not cryptographically secure (since collisions have been found), the mechanics are similar to cryptographically secure hash functions. Importantly, the […]

Featured Jobs

RareSkills Researcher

As a RareSkills researcher, you will be contributing to the technical content we post on our website.

Apply Now
Rust/Solana Auditor

We’re looking for someone to design and implement security measures and defense-in-depth controls to prevent and limit vulnerabilities.

Apply Now
Full Stack Developer

We’re looking for a Senior Full-Stack Engineer to play a foundational role in working across the entire offchain stack of products.

Apply Now
Rust Developer

We are seeking a talented Rust Developer to build a robust, scalable blockchain indexers and analytic backend.

Apply Now