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 elements of list A and the elements of B are sorted, then we know the prover correctly sorted A.

To determine if two lists are the same, we typically sort them and compare them element-wise.

However, to know that a list is sorted, we need to check that 1) the elements are in order and 2) the output of the sorting algorithm contains the same elements as the inputs.

This creates a circular dependency — to know that one list is a permutation of the other, we have to know their sorted versions are identical. But to know that the sorting algorithms were executed properly, we have to know the output of the sort is a permutation of the input.

This isn’t a problem in regular code, but in ZK we must constrain every step of the computation.

We’ve already shown how to prove a list is sorted. This chapter focuses on proving two lists are permutations of each other.

Option 1: Writing constraints for a sorting algorithm

In an earlier chapter, we showed how to write constraints for the Selection Sort algorithm. The Selection Sort algorithm runs in $\mathcal{O}(n^2)$ time, so it will have $\mathcal{O}(n^2)$ constraints. We could use a more efficient algorithm like merge sort to accomplish the same thing in $\mathcal{O}(n \log n)$ constraints, but better solutions exist as we will soon see.

Option 2: Attempt to constrain a 1-to-1 mapping directly

One can try to directly write a circuit that is satisfied if and only if one list is a permutation of the other. In other words, each element in one list has a matching element in the other list and vice-versa.

For example, to prove that [a1, a2, a3] is a permutation of [b1, b2, b3], we need to show a mapping between the two, it is sufficient to prove that each a_i maps to some element in the b list and each b_i maps to some element in the a list.

To create a circuit to prove the dual mapping, we create a matrix of s signals defined as follows:


               b1             b2             b3
    --------------------------------------------
a1 | s11 = (a1-b1)  s12 = (a1-b2)  s12 = (a1-b3)
a2 | s21 = (a2-b1)  s22 = (a2-b2)  s23 = (a2-b3)
a3 | s31 = (a3-b1)  s32 = (a3-b2)  s33 = (a3-b3)

Note that if an s signal is 0, then the corresponding a element and b element are equal. For example, if a3 == b1, then s31 will be 0.

If we then multiply the s signals row-wise and column-wise and constrain their products to be o signals as shown below, then the o signals will be zero if there is at least one matching element.

     b1      b2      b3
a1  s11  ×  s12  ×  s13   =  o_row1
     ×       ×       ×
a2  s21  ×  s22  ×  s23   =  o_row2
     ×       ×       ×
a3  s31  ×  s32  ×  s33   =  o_row3
    ||      ||       ||
    o_col1  o_col2   o_col3

If we constrain each of the o signals to be zero, then that also constrains that each element in a has at least one matching element in b and vice-versa. Consider the interpretation of the output signals:

  • o_row1 is zero if-and-only-if a1 matches an element in b.
  • o_row2 is zero if-and-only-if a2 matches an element in b.
  • o_row3 is zero if-and-only-if a3 matches an element in b.
  • o_col1 is zero if-and-only-if b1 matches an element in a.
  • o_col2 is zero if-and-only-if b2 matches an element in a.
  • o_col3 is zero if-and-only-if b3 matches an element in a.

Therefore, if all of the o signals are zero, then each element of each list has a matching element in the other list.

The drawback of this approach is that the number of constraints grows quadratically with the length of the list.

Instead, we show a method to prove one list is a permutation of the other in time linear to the length of the list.

Credit: The rest of this article is heavily based on this documentation of the Triton VM, we simply show a Circom implementation and add some more beginner-friendly explanations.

How the permutation argument works

Consider a polynomial written in the form:

$$ (x-a)(x-b)(x-c) $$

Its value does not change if the order of the multiplication changes:

$$ (x-b)(x-a)(x-c) $$

In other words, permuting the multiplication of the linear factors of the polynomial does not change the value of the polynomial. (A linear factor is a term of the form $(x – a)$).

