--- title: Formally verifying zk-circuits tags: zk-proof, soundess, completenss breaks: false --- [Franck Cassez](https://franck44.github.io), Head of Research at [Mantle](https://www.mantle.xyz). — all views expressed are my own. [![Twitter URL](https://img.shields.io/twitter/url/https/twitter.com/franckDownunder.svg?style=social&label=Follow%20%40FranckDownunder)](https://twitter.com/franckDownunder) The [zk-bug-tracker](https://github.com/0xPARC/zk-bug-tracker) repository is an excellent resource to learn about bugs and vulneriabilities in ZK components. I was looking for a simple known bug to check whether, with some _deductive verification_ techniques, I could identify it, fix it and formally prove _correctness_. The simplest bug I could find is the [PSE & Scroll zkEVM: Missing Overflow Constraint](https://github.com/0xPARC/zk-bug-tracker?tab=readme-ov-file#15-pse--scroll-zkevm-missing-overflow-constraint) bug. So I set out to implement the code and verify it with my favourite verification-friendly language: [Dafny](https://github.com/dafny-lang/dafny). # Background In a zk-proof system, a _prover_ wants to convince a _verifier_ that they have computed a function correctly. They both know the function, and in the example of the [PSE & Scroll zkEVM: Missing Overflow Constraint](https://github.com/0xPARC/zk-bug-tracker?tab=readme-ov-file#15-pse--scroll-zkevm-missing-overflow-constraint) this is the function `Mod` defined by (the code is this article is written in [Dafny](https://github.com/dafny-lang/dafny)): ```rust= function Mod(a: u256, n: u256): (r: u256) { if n == 0 then 0 else a % n // standard int modulo operator } ``` The prover wants to convince the verifier that they have computed `r` and that `r == Mod(a, n)` for given `a` and `n`. Note that all the values are unsigned integers over 256 bits (`u256`). The verifier has a _gadget_ to verify this claim in the form of a _set of constraints_. The constraints must have some predefined form (R1CS, QAP, AIR, etc) and represent what is usually called a _circuit_. In this instance, the gadget/circuit for verifying the `Mod` function takes 4 input values `k, n, r, a` and has one constraint^[`2^256` is `2` to the power `256`.] `k * n + r == a [modulo 2^256]`. Both the prover and the verifier know `a` and `n`. The protocol to verify the correctness of the computation is: 1. the prover provides a _proof_ in the form of two values `k, r`. 2. The verifier _accepts_ the proof if and only if the predicate`k * n + r == a [modulo 2^256]` evaluates to `true`. :::info :bulb: What is expected from this protocol is that it is _sound_ and _complete_: - _Soundness_ means that if the verifier accepts the proof then `r == Mod(a, n)`; - _Completeness_ means that if `r == Mod(a, n)` the prover can supply a proof `k, r` that is accepted by the verifier. ::: # Specification and verification How can we check whether the protocol is sound and/or complete? ## Constraints as predicates We can specify the verifier's gadget with a Dafny _predicate_, a function that evaluates to either`true` or `false`: ```rust= predicate MulAddWordsGadget(a: u256, b: u256, c: u256, d: u256) { (a as nat * b as nat + c as nat) % TWO_256 == (d as nat) % TWO_256 } ``` In this predicate, - `TWO_256` is a constant equal to `2^256`, - the type `u256` is the set of unsigned integers over 256 bits (from `0` to `TWO_256 - 1`), - `nat` is the type for _mathematical unbounded_ natural numbers, and - `%` is the usual modulo operator on `nat`. The predicate reads: "compute `a * b + c` _as an unbounded natural number_, take the `modulo TWO_256` and check that it is equal to `d % TWO_256`." Note that `d as nat % TWO_256` is `d` as `d` is less than `TWO_256` (because it is a `u256`). ## Soundness and completeness Soundness and completeness are properties of the protocol and they can conveniently be specified as _lemmas_ (theorems) in [Dafny](https://github.com/dafny-lang/dafny). Here is how the soundness property can be written: ```rust= // Soundness proof lemma SoundnessProof(k: u256, n: u256, r: u256, a: u256) requires MulAddWordsGadget(k, n, r, a) ensures r == Mod(a, n) { // Proof to be supplied ... } ``` The `requires` is a _precondition_ (assumption) of the lemma `SoundnessProof` and the `ensures` the _postcondition_ (conclusion). As a result the lemma specifies the property: "If the verifier accepts then `r == Mod(a, n)`". Completeness is specified in a similar manner: ```rust= // Completeness proof lemma CompletenessProof(n: u256, r: u256, a: u256) requires r == Mod(a, n) ensures exists k: u256:: MulAddWordsGadget(k, n, r, a) { // Proof to be supplied ... } ``` The `CompletenessProof` lemma reads: "if `r == Mod(a, n)` then there exists a value of `k` such that the verifier accepts the proof". As a result if the prover has correctly computed `r` they can supply a value of `k` such that `MulAddWordsGadget(k, n, r, a)` evaluates to true (the verifier accepts). ### Verification of soundness We can now try to _verify_ the lemmas. By _verify_, I mean formally prove with the rules of logic (and the arithmetic properties of `Mod`) that for _all inputs_ that satisfy the preconditions, the postcondition is satisfied. The rules of logic and arithmetic properties are used by the Dafny verification engine to verify each step of a proof. Moreover, in Dafny, a proof is a program, so if you can write programs, you can write a proof. Let's first try with a weaker version (I'll explain why later) of the `SoundnessProof` lemma, where we assume that `n > 0` (not zero) and we only want to ensure that `Mod(r, n) == Mod(a, n)` rather than `r == Mod(a, n)`: ```rust= // Soundness proof - weak version lemma SoundnessProofWeak(k: u256, n: u256, r: u256, a: u256) requires n > 0 requires MulAddWordsGadget(k, n, r, a) // instead of r == Mod(a, n), we require the modulus to be equal ensures Mod(r, n) == Mod(a, n) { // Proof to be supplied } ``` :::warning :warning: When verifying this lemma, Dafny reports that the it does not hold, and automatically synthesises a counter-example: ::: ```rust= k = 2 n = 3 r = 115792089237316195423570985008687907853269984665640564039457584007913129639933 a = n ``` The value of `r` is actually `2^256 - 3`, and `k * n + r % TWO_256` evaluates to `3`, same as `a % TWO_256` so the verifier accepts. However `Mod(r, n) == 1` but `Mod(a, n) == 0` so the prover did not compute the correct value. The prover managed to supply `k, r` that verifies but they have not computed the correct value of `r`. The problem has already been identified in this [issue-996](https://github.com/privacy-scaling-explorations/zkevm-circuits/issues/996). The reason is that the `MulAddWordsGadget` gadget is _under constrained_. Note that the counter example provided in [issue-996](https://github.com/privacy-scaling-explorations/zkevm-circuits/issues/996) is different to the one provided by Dafny. The fix is to add a constraint to ensure that no overflow occurs in the computation of `(a as nat * b as nat + c as nat)`. Let's add another gadget (a predicate) that is true if and if the computation overflows: ```rust= predicate OverflowMulAddGadget(a: u256, b: u256, c: u256) { a as nat * b as nat + c as nat >= TWO_256 } ``` The (weak) soundness proof now becomes: ```rust= // Soundness proof - weak version lemma SoundnessProofWeak(k: u256, n: u256, r: u256, a: u256) requires n > 0 requires !OverflowMulAddGadget(k, n, r) requires MulAddWordsGadget(k, n, r, a) ensures Mod(r, n) == Mod(a, n) { Lemma1(k, n, r); } // Helper. lemma {:axiom} Lemma1(k: u256, n: u256, r: u256) requires k as nat * n as nat + r as nat < TWO_256 ensures Mod(k * n + r, n) == Mod(r, n) ``` :::success :+1: With the help^[I have assumed the folks arithmetic lemmas here for the sake of simplicity but we can include them from the Dafny standard libraries.] of an arithmetic lemma `Lemma1`, Dafny automatically _verifies_ `SoundnessProofWeak`. ::: Now let's try to verify the original lemma `SoundnessProof`. ```rust= // Soundness proof lemma SoundnessProof(k: u256, n: u256, r: u256, a: u256) requires n > 0 requires !OverflowMulAddGadget(k, n, r) requires MulAddWordsGadget(k, n, r, a) ensures r == Mod(a, n) { Lemma1(k, n, r); } ``` :::warning :warning: Unfortunately Dafny reports a counter-example `k, n, r, a` again and we perform some simple computation (e.g. with Python) to understand the counter-example better: ::: ```python3 >>> k = 115792089237316195423570985008687907853269984665640564039457584007913129639934 >>> n = 1 >>> r = n >>> a = 115792089237316195423570985008687907853269984665640564039457584007913129639935 >>> (k * n + r) % 2**256 115792089237316195423570985008687907853269984665640564039457584007913129639935 >>> a % 2**256 115792089237316195423570985008687907853269984665640564039457584007913129639935 >>> a % n 0 >>> r 1 >>> 2**256 - k 2 >>> 2**256 - a 1 ``` The issue here (I assume it has already been identified) is that we do **NOT** require `r < n`. So the `Mod` gadget is still _under constrained_. And this is the reason why I started with a weaker version of the soundness proof. :::success :+1: Replacing `requires n > 0` (line 3) with `requires r < n` fixes the problem and this time Dafny _verifies_ lemma `SoundnessProof`. Remember that _verifies_ here means _for all inputs_ the lemma holds. ::: There is one case with have not yet dealt with: `n == 0`. In this case we have to add the constraint `r == 0` to the gadget (I assume this has also been identified before). So overall, here is the final version of the lemma: ```rust= // Soundness proof lemma SoundnessProofWithZero(k: u256, n: u256, r: u256, a: u256) requires (n == 0 && r == 0) || r < n requires !OverflowMulAddGadget(k, n, r) requires MulAddWordsGadget(k, n, r, a) ensures r == Mod(a, n) { Lemma1(k, n, r); } ``` :::success :bulb: To ensure soundness, the gadget for the verification of `Mod` requires 3 constraints (and circuits) given by lines 3--5 in the `SoundnessProofWithZero` lemma. ::: ### Verification of completeness Now let's check completeness. Here is the specification (as above) augmented with a proof, using a simple lemma `ModuloLemma2` that states that: if two numbers `a` and `b` are equal modulo `n` then their difference is a multiple of `n`. ```rust= // Completeness proof lemma CompletenessProof(n: u256, r: u256, a: u256) requires n > 0 requires r == Mod(a, n) ensures exists k: u256:: MulAddWordsGadget(k, n, r, a) && !OverflowMulAddGadget(k, n, r) { if n <= a { var k := ModuloLemma2(r, a, n); assert MulAddWordsGadget(k, n, r, a) ; } else { assert MulAddWordsGadget(0, n, r, a); } } lemma {:axiom} ModuloLemma2(a: u256, b: u256, n: u256) returns (k: u256) requires n > 0 requires a < n <= b requires Mod(a, n) == Mod(b, n) ensures k as nat * n as nat < TWO_256 && b - a == k * n ``` :::success :+1: Dafny confirms that the `CompletenessProof` lemma is valid. ::: What about the case `n == 0`? In that case we **cannot prove completeness**. And indeed, the prover cannot build a proof except if `a == 0.`Assume `n == 0` and `a != 0`. Then `Mod(a, n) == 0`. So the prover should provide a proof of the form `k, 0`. In that case the verifier computes`k * n + r == k * 0 + 0 = 0` but `a % TWO_256 != 0` and the verifier rejects the proof. The formal proof of _incompleteness_ can also be specified and _verified_ with Dafny: ```rust= lemma InCompletenessProofZero(k: u256, n: u256, r: u256, a: u256) requires n == 0 && a > 0 requires r == Mod(a, n) ensures !MulAddWordsGadget(k, n, r, a) { // Thanks Dafny for automatically proving this lemma. } ``` The previous lemma states that for any value of `k`, `MulAddWordsGadget(k, n, r, a)` evaluates to `false`, implying that **the prover cannot provide any `k`** to build an accepting proof (although they computed `r` correctly as per line 3). I also assume that this is known fact. # Conclusion The example above demosntrates that deductive formal verification is practical and applicable to proving zk-circuits. I did not have to write any _proof code_, only to import already proved arithmetic lemmas. The counter-examples were synthesised automatically. The result above is a **machine-checkable proof of soundness and completeness** for the `Mod` gadget. To the best of my knowledge this is the first proof of this type for this gadget. Unfortunately I have not found any _formal proof_ bounty, and as a matter of fact you can get rewards for finding bugs, but not for proving the absence of bugs :disappointed:. [^fn]: