(If you do not know about Nova, please read the first part)
In the first part, we've been able to produce a SNARK for a very particular case of a recursive function. It was first of all a recursive function from and to numbers and would only call itself recursively once in a branch: i.e., it had a linear call trace. We will show in this article how to construct SNARKs for general recursive functions
- might take/return data types instead of plain numbers
- might call themselves multiple times in a branch
- might call foreign functions, which could be mutually dependent on them
The first thing we need to be able to do is find a way to represent data types as numbers (or more specifically, elements of a finite field). We will follow an approach very similar to Lurk[3], which requires a collision resistant hash function:
- all values that are limited in size, which is smaller or equal to the field size, such as chars, booleans and simple enums, will be represented natively as numbers
- a tuple (or product type) of other data types will be represented by a hash of the (recursive) representation of the tuple elements
- a Rust-like enum (or sum type) constructor will be represented by two components: a unique tag representing each respective constructor (could be a simple ordering of constructors) and the representation of the tuple of arguments of the constructor (if there are any).
Here are some examples, written in Haskell notation:
exTuple = (true, 10, 'a')
data Tree = Leaf Int | Branch Tree Tree
exTree = Branch (Branch 11 7) (Leaf 8)
The representation of exTuple
will be hash3(1, 10, 97)
, since 1, 10, 97
are the representations of true, 10, 'a'
respectively. The constructors of Tree
, i.e. Leaf
and Branch
, will be given unique tags, such as 0
and 1
. Then the representation of exTree
will be (1, hash4(1, hash4(0, 11, 0, 7), 0, 8))
.
Although we've given a way to construct data types, by effectively hash consing, we currently have no way of deconstructing data type. How can we produce a circuit that, for example, extracts the first element of tuples, if the circuit only ever sees its hash and not the tuple itself? How can we reconstruct the tuple from the hash so we can extract its elements? The answer is: we don't reconstruct the tuple, we simply take the tuple itself (the preimage of the hash), as a nondeterministic advice (as a hidden input). Then all the circuit has to do is verify that the public input h
is equal to hash2(x, y)
, x, y
being the advices, and then output x
.
Let us now construct a SNARK for a function that does structural recursion on a data type. Say, let us create the protocol for the sum
function:
sum [] = 0
sum (x : xs) = x + sum xs
Following the approach of our first recursive function, the
The tag of the empty list []
will be x : xs
will be
- if
$\textrm{xs.tag} = \textrm{NIL}$ - return
$(0, hash(u_⊥)),$
- return
- else:
- check
$\textrm{xs.tag} = \textrm{CONS}$ - extract
$(\textrm{head}, \textrm{tail}) ← ω$ - check
$\textrm{xs.bod} = \textrm{hash3}(\textrm{head}, \textrm{tail.tag}, \textrm{tail.bod})$ - check
$u\textrm{.input} = (vk, \textrm{tail})$ - extract
$(s, h) = u\textrm{.output}$ - check
$h = \textrm{hash}(U)$ - compute
$U' ← \textrm{fold.V}(vk, u, U, π)$ - compute
$s' ← \textrm{head} + s$ - return
$(s', \textrm{hash}(U'))$
- check
The folding protocol will mostly follow
- if
$\textrm{xs.tag} = \textrm{NIL}$ :- compute the trivial case
$(u_0, w_0) ← \textrm{trace}(G', (vk_{\textrm{fold}}, \textrm{xs} | ø, ø, ø, ø)),$ for$ø$ a dummy value, like$0$ - return
$(u_0, w_0, u_⊥, w_⊥)$
- compute the trivial case
- else:
- extract
$\textrm{tail} ← ω_n\textrm{.tail}$ — there's no need to check that this is well-formed, since it will be done by the circuit - recursively compute
$(u_{n-1}, w_{n-1}, U_{n-1}, W_{n-1}) ← \textrm{IVC.F}(pk_{\textrm{fold}}, vk_{\textrm{fold}}, \textrm{tail})$ - compute
$(U_n, W_n, π_n) ← \textrm{fold.P}(pk_{\textrm{fold}}, u_{n-1}, U_{n-1}, w_{n-1}, W_{n-1})$ - compute
$(u_n, w_n) ← \textrm{trace}(G', (\textrm{vk}, \textrm{xs} | ω_{n-1}, u_{n-1}, U_{n-1}, π_n))$ - return
$(u_n, w_n, U_n, W_n)$
- extract
And the prover and verifier protocol will have basically no change apart from adjusting the input:
- compute
$(U_{n+1}, W_{n+1}, π) ← \textrm{fold.P}(pk_{\textrm{fold}}, u_n, U_n, w_n, W_n)$ - compute
$Π ← \textrm{SNARK.P}(pk_{\textrm{SNARK}}, U_{n+1}, W_{n+1})$ - return
$(π, Π)$
- check
$u_n\textrm{.input} = (vk_{\textrm{fold}}, \textrm{xs})$ - check
$u_n\textrm{.output.h} = \textrm{hash}(U_n)$ - compute
$U_{n+1} ← \textrm{fold.V}(vk_{\textrm{fold}}, u_n, U_n, π_n)$ - check
$\textrm{SNARK.V}(vk_{\textrm{SNARK}}, U_{n+1}, Π)$
Now let's make it more challenging: summing binary trees. Here's the Haskell code for it:
data Tree = Leaf Int | Branch Tree Tree
sum (Leaf a) = a
sum (Branch ts ts') = sum ts + sum ts'
The Leaf
constructor will get the tag Branch
constructor the tag
Now, there's a caveat here. We were assuming we only needed to fold R1CS instances with relaxed R1CS. Now we will need the general fold of two relaxed R1CS instances. Because of this, we introduce the injection of R1CS to relaxed R1CS as
- if
$\textrm{ts.tag} = \textrm{LEAF}$ - return
$(\textrm{ts.bod}, hash(u_⊥))$
- return
- else:
- check
$\textrm{ts.tag} = \textrm{BRANCH}$ - extract
$(\textrm{left}, \textrm{right}) ← ω$ - check
$\textrm{ts.bod} = \textrm{hash4}(\textrm{left.tag}, \textrm{left.bod}, \textrm{right.tag}, \textrm{right.bod})$ - check
$u^L\textrm{.input} = (vk, \textrm{left})$ - check
$u^R\textrm{.input} = (vk, \textrm{right})$ - extract
$(s^L, h^L) = u^L\textrm{.output}$ - extract
$(s^R, h^R) = u^R\textrm{.output}$ - check
$h^L = \textrm{hash}(U^L)$ - check
$h^R = \textrm{hash}(U^R)$ - compute
$U' ← \textrm{fold}_4\textrm{.V}(vk, ι(u^L), U^L, ι(u^R), U^R, π)$ - compute
$s' ← s^L + s^R$ - return
$(s', \textrm{hash}(U'))$
- check
The IVC's prover and verifier protocols will have basically no change:
- compute
$(U', W', π) ← \textrm{fold.P}(pk_{\textrm{fold}}, u, U, w, W)$ - compute
$Π ← \textrm{SNARK.P}(pk_{\textrm{SNARK}}, U', W')$ - return
$(π, Π)$
- check
$u\textrm{.input} = (vk_{\textrm{fold}}, \textrm{xs})$ - check
$u\textrm{.output.h} = \textrm{hash}(U)$ - compute
$U' ← \textrm{fold.V}(vk_{\textrm{fold}}, u, U, π)$ - check
$\textrm{SNARK.V}(vk_{\textrm{SNARK}}, U', Π)$
The IVC's folding protocol will follow the shape of the
- if
$\textrm{ts.tag} = \textrm{LEAF}$ :- compute the trivial case
$(u_0, w_0) ← \textrm{trace}(G', (vk_{\textrm{fold}}, \textrm{ts} | ø, ø, ø, ø))$ - return
$(u_0, w_0, u_⊥, w_⊥)$
- compute the trivial case
- else:
- extract
$(\textrm{left}, ω^L, \textrm{right}, ω^R) ← ω$ - recursively compute
$(u^L, w^L, U^L, W^L) ← \textrm{IVC.F}(pk_{\textrm{fold}}, vk_{\textrm{fold}}, \textrm{left}, ω^L)$ - recursively compute
$(u^R, w^R, U^R, W^R) ← \textrm{IVC.F}(pk_{\textrm{fold}}, vk_{\textrm{fold}}, \textrm{right}, ω^R)$ - compute
$(U, π) ← \textrm{fold}_4\textrm{.V}(vk, (ι(u^L), U^L, ι(u^R), U^R), (w^L, W^L, w^R, W^R))$ - compute
$(u, w) ← \textrm{trace}(G', (\textrm{vk}, \textrm{ts} | (\textrm{left}, \textrm{right}), u, U, π))$ - return
$(u, w, U, W)$
- extract
[1]: "Nova: Recursive Zero-Knowledge Arguments from Folding Schemes", Abhiram Kothapalli, Srinath Setty, and Ioanna Tzialla, 2021.
[2]: "SuperNova: Proving universal machine executions without universal circuits", Abhiram Kothapalli, and Srinath Setty, 2022.
[3]: "LURK: Lambda, the Ultimate Recursive Knowledge", Nada Amin, John Burnham, François Garillot, Rosario Gennaro, Chhi’mèd Künzang, Daniel Rogozin, and Cameron
Wong. 2023.