We do not check if the polynomials are equivalent by algebraically multiplying the terms. Rather, we can use a much more efficient polynomial equality test called the Schwartz-Zippel lemma.

This test samples a random point for $x$ and plugs it into the two polynomials. If they have the same evaluation, then with extremely high probability, they are the same polynomial (to understand why this test is secure, please see the linked article above).

This technique can be used to prove that the arrays $[a,b,c]$ and $[b,c,a]$ are permutations of each other. We create a circuit that takes two arrays $c_1,c_2,c_3,…,c_n$ and $d_1,d_2,d_3,…,d_n$ as input and then constructs polynomials:

$$ \begin{align*} (x – c_1)(x – c_2)…(x – c_n)\\ (x – d_1)(x – d_2)…(x – d_n) \end{align*} $$

Finally, it picks a random point for $x$, evaluates the two polynomials, and constrains the products to be the same.

To generate the random point, let’s call it $r$, we use the hash of the inputs, i.e., hashing the concatenation of the arrays:

$$ r=\mathsf{hash}([a,b,c,b,c,a]) $$

This way, the prover cannot try to “cheat” by picking a value for $r$ where the polynomials intersect. Once the prover has provided the polynomials, they cannot control the value $r$ at which these polynomials are evaluated.

If the prover changes the polynomials, the value $r$ they are tested at will also change.

The circuit below takes two lists and checks if they are permutations of each other. The array signal prodA holds the terms:

  • $\texttt{prodA[0] = } r – a_0$
  • $\texttt{prodA[1] = prodA[0]} \cdot(r – a_1)$
  • $\texttt{prodA[2] = prodA[1]} \cdot(r – a_2)$
  • $\texttt{prodA[n – 1] = prodA[n – 2]}\cdot(r -a_{n-1})$

Thus, the final entry prodA[n - 1] holds the evaluation of the polynomial at r. Here, r is hash.out, which is the Poseidon hash of all the entries of arrays a and b.


include "circomlib/poseidon.circom";

template IsPermutation(n) {
  signal input a[n];
  signal input b[n];

  // the random point will be the hash
  // of the concatenation of the arrays
  component hash = Poseidon(2 * n);
  for (var i = 0; i < n; i++) {
    hash.inputs[i] <== a[i];
    hash.inputs[i + n] <== b[i];
  }

  signal prodA[n];
  signal prodB[n];

  prodA[0] <== hash.out - a[0];
  prodB[0] <== hash.out - b[0];

  for (var i = 1; i < n; i++) {
    prodA[i] <== (hash.out - a[i]) * prodA[i - 1];
    prodB[i] <== (hash.out - b[i]) * prodB[i - 1];
  }

  // the evaluation of the polynomials at r = hash.out
  prodA[n - 1] === prodB[n - 1];
}

component main = IsPermutation(3);

/* INPUT = {
  "a": [1,2,3,4,5,6],
  "b": [1,2,3,4,6,5]
}
*/

Vulnerability: Not Hashing All Elements

When generating random points in this manner, the hash must depend on all the inputs to the computation. Otherwise, a malicious prover can keep the hash value fixed, and tweak the values of the output array until they find an intersection point of the polynomial.

A note about safety

This algorithm relies on non-equal polynomials only intersecting in at most $d$ points where $d$ is the maximum degree of the two polynomials. If the size of the finite field is much greater than $d$, then we can assume that in practice, $p(r)\neq q(r)$ if $p$ and $q$ are non-equal polynomials and $r$ is a random point. Circom uses a finite field size that is slightly larger than $2^{253}$. If the polynomials have degree one million, then the probability that $r$ is an intersection point is approximately $2^{20}/2^{253}$ or $1/2^{233}$ or $1$ out of $10^{70}$. This is nearly on the same order of magnitude of the number of atoms in the universe.

If we used a very small finite field, however, say on the order of 31 bits, then the probability of $p(r)=q(r), q \ne p$ for a random $r$ is not negligible.

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 […]

How a ZKVM Works

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 […]

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