← Back to Home

An Introduction to zkVMs and zkEVMs

(This blogpost is still a bit work in progress)

In this post we will take a high level look at zkVMs. I wrote this blogpost mainly to have some context for when building out the Erigion executable proof sourcing project. It looks at how zkVMs are built and some that exists already, it mainly focuses on zkVMs targeting EVM execution proofs.

Existing articles on the topic

I'm not the first to write about this, obviously, you can read more at these places:

Part 1: What is a zkVM built out of?

(I'm reusing the example from the blogpost of Vitalik throughout this section)

From a high level, things are built up by Algebraic circuits which you can think of as logic gates. You perform some computation, you have that encoded as gates, then you can verify the logic gates.

More precisely, you have some equation that you want to prove that you have the answer for.

def qeval(x):
    y = x**3
    return x + y + 5

Rank-1 constraint system (R1CS)

(I'm reusing the example from the blogpost of Vitalik throughout this section)

This code by itself is too complex to prove so we need to do some adjustments to make it provable. The first step in doing that is converting it into a flattened form (also called algebraic circuit).

The flattened form can only do some operators like assignments or simple math operators ($a + b$, $a - b$, $a \cdot b$, $\frac{a}{b}$) which is why we can't do $x^3$ in one step.

sym_1 = x * x
y = sym_1 * x
sym_2 = y + x
~out = sym_2 + 5

This flattened form can then be converted into R1CS. This is just a way to describe the constraint in the form of $(A \cdot w) \circ (B \cdot w) = (C \cdot w)$ which we will now look at.

We will mainly discuss this at a high level, if you want the full details see this section from the original post of Vitalik.

Each logic gate will have a constraint and they will always be in a triplet form (which is also why we needed to do $x^3$ in two operations above). This means we can describe it as group of three vectors ($a$, $b$ and $c$) which would encapsulate the outputs and the inputs. There would also need to be a solution ($w$) to this constraint which when applied would make the results on both sides be equal. The witness vector contains all the variables from the flattened computation and is used to provide the intermediate values of the computation steps.

The constraints are created like this:

Constraint 1: x * x = sym_1

            
                A[0] = [0,1,0,0,0,0] selects x
                B[0] = [0,1,0,0,0,0] selects x
                C[0] = [0,0,0,1,0,0] selects sym_1
            
            

Constraint 2: sym_1 * x = y

            
                A[1] = [0,0,0,1,0,0] selects sym_1
                B[1] = [0,1,0,0,0,0] selects x
                C[1] = [0,0,0,0,1,0] selects y   
            
        

Constraint 3: (x + y) * 1 = sym_2

            
                A[2] = [0,1,0,0,1,0] selects x + y
                B[2] = [1,0,0,0,0,0] selects ~one
                C[2] = [0,0,0,0,0,1] selects sym_2           
            
        

Constraint 4: (5 + sym_2) * 1 = ~out

            
                A[3] = [5,0,0,0,0,1] selects (5 * ~one) + sym_2 = 5 + sym_2
                B[3] = [1,0,0,0,0,0] selects ~one
                C[3] = [0,0,1,0,0,0] selects ~out
            
        

We can compute the solution vector (also called witness vector) with the following computation

            
def compute_witness(x):
    # Step 1: sym_1 = x * x
    sym_1 = x * x
    
    # Step 2: y = sym_1 * x (this gives us x^3)
    y = sym_1 * x
    
    # Step 3: sym_2 = y + x (this gives us x^3 + x)
    sym_2 = y + x
    
    # Step 4: out = sym_2 + 5 (this gives us x^3 + x + 5)
    out = sym_2 + 5
    
    # Construct witness vector: [~one, x, ~out, sym_1, y, sym_2]
    witness = [1, x, out, sym_1, y, sym_2]
    
    return witness

compute_witness(3) # [1, 3, 35, 9, 27, 30]
            
            
We can verify the computation by doing the following
            
def verify_constraints(witness, A, B, C):
    print("Verifying R1CS constraints...")                    
    for i, (a_row, b_row, c_row) in enumerate(zip(A, B, C)):
        # Compute dot products
        left = sum(a * w for a, w in zip(a_row, witness))
        right = sum(b * w for b, w in zip(b_row, witness))
        expected = sum(c * w for c, w in zip(c_row, witness))
        
        result = left * right
        
        if result != expected:
            return False                    
    return True
            
            

Quadratic Arithmetic Program (QAP)

We can think of the R1CS as a way to describe zkVMs, but without the zk part. To make it zk, we would convert this R1CS specification into a polynomial (or cryptographic proving system). The two most common ones are STARKs and SNARKs, different zkVMs use different proving systems depending on what tradeoffs they chose (proof size, trusted setup, verification time, etc).

We won't go into the details of encoding it as QAP in this blogpost, but some good resources to read more for this can be found here:

From equations to VMs

The high level idea would be to create a trace of the program execution and then have a circuit which verifies that computation.

The tricky part is how to handle memory access which is commonly solved by doing Merkle proofs of the memory. What about loops? You can't have them in a arithmetic circuit. One way is to just unroll the program execution. There is also research into using recursion. This is some of many challenges with writing a program that uses zk proofs.

Alright, so it's not all that easy to create proofs for this, but how do we get program execution in the first place? From the previous section you might recall that we only have access to some primitive math operators, luckily enough these operators are simple enough to give us access to NAND gates which are universal computation units.

Some resources to read more about this step can be found here:

Part 2: zkVM implementations

Disclaimer

Most current zkVMs don't really give privacy benefits, but do give verification speed benefits. zk-SNARKs can verify any code complexity in O(1) time which is why they are appealing.

All zkVMs are not the same

There are many variants of zkVMs, Vitalik also wrote up a good blogpost on this The different types of ZK-EVMs. zkVMs are generally easier to build and more flexible than building zkEVMs. The big challenge is having the EVM compatibility.

Some resources to read more about this can be found here:

EVM equivalence - Proving the EVM execution trace

By proving the execution trace we will have full EVM compatibility. This means you don't have to recompile the contract with some custom compiler and that things should generally just work as on mainnet.

This is something that Scroll has done and is also done by Linea. You can see the high level compilation approach matches that of the EVM as well.

🧻 Scroll

graph TD A[Solidity] --> B[Solidity IR] B --> C[Opcode] C --> D[zkEVM] %% Add clickable links click A "https://soliditylang.org" "Solidity Documentation" click B "https://docs.soliditylang.org/en/latest/ir-breaking-changes.html" "IR Documentation" click C "https://ethereum.org/en/developers/docs/evm/opcodes/" "Opcode Reference" click D "https://scroll.io/zkEVM" "zkEVM Documentation" %% Styling classDef scrollBox fill:#90EE90,stroke:#333,stroke-width:2px class A,B,C,D scrollBox

⬥ Ethereum

graph TD E[Solidity] --> F[Solidity IR] F --> G[Opcode] G --> H[EVM] %% Add clickable links click E "https://soliditylang.org" "Solidity Documentation" click F "https://docs.soliditylang.org/en/latest/ir-breaking-changes.html" "IR Documentation" click G "https://ethereum.org/en/developers/docs/evm/opcodes/" "Opcode Reference" click H "https://ethereum.org/en/developers/docs/evm/" "EVM Documentation" %% Styling classDef ethBox fill:#fff,stroke:#333,stroke-width:2px class E,F,G,H ethBox

Non EVM equivalence - Custom VM

Another approach, usually done to optimize the performance is to use a custom VM, but at the cost of more complexity. You will need to recompile your contracts and things are likely to not fully match how things work on mainnet all the time.

There are a few variants of this for instance earlier versions of Polygon Hermez and ZkSync. Note that ZkSync uses a custom fork of the Solidity compiler to generate the zkEVM bytecode.

🔺 Polygon Hermez

graph TD M[Solidity] --> O[Opcode] O --> P[zkASM instructions] P --> Q[zkVM] %% Add clickable links click M "https://soliditylang.org" "Solidity Documentation" click O "https://ethereum.org/en/developers/docs/evm/opcodes/" "Opcode Reference" click P "https://docs.polygon.technology/zkEVM/spec/zkasm/" "Micro Opcode Documentation" click Q "https://docs.polygon.technology/zkEVM/spec/zkasm/" "uVM Documentation" %% Styling classDef hermezBox fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px class M,N,O,P,Q hermezBox

âš¡ ZkSync

graph TD I[Solidity] --> J[LLVM-IR] J --> K[zkEVM Instructions] K --> L[zkEVM] %% Add clickable links click I "https://soliditylang.org" "Solidity Documentation" click J "https://llvm.org/docs/LangRef.html" "LLVM-IR Documentation" click K "https://docs.zksync.io/zk-stack/concepts/data-availability" "Zinc Instructions" click L "https://docs.zksync.io/zk-stack" "Zinc zkVM Documentation" %% Styling classDef zksyncBox fill:#e3f2fd,stroke:#1976d2,stroke-width:2px class I,J,K,L zksyncBox

General-Purpose zkVMs

There has lately also been a lot of progress in general-purpose zkVMs which target other ISA than the EVM. Most of them target RISC-V, but there are also MIPS variants. These has gathered a lot more attention in recent time.

One of the main advantages of this is that it allows faster proving, but the con in the context of the EVM is that no EVM program will be natively supported and has to be recompiled for the target ISA. However, you can always compile the EVM implementation into the target ISA and run the proofs that way.

Some examples of general purpose zkVMs are:

Utilizing general-purpose zkVMs for EVM execution traces is also something I'm looking into with Erigion executable proof sourcing as a way to optimize the proving time of EVM executions by not having to also prove the EVM implementation.

Making proving fast

CPU proving is slow

It's also worth mentioning that most of these provers support running on the GPU. RISC ZERO reported a significant improvements (10x+) improvement with GPU optimizations.

Precompiles

Certain operations like cryptography are slow to execute in the raw zkVM ISA. Many therefore add what are called precompiles or accelerated circuits. These are extensions to the underlying ISA to allow certain operations to be performed faster.

Design tradeoffs

For instance, what ISA is being used and how memory modelling is done has an impact on the performance. Parallelized proving by breaking up the program into segments.

Aside: Is RISC-V the future?

Vitalik made a post Long-term L1 execution layer proposal: replace the EVM with RISC-V where he advocated that moving to RISC-V could be beneficial for ZK provers.

Here are some posts that provides more nuance to the discussion

Conclusion

If you liked this post, you might also like: