Disclaimer: This spec is a work in progress. It is an ongoing project and will continue to be refined over time. Questions about the circuit can be posed in the Lurk Zulip server.
Introduction
Zero-Knowledge Proofs 1 are cryptographic primitives that allow some entity (the prover) to prove to another party (the verifier) the validity of some statement or relation. Today there are many efficient constructions of NIZK proof systems, with different trade-offs, as well as several implementations of the proving systems.
Every proving system, as described in the zk-Interface 2, can be divided into the backend, which is the portion of the software that contains the implementation of the underlying cryptographic protocol, and the frontend, which provides means to express statements in a convenient language, allowing to prove such statements in zero knowledge by compiling them into a low-level representation.
The backend of a proving system consists of the key generation, proving and verification algorithms. It proves statements where the instance and witness are expressed as variable assignments, and relations are expressed via low-level languages. The most common instance of such a low-level language is R1CS, a generalization of arithmetic circuits, introduced in 3 and used in many other proof systems (see 4, 5, 6, 7 among others).
Lurk is a proving frontend which accepts high-level statements written in the form of a Lisp dialect, and produces a low-level proof representation thereof specifically tailored to work well with a certain class of proving backends. Indeed, Lurk produces proof statements shaped as chained but distinct iterations of a single abstract state machine8 represented in R1CS. As the components of the low-level proof statement only differ in their assignment of inputs and outputs, Lurk’s outputs are particularly well suited to cryptographic backends that support recursion, or more generally proof aggregation. As an implementation, Lurk currently focuses on producing statements in R1CS, and supports two backends: Nova 9 and SnarkPack 10.
The Lurk frontend consists of the following:
The specification of a high-level language for expressing statements.
A compiler that converts relations expressed in the high-level language into the low-level relations suitable for some backends. This leverages a library of “gadgets” consisting of useful and hand-optimized building blocks for certain primitive statements.
Instance reduction: conversion of the instance in a high-level statement to a low-level instance
Witness reduction: conversion of the witness to a high-level statement to a lowlevel witness (e.g., assignment to witness variables).
Lurk decomposes relations expressed in its Lisp-like source language into iterations of an abstract state machine (ASM), which implements a deterministic interpreter for this language. The workings of the step function of this state machine is detailed in the reduction section below.
At a high level, Lurk’s ASM manages four constructs:
a library of R1CS gadgets, which allow translating primitive blocks of the source language directly into arithmetic sub-circuits,
a continuation stack, that allows deferring the evaluation of sub-parts of the current program, or upacking deferred part of the computation in the translation of the currently-selected portion of the AST is finished,
a reduction strategy, that allows selecting sub-parts of the input AST for evaluation deterministically,
a strict hashing discipline for Lurk programs, which allows the prover to memoize the translation of portions of the input program, while producing cryptographic hashes that bind the prover to a specific source program.
This step function proceeds repeatedly until the input program is fully evaluated.
Lurk
Lurk is a functional programming language based on Scheme and Common Lisp. An important aspect of its design is Continuation Passing Style (CPS)11, where the control flow of programs can be managed through the use of continuations. Continuations are recursive data structures that can be used as part of a representation of generic computations. In particular, for each basic operation in a certain computation, a continuation is used as a pointer to the rest of the computation, i.e. it indicates what must happen after this basic operation is computed. Concretely, it takes the form of a stack of continuations that we will describe in greater detail later on. This technique allows us to divide a program into small parts and build a small circuit for each part. We will summarize the main concepts involved in the design of the language, which should enable the reader to understand how zero-knowledge proofs 7912 are constructed in Lurk.
In this section, we provide a summary of Lurk’s main elements. An expression represents a computation involving literals, variables, operations and procedures. Variables are handled by an environment , which is responsible for binding variables to values. We also use continuations to indicate what must be done to finish the computation.
The system’s I/O is formed by an expression, an environment, and a continuation. Understanding these 3 elements is essential to comprehending Lurk. Each element is represented as a pointer, which is implemented using hash functions. In particular, we use Poseidon 13 to instantiate our pointers.
The environment has a list of bindings, which correspond to a mapping between variables and values at a certain point in time. The mapping of these local bindings is valid only for a specific evaluation of an expression. On the other hand, the global state is represented by the store, which behaves as a memory of the system. The store is global, while the environment is local.
Language overview
t, nil: are self-evaluating and represent true and
false, respectively.
if: has the format (<test> <consequent> <alternate>) and
represents a conditional expression. It must receive all 3
parameters, where the <test> is an expression used to select the result; the <consequent> is selected if the <test> evaluat es to non-nil; and <alternate> is selected otherwise. Unlike other
programming languages, the <alternate> expression is mandatory.
lambda: has the format (lambda <formals> <body>) and
represents a procedure. The environment when the lambda expression
is evaluated is used as a closure, which is extended with
<formals>, a list of variables. The unique expression in the
<body> is evaluated and returned as the result of the lambda
expression.
let: has the format (let <bindings> <body>) and represents an
assignment expression, where <bindings> represents a list of pairs
in the form (<variable>, <init>); and <body> is a unique
expression.
letrec: has the same format as <let> expressions, following
the same rules, but also allowing recursion.
quote: has the format (quote <datum>) or (’ <datum>) and evaluates
to <datum>.
atom: has the format (atom <e>), and it evaluates to t if
<e> is not a list, and evaluates to nil otherwise.
cons, strconscar, cdr: The expression (cons <a> <d>) produces a pair whose car is <a> and cdr is <d>. When
<a> evaluates to a character and <d> evaluates to a string, we
have that strcons <a> <d> produces to a string. In this situation
we have that car <e> returns the first character when <e> is a
string. Correspondingly, cdr <e> returns a string obtained from
removing the first character from <e>.
arithmetic operations: has the format (<op> <e1> <e2>), where
<op> corresponds to an arithmetic operation (+, -, *, /). <e1>
is evaluated before <e2> and the operation is carried out in the
finite field that is used in the subjacent zero-knowledge backend.
equality: has the format (<op> <e1> <e2>), where <op> can be
either = or eq. The equality symbol = is used to compare
expressions whose result is a number (finite field elements), while
the symbol eq is used to compare pointers.
emit: has the format (emit <e>) and is used to return the
result of the evaluation of <e> as a public value, which can be
used to define the instance of the zero-knowledge statement.
begin: has the format (begin <e> ...). The sequence of
expressions is evaluated from left to right and the last result is
returned.
current env: returns the current environment represented as an
association list.
eval: has the format (eval <exp>) or (eval <exp> <env>). The
evaluation of <exp> is used as an expression in the environment
obtained from the evaluation of <env>. If no <env> is provided,
an empty environment is used.
hide: has the format (hide <exp> <secret>). Return the commitment of
<exp> using secret <secret>.
commit: has the the format (commit <exp>). Compute the commitment of
<exp>.
secret: has the format (secret <comm>). Return the secret used to
generate the commitment <comm>, if known.
open: has the format (open <comm>). Return the value used to generate
the commitment <comm>, if known.
comm: has the format (comm <data>). Change the tag of <data> to comm.
num: has the format (num <data>). Change the tag of <data> to num.
char: has the format (char <data>). Change the tag of <data> to <char>.
u64: has the format (u64 <num>). Coerce <num> to be a 64-bit unsigned integer.
Fibonacci example
Here is an example code snippet that implements the Fibonacci sequence.
You can click the ▶ button to display the output.
LurkRecorded example
(letrec ((next (lambda (a b n target) (if (eq n target) a (next b (+ a b) (+ 1 n) target)))) (fib (next 0 1 0))) (fib 10))
Recorded output
Iterations: 521
Result: 55
Figure1:Fibonacciexample
Circuit overview
In this section, we give a short description of Lurk’s circuit. Important concepts are introduced to help the reader better understand the purpose of certain components and how they interface with each other.
High-level description
A Lurk program consists of a sequence of reduction steps, or iterations, which are mapped to frames. A set of frames is grouped into a MultiFrame object. Each frame is represented by a CircuitFrame and a Circuit is a sequence of CircuitFrames where the output of one frame is connected to the input of the next, mimicking the evaluation of Lurk expressions. For instance, in the Fibonacci example above, we have 521 iterations, each one mapped into a frame.
In eval.rs, the function reduce_with_witness() computes reduction steps with their witnesses. We provide an implementation of this computation in the circuit using the functions Reduce-expression() and Apply-Continuation() in the file circuit_frame.rs. Global symbols are pre-computed Lurk symbols that can be easily compared with symbols found during expression evaluation.
To reduce an expression, we distinguish between two cases: atoms, such as symbols, and lists, which are more complicated expressions composed of an operation in the first position and other elements that can be atoms or nested lists. In Reduce-Sym(), we reduce symbols using comparisons among symbols that require allocation of variables and pointers in the circuit, Boolean logic, and conditionals. For example, Reduce-Sym() is used to find the value of a variable. We update the environment and the store accordingly.
The cons function is a crucial building block in functional languages like Lurk. It concatenates car and cdr, allowing us to break down expressions into smaller pieces. In Reduce-Cons(), we handle each possible Lurk expression that is constructed using cons. We allocate auxiliary variables in the circuit for later use and use a CAR-CDR-NAMED() gadget as a building block. We include a clause in a multicase gadget for each situation depending on the type of expression we are handling, and select the desired result based on the head of the expression using car. Finally, we return the result of the multicase.
Another important function is Apply-Continuation(). In order to finish a reduction step, we must calculate the output of the frame. Each iteration has a continuation tag that requires a computation of the next expression, environment, continuation, and thunk. Therefore, we have to constrain the system to prove we are computing the correct elements, and we have to allocate pointers to use them later. This task is executed in 2 stages:
Some continuations require the calculation of new pointers, while others don’t. For those that need new pointers, since the implementation of pointers requires a hash computation and because hashes are expensive in the circuit, we use a multicase to select the appropriate hash preimage. Then we can compute the hashes just once. This allows us to avoid computing unnecessary hashes.
We then use another multicase to select the continuation results.
Gadgets
To construct the circuit, we use gadgets and auxiliary functions as building blocks. These gadgets are fragments of emitted code generated by the Lurk compiler, which represent the translation of primitive operations of the Lurk language into the low-level language accepted by cryptographic proof backends. Each gadget incorporates constraints that convey the semantics of the source language in the low-level language of arithmetic circuits. For instance, when we interpret an expression like a+b, the emitted gadget not only computes the addition but also takes into account the max values of a and b when interpreted as u64, along with the overflow semantics of their sum - even when the gadget manipulates primitive addition operations on 256-bit numbers.
Gadgets are denoted in all caps, and composition of gadgets is shown using a dark gray color. Full details of the gadgets, such as the number of constraints of each component and their implementation description, will be provided in the Low-level description section.
Variable types: In the circuit we allow variable to have the following types:
AllocatedNum: represents a field element in the circuit.
AllocatedPtr: represents a pointer in the circuit, which is given by 2 AllocatedNums, denoted by tag() and hash() respectively.
AllocatedContPtr: represents a continuation pointer in the circuit. Silimilarly to AllocatedPtr, it is formed by 2 AllocatedNums.
Boolean: represents a bool in the circuit. It can be used both for allocated values or as constants. In the case it represents an allocated value, this value is enforced to be 0 or 1.
Some gadgets, such as NOT, refer to other circuits - in this case the Boolean variable being negated. This reflects that arithmetic circuits have input and output variables, and can therefore be connected to each other. In this case, the circuit for NOT will have its input wired to the output of the circuit generating the Boolean variable passed as its argument.
Syntax :
Let: creates a variable in the circuit.
Call: allocate a gadget inside a circuit, used when gadgets have no return values.
Return: defines the output of a circuit.
We use dot notation to access global variables:
Let symbol-tag=globals.sym-tag.
We use dot notation to access gadget’s helper methods:
Let m=CASE-CLAUSES().
Call m.ADD-CLAUSE(k,c).
Tuples and vectors: syntactic sugar to manipulate circuit variables.
Boolean operations: used to handle bit operations like conjunctions, disjunctions, negations, and bit decomposition.
Let o=AND(i1,i2,…): Receives a variadic number of input variables of type Boolean and returns another Boolean representing the conjunction of the input variables.
Let o=OR(i1,i2,…): Receives a variadic number of input variables of type Boolean and returns another Boolean representing the disjunction of the input variables.
Let negated-b=b.NOT(): Every variable b of type Boolean has an auxiliary method called NOT, which receives no input and returns another Boolean representing the negation of b.
Equality: allows equality tests of allocated variables.
Let is-equal=ALLOC-EQUAL(a,b): The Boolean variable is-equal is true if and only if a is equal to b.
EQUAL(a,b): enforces a equal to b.
Let is-zero=ALLOC-IS-ZERO(a): The Boolean variable is-zero is true if and only if a is zero.
Pick: used for ternary operators.
Let a=PICK(cond,a,b): If cond is true, return a, otherwise return b.
Implication: used for constraints in the form: if a is true, then b is true, where a and b are expressions that evaluate to Boolean values.
Call IMPLIES-EQUAL(cond,a,b): If cond is true, then a is enforced to be equal to b.
Arithmetic operations: used to constrain arithmetic operations (+,−,∗,/) in the subjacent finite field.
Let c=SUM(a,b): c is enforced to be a+b.
Let c=SUB(a,b): c is enforced to be a−b.
Let c=MUL(a,b): c is enforced to be a.b.
Let c=DIV(a,b): c is enforced to be a.b−1.
Pointers: formed by a tag, which allows us to identify the type of the pointer, and a hash that links the pointer to its content, which is given by the hash preimage.
Let a=ALLOC-PTR(pointer): Allocates a pointer in the circuit.
Data: functions can allocate different types of data by using pointers. Later, data can be accessed non-deterministically by providing the witness that corresponds to the hash preimage.
Let hash=CONSTRUCT-CONS(car,cdr): Computes a hash function over car and cdr.
Let hash=CONSTRUCT-FUN(args,body,env): Computes a hash function over args, body and env.
Let hash=CONSTRUCT-LIST(args[]): Computes a hash function over args by using a sequence of cons.
Let hash=CONSTRUCT(comp1,comp2,comp3,comp4): computes a hash function over comp1,comp2,comp3,comp4.
Multicase: used to select results based on certain selection tags. It is basically a set of cases that share the same set of selection tags. A multicase whose size is equal to 1 is the same as a regular case.
Let case-clauses=CASE-CLAUSES(): a list of clauses for a CASE gadget.
Let result=CASE(key,clauses,default): the result of using key to select a value from clauses. If no clause is found, return default.
Let multicase-clauses=MULTICASE-CLAUSES(): A list of clauses for a MULTICASE gadget.
Let result=MULTICASE(key,clauses,default): the result of using key to select a value from clauses. If no clause is found, return default.
Auxiliary circuits:
Comparisons: computes the comparison of allocated variables.
Enforce n bits: Enforce a certain allocated number can be represented using n bits.
Circuit specification
This section describes the Lurk circuit in detail. We present the high-level algorithms first to help readers understand our architectural decisions, become familiar with the notation, and comprehend how components are interconnected. We then provide the low-level algorithms, which explain how we construct R1CS constraints for each building block in this document.
Backends
Currently, we support two backends: Groth16 7 and Nova 9. Both are based on R1CS constraints, enabling us to create a single circuit that works with both backends. However, there is an essential difference between the two. Groth16 requires a trusted setup for each circuit, which we want to avoid since updating the circuit would require another ceremony for the new trusted setup. Conversely, Nova doesn’t need a trusted setup. Moreover, Nova allows recursive composition of proofs, making it a practical and exciting alternative. In particular, it fits well into the Lurk circuit since we can fold Lurk frames using Nova’s folding technique. Regrettably, recursive composition is beyond the scope of this document.
From the application layer perspective, the only difference between these systems is the underlying finite field. Specifically, it means that programs like (−10) evaluate to different numbers in each case.
Next we summarize the main characteristics of each system:
Groth16 7 is implemented over the BLS12-381 elliptic curve.
The subjacent Finite Field for Lurk applications is defined over the following prime:
0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001
Nova 9 is based on the cycle of elliptic curves named Pallas and Vesta 14. The underlying Finite Field is defined over the following prime: 0x40000000000000000000000000000000224698fc0994a8dd8c46eb2100000001
Lurk data are constructed using finite field elements. In subsequent sections we show how other data types are represented using these elements.
We denote the finite field by F in order to provide an homogeneous description of Lurk circuits, but it is important to note that, depending on the chosen backend, a different prime is used, which may lead to distinct behavior in some situations (as, for example, when modular reductions are involved).
Main concepts
Here we describe how Lurk evaluation works. Later we will show how the circuit implements the same components.
A Lurk expression is evaluated step-by-step by applying a reduction method. The next step to be applied is called a continuation. In this next example we see how a simple program is evaluated.
The evaluation process begins by identifying the atom operation. Once the operation is detected, we can create a corresponding continuation, called unop, which is short for unary operation. In the second step, we evaluate the unique operand, the number 42, which results in itself. Next, we apply the atom operation to 42 and obtain the symbol T.
The continuation for the atom operation requires only one expression as input, which in this case is the number 42. We then need to find a way to return this to the caller, which is Frame 0. After this step, we can return the result of evaluating atom over 42, which is T, and terminate the computation since it is the outermost continuation.
The unary operator follows a specific structure, consisting of the operator and the continuation, which is represented as Lurk data by finite field elements. The continuation is represented by a tag and a pointer. To look up the pointer content, we use dictionaries, which are hash maps used to store data in a content-addressable way. A hash function is calculated over the content to generate an index, which is used to address the data efficiently. It is important to note that we do not use dictionaries in the circuit. Instead, we provide the witness that corresponds to the hash preimage and the result non-deterministically. Therefore, we do not need to look up any table to find the result since the result is already there.
Pointers
A pointer consists of two field elements: a tag and a raw pointer. The tag serves the purpose of distinguishing between different types of pointers, which is important because each tag corresponds to a distinct data structure behind the pointer. The content of the pointer has a structure that varies according to its type, and this type can be determined by the tag. The raw pointer is a dictionary index that can be used to efficiently access the content. Pointers are versatile and can be used to store Lurk expressions, allowing for the expression of recursive expressions. Additionally, pointers can be used to represent the environment, as described in the Environment Section.
Figure 3: Pointer
Figure 4: Continuation pointer
A continuation pointer, denoted by cont, is also defined as 2 field elements: a continuation tag and a raw pointer. It works in the same way as regular pointers, but it is restricted to continuations. While this differentiation is not necessary, it enhances the design organization by separating components based on their functionality. If we want continuations to be first class, we would have to treat them like regular pointers.
A raw pointer is a 32-bit integer (encoded naturally as a field element) that represents an index in some of the dictionaries managed by the store. This enables efficient recovery of the pointer’s content. If the raw pointer is negative, it encodes an opaque pointer, which implies that the store is not aware of it.
Expressions
Expressions are recursive data structures containing nested operations involving literal values, variables, and operations. It can represent arithmetic expressions like (+1032), or lambda expressions such as (lambda(x)x). An anonymous function that returns the received input argument. Here is a list of Lurk expressions:
nil. The nil symbol is a self-evaluating expression. Since it is used frequently, we have a global pointer to represent it.
cons(pointer, pointer). A cons expression receives two input arguments, which are given by generic pointers. Both expressions are concatenated, forming a new list, which is inserted into the store.
comm(F, pointer). A comm operation receives as input a secret value, represented as a field element, and a second expression, which represents the content of the commitment. The output is a pointer to a field element, which is the commitment using Poseidon hash.
sym(string). A Lurk symbol is given by a string, which may be either a restricted Lurk symbol like cons, car, cdr, if, or let, or a new symbol, defined using let or letrec. It could also be a local symbol, as, for example, in arguments of lambda expressions.
fun(pointer, pointer, pointer). Each pointer represents the arguments, the body, and the closed environment, respectively.
num(F). A num expression is represented by a field element, and the output is a pointer to it.
str(string). A string expression is represented as a list of char, recursively interpreted as a cons(char, string), where the first element is some char, and the rest of the string is another string.
thunk(pointer, cont). A thunk expression contains a pointer to an expression that must be evaluated before calling the continuation, which is the second argument. It is an important building block for constructing special continuations like tail, outermost, terminal, and error continuations.
opaque(pointer). LurkIt allows for opaque pointers, where the content of the pointer is not known by the store. Such pointers can’t be resolved, and they are useful for manipulation of private data (for example).
char(char). A char expression is represented by a char type, which is encoded as a field element naturally.
Figure 5 shows the tree structure of Lurk expressions.
Figure 5: Lurk expression example
Continuations
Continuations are data structures containing all the information necessary to continue some computation. Different continuations require different information. Two important continuations are unop and binop, which respectively denote the continuation for unary and binary operations. In order to evaluate the second argument of a binop we require a binop2 continuation.
outermost. It is the continuation that points to the initial frame.
call0(cont). It is represented by a continuation pointer that contains the function to be called.
call(pointer, pointer, cont), where the first argument corresponds to the unevaled argument, the second is the saved environment, and the third is the continuation. It creates a call_2 continuation that will be able to evaluate the argument and finish the call procedure.
call2(pointer, pointer, cont), where the first argument corresponds to the function to be called, the second is the saved environment, and the third is the continuation. It creates a tail continuation, which will evaluate the body of the function.
tail(pointer, cont). A tail continuation allows nested tails that may be constructed after evaluation of recursive operations to be compressed down to a single step that returns the expected continuation, returning a thunk.
cont-error. This represents the continuation after some error happened. It is equivalent to a terminal continuation, because if Lurk finds an error, then a cont-error is returned as the output of the Lurk program evaluation.
lookup(pointer, cont). This continuation is responsible for doing lookups in the environment, returning a thunk.
unop(operator, cont). The unary operator continuation doesn’t have an unevaluated argument, therefore it can be executed in a single step, returning a thunk.
binop(operator, pointer, pointer, cont). This is the first step of a binary operator, where the unevaluated argument pointer is used to construct a binop2 continuation.
binop2(operator, pointer, cont). The second step of the binary operator can finish the work returning a thunk.
if(pointer, cont). This continuation is used to select which unevaluated expression will be returned to be evaluated next, according to the conditional expression that is currently being evaluated.
let(pointer, pointer, pointer, cont). The first argument represents a variable, the second is the body, the third is the saved environment, while the fourth is the continuation. We have that the continuation of a let expression is to extend the environment with the variable and return the body.
letrec(pointer, pointer, pointer, cont). Similarly to let, but allowing recursion.
dummy. A dummy continuation is useful for maintaining some invariants in the design, allowing us to control the call of the apply-continuation algorithm.
terminal. It is the final continuation of a successful Lurk program evaluation.
emit(pointer). This continuation is useful for creating intermediary publicly visible output of Lurk evaluations.
In the below Figures, we show the DAG structure of Lurk continuations. For each specific point in time, this DAG is just a stack of continuations, starting with the outermost continuation (see left part of Figure 6), growing and reducing the stack accordingly as we reduce expressions. As an example, we evaluate (+(∗45)(∗112)).
Expression DAG
Continuation DAG
Figure6:Reductionstep1
Expression DAG
Continuation DAG
Figure7:Reductionstep2
After we compute one reduction step over the input expression, we find a + operator at the head of the expression, which is one among many possible binary operations supported by Lurk. In order to evaluate the two input arguments of this binary operation, we need to create two continuations, respectively binop and binop2, saving context data to allow the calculation and saving the previous continuation to be able to return the control flow to the expected place.
The first step is to create a binop continuation, as shown in Figure 7, saving the second argument, which is not yet evaluated, such that we evaluate it in the next reduction step.
Because the first argument itself is the binary operation (∗45) (see Figure 7), we need to repeat the same logic again, creating a new binop continuation in the stack, saving the second expression, as before.
Expression DAG
Continuation DAG
Figure8:Reductionstep3
Expression DAG
Continuation DAG
Figure9:Reductionstep4
Expression DAG
Continuation DAG
Figure10:Reductionstep5
Now we have reached a point where the first argument of the binary operation is a self-evaluated expression: the number 4. We can then proceed by applying the continuation, which is responsible for removing itself from the continuation stack. I.e. the leaf binop continuation is removed and replaced with a binop2 continuation that will be applied when the second argument is evaluated. As a result, a thunk containing the evaluations of both arguments is created, such that the binary operation can finally be applied. We then return the control flow to the saved continuation, which is represented here as the previous continuation in the stack.
Since the result of the binary operation is the number 20, which is a self-evaluated expression, we can apply the continuation of the addition expression, creating a corresponding binop2.
Now we evaluate both input arguments of the first multiplication, producing a thunk containing all the information we need to proceed. For instance, we create a binop2 continuation containing the result 20.
Expression DAG
Continuation DAG
Figure11:Reductionstep6
Expression DAG
Continuation DAG
Figure12:Reductionstep7
Expression DAG
Continuation DAG
Figure13:Reductionstep8
Similarly, we evaluate the second multiplication following the same steps. Next, we evaluate the number 2, which is the first argument, and save the number 11 for the next reduction step.
Next, we evaluate the number 11, the second argument, and create a thunk to finish the calculation.
The thunk returns 42 as the result of the addition of 20 and 22 to the previous continuation, which is the outermost continuation. Therefore we simply replace it by the terminal continuation and finish the execution.
Environment
The environment is a data structure that contains pairs of variables and values, such that the variables of Lurk expressions can be evaluated. This simply means that variables are replaced by their values.
Next we show a LET expression, which allows us to create variables in the environment. Briefly, a LET expression receives as input argument a list of pairs (variable, value), and a body, which is an expression that will be evaluated right after the list of variables is inserted into the environment.
Expression DAG
Continuation DAG
Figure14:Reductionstep9
> (let ((a 1)) a) INFO lurk::eval > Frame: 0 Expr: (LET ((A 1)) A) Env: NILCont: Outermost INFO lurk::eval > Frame: 1Expr: 1Env: NILCont: Let{ var: A, body: A, saved_env: NIL, continuation: Outermost } INFO lurk::eval > Frame: 2Expr: AEnv: ((A . 1))Cont: Tail{ saved_env: NIL, continuation: Outermost } INFO lurk::eval > Frame: 3Expr: 1Env: NILCont: Terminal[3 iterations] => 1
Figure15:LETexample
Store implementation
In this section, we explain how the store is implemented in Lurk. Although the store is not essential to constructing the circuit, understanding its implementation is useful because the circuit must have components that behave equivalently. We replace the store implementation with corresponding mechanisms in the circuit to preserve the same properties. It is important to note that the underlying hash functions used in the store and the circuit are completely different, and there is no connection between the hash maps used in the store implementation and the use of Poseidon in the circuit.
The store is a set of pointers that uses hash maps to implement content-addressable storage. These maps allow us to insert pointers in the set in constant time. A distinct set is created for each pointer tag, including continuation pointers. When a new pointer is created, its content is inserted into the set, and an index is generated. This index is used as a raw pointer, which is useful for accessing the content later.
The following are the sets defined in the Lurk store: cons, comm, fun, sym, num, str, thunk, call, call, call2, tail, lookup, unop, binop, binop2, if, let, letrec, and emit. The store provides a convenient interface to create and access its content, such as the get and intern functions for each set.
An opaque pointer has a negative raw pointer, therefore it can’t be used as an index in the store. Hence such pointers do not have known content.
How to implement the store in the circuit:
Instead of using hash maps, the circuit uses non-determinism to construct cryptographic hash relations in the form y = H(x), where before starting the construction of the circuit, the prover already knows both the preimage, x, and the result, y, of the hash function. In the circuit, the prover shows all the intermediate steps necessary to calculate y from x. Hence, Lurk objects are represented as a Merkle-DAG, where arrows are constructed using this one-way hash function. To show that a certain path in the Merkle-DAG is valid, we non-deterministically provide the sequence of hash relations that corresponds to the needed witness (instead of indexing the hash maps, as we did in the store implementation). The prover can still use the hash map to efficiently build the witness, but from the perspective of the verifier, there is no store.
Note
In the context of zero-knowledge proofs, non-determinism refers to the use of non-deterministic auxiliary inputs to improve the efficiency of the verification process. While computing the value of a function can be computationally expensive, verifying whether an already computed answer is correct is typically less costly. Provers can use non-deterministic auxiliary inputs to provide additional information, but high-level programs that compute the answer often lack this information. The benefits of non-determinism can be illustrated with the example of deciding whether two n-element lists are sorted copies of each other. For more on non-determinism, see 15
Poseidon
Poseidon 13 is a hash function tailored for zero-knowledge proofs. Its input is a list of finite field elements, with the output given as a single field element. It is designed in a modular way, being appropriate in different scenarios, from Fiat-Shamir implementation to authenticated encryption 16.
Here we use it as a hash function to construct pointers, allowing us to have not only content addressable storage, but also a clean solution for recursion, since pointers can be used to represent expressions that contain other expressions in a natural way.
Figure 16: Allocated pointer
Figure 17: Hash preimage
Poseidon circuit
Neptune 17 is a rust implementation of Poseidon, which allows efficient proof construction. In particular, the total number of constraints is 286 for the 4-ary instantiation, 334 for the 6-ary instantiation, and 385 for the 8-ary instantiation
Pointers in the circuit
While pointers are implemented outside the circuit using dictionaries, we need a different solution for zero-knowledge circuits. One solution is to use hash functions as for example occurs in the construction of Merkle Trees. Poseidon hash function is allegedly a good solution for Lurk pointers, since it can be implemented using a small number of R1CS constraints.
As shown previously, each possible expression or continuation can be represented using at most 8 field elements. Therefore, we designed the system to allow the hash preimage to be formed by at most 4 components, as shown in Figure 17
Next, we describe in detail how we construct pointers in the circuit. In summary, a cons operation requires the 4-ary hash, functions require the 6-ary hash, and generic pointers require 8-ary hash.
Let a=CONSTRUCT(cont-tag,components):
Call hash=POSEIDON(components) - using the 8-ary hash.
Return ALLOC-PTR(cont-tag,hash).
Let CONSTRUCT-CONS(car,cdr):
Let hash=POSEIDON(car,cdr), using the 4-ary hash.
Return ALLOC-PTR(globals.cons-tag,hash).
Let CONSTRUCT-FUN(arg,body,closed-env):
Call hash=POSEIDON(arg,body,closed), using the 6-ary hash.
Return ALLOC-PTR(globals.fun-tag,hash).
Let CONSTRUCT-LIST(elts):
Let first=elts[0].
Let rest-of-elts=elts[1..].
Call tail=CONSTRUCT-LIST(rest-of-elts).
Return CONSTRUCT-CONS(first,tail).
Let CONSTRUCT-THUNK(val,cont):
Error system
Lurk programs can generate error continuations, which indicate an unrecoverable situation. This error is used to finalize the computation. Therefore, it is possible to construct zero-knowledge proofs that a program generated this error. It is important to remark that the error doesn’t carry detailed information to identify the cause of the error. The error can happen for malformed programs, like unary operations that receive more than one argument, or binary operations that receive fewer than two arguments. It can also happen if someone tries to divide by zero. However, the zero-knowledge proofs do not reveal which kind of error occurred.
As an example, when we try to divide by zero, the final output continuation is Error, which is a global pointer used instead of Terminal, such that a verifier can recognize that this program didn’t finish successfully.
Now we can start to describe how Lurk programs are translated into R1CS constraints. The strategy is to follow a top-down approach, by first showing some high-level components and saying how they interact. Later we can present the low-level construction of constraints.
Self-evaluated expressions:
Some expressions already are in the final stage of evaluation (as, for example, literal expressions like NIL, literal numbers, and characters). The complete list of self-evaluated expressions is given by the following tags: nil, num, fun, char, str, comm.
Unary expressions:
As the name suggests, unary expressions correspond to operations that receive only one input argument, which in Lurk are given by expressions that must be evaluated before the unary operation is evaluated.
atom. It returns true if and only if the input argument is not a list.
car. This operation receives as input a list and returns its first element.
cdr. Complementary to the previous item, it receives a list and returns everything except for the first element.
emit. This operation emits an intermediary output expression, so that it can be externally viewed.
commit. Creates a commitment to the received expression.
open. It is used to open a commitment.
secret. It returns the secret element used to create a given commitment.
num. Interprets the finite field element as a number.
u64. Interprets the finite field element as a 64-bit unsigned integer.
comm. Interprets the finite field element as a commitment.
char. Interprets the finite field element as a character.
Below is example of a unary expression evaluation.
As a first step, a unop continuation is created, pointing to the outermost continuation. Since 42 is a self-evaluated expression, we can continue the evaluation of this continuation, by returning T.
Binary expressions:
+. Addition of the received elements.
-. Subtraction of the received elements.
*. Multiplication of the received elements.
/. Division of the received elements.
%. Modular reduction of received elements.
>. Greater than.
>=. Greater than or equal.
>. Less than.
<=. Less than or equal.
=. Equality test between numbers.
eq. Equality test between pointers.
cons. Creates a list formed by the concatenation of the received elements.
strcons. Creates a list formed by the concatenation of a char and a string.
begin. Allows evaluation of multiple expressions, returning the result of the last one.
hide. Uses the first argument as a secret to create a commitment to the second argument.
Here is an example showing how Lurk evaluates an addition:
The addition expression is reduced by creating a binop continuation containing the second input argument. The first argument is a self-evaluated expression, after which we can create a binop2 continuation, where the second argument will be evaluated, and the sum of both results is returned as a terminal continuation.
Equality expressions:
Equality operators are binary operations, therefore we have binop and binop2 continuations.
=. Used to check equality of numbers.
eq. Used to check equality of pointers.
Comparison expressions:
>. Greater than operation.
>=. Greater than or equal operation.
<. Less than operation.
<=. Less than or equal operation.
Conditional expression:
if. Receives 3 expressions as arguments. If the first one evaluates to something different from nil, then the result is given by the evaluation of the second expression. Otherwise, the result is the evaluation of the third one.
Functional commitments
General work related to functional commitments can be found here 181920.
Functional commitments having a function-privacy property, specifically, are described in this paper 21. We implement functional commitments as first-class Lurk operations. Specifically, we use cons in order to compute the hash of a function and a secret number. Later, we can prove this function evaluates to determined values. In order to construct functional commitments, we need some building blocks, which Lurk provides natively, and whose high-level description is the following:
hide(secret, maybe-payload)
If there aren't exactly 2 input arguments, return error.
Otherwise, compute the commitment using all arguments as input of a 3-ary Poseidon instance.
Allocate a pointer to it.
Return this pointer.
commit(payload)
If there isn’t exactly 1 input argument, return error.
Otherwise, compute the commitment, using zero as the value of the secret.
Allocate a pointer to it.
Return this pointer.
open(commitment)
If there isn’t exactly 1 input argument, return error.
Otherwise, if the commitment is known, find the corresponding pair (secret, payload).
Allocate a pointer to the payload.
Return this pointer.
secret(commitment)
If there isn’t exactly 1 input argument, return error.
Otherwise, if the commitment is known, find the corresponding pair (secret, payload).
Allocate a pointer to the secret.
Return this pointer.
comm(value)
Take as input a pointer to the value, and it finds the field element given by value.hash().
Allocate a pointer whose tag is globals.comm-tag and the hash is given value.hash().
Return this pointer.
The commitment scheme is implemented by concatenating a pointer and a secret field element and computing the Poseidon hash function. Since functions are defined using lambda expressions, we obtain functional commitments basically for free.
Globals
We allocate constants in order to represent global data. For instance, we have global pointers, such as the terminal pointer, which can only be used in the last frame to indicate a program finished successfully. We also have an error pointer for programs that didn’t finish correctly. The first frame determines the outermost continuation, which has an outermost continuation pointer. Another important global pointer is the one that points to the symbol nil.
We also have global constants for each different tag in the system. Those constants are useful for comparing runtime data and determining which kind of pointer we are dealing with.
Beyond that, we have constants for Boolean variables. We pair true with 1 and false with 0. Finally, we have a constant of value 0 for default numbers.
Reduce expression
In this section, we explain the step-by-step reduction of complex expressions.
First, we provide an overview in Algorithm 3.1, which constructs multicase clauses selected based on the expression tag. Each distinct tag has a different reduction method, but the overall process remains the same. The result of the reduction provides a new triple of expression, environment, and continuation. Additionally, it determines whether we need to apply the continuation, resulting in a new IO consisting of the triple expression, environment, and continuation. For certain continuations, we need to create a thunk.
Reducing self-evaluated expressions is straightforward. The expression, environment, and continuation remain unchanged, but we must apply the continuation to the evaluated expression.
If the expression is a thunk, we need to verify hash consistency before applying the continuation.
If the expression is a symbol or a cons, we have two distinct scenarios that require detailed explanations. Therefore, we will dedicate a section to each scenario.
Let expr-is-cons=ALLOC-TAG-EQUAL(expr.tag(),globals.cons-tag).
Let reduce-cons-not-dummy=AND(expr-is-cons,cont-is-not-terminal-or-error).
Let (cons-result,cons-env,cons-cont,cons-apply-cont)=Reduce-cons(expr,env,cont,reduce-cons-not-dummy,witness,allocated-cons-witness,allocated-cont-witness).
Let first-result-expr=PICK(cont-is-terminal-or-error,globals.nil-ptr,first-result-expr0).
Let first-result-env=results[1].
Let first-result-cont=results[2].
Let first-result-apply-continuation=results[6].
Let apply-continuation-boolean0=ALLOC-IS-ZERO(first-result-apply-continuation).NOT().
Let apply-continuation-boolean=AND(apply-continuation-boolean0,cont-is-not-terminal-or-error).
Let apply-continuation-results=Apply-Continuation(expr,env,cont,witness,globals,flag).
Let apply-continuation-make-thunk=apply-continuation-results[3].
Let result-expr0=PICK(apply-continuation-boolean,apply-continuation-results[0],first-result-expr).
Let result-env0=PICK(apply-continuation-boolean,apply-continuation-results[1],first-result-env).
Let result-cont0=PICK(apply-continuation-boolean,apply-continuation-results[2],first-result-cont).
Let make-thunk-num=PICK(apply-continuation-boolean,apply-continuation-make-thunk,globals.false-num).
Let make-thunk-boolean=ALLOC-IS-ZERO(make-thunk-num).NOT().
Let thunk-results=Make-Thunk(result-cont0,result-expr0,result-env0,make-thunk-boolean,allocated-cont-witness).
Let result-expr-candidate=PICK(make-thunk-boolean,thunk-results[0],result-expr0).
Let result-env-candidate=PICK(make-thunk-boolean,thunk-results[1],result-env0).
Let result-cont-candidate=PICK(make-thunk-boolean,thunk-results[2],result-cont0).
Let result-expr=PICK(cont-is-terminal-or-error,expr,result-expr-candidate).
Let result-env=PICK(cont-is-terminal-or-error,env,result-env-candidate).
Let result-cont=PICK(cont-is-terminal-or-error,cont,result-cont-candidate).
Return (result-expr,result-env,result-cont).
Reduce symbol
To reduce symbol expressions, we must determine whether the symbol is a self-evaluated symbol or a variable that needs to be looked up in the environment. If it’s the latter, we must determine whether we have a regular or recursive environment. In a regular environment, we compare the given symbol with the first binding in the environment. If it’s the variable we’re looking for, we return the corresponding value in a thunk. Otherwise, we recursively call the lookup method in the remaining bindings in the environment. For a recursive environment, we follow the same strategy, but using closures when the value to be used is a function.
To carry out these steps, we first analyze whether the expression is a self-evaluated symbol, like NIL or T, or a variable name that we must look up in the environment. Distinguishing between these possibilities requires multiple booleans and using car-cdr to split the expression and environment in a way that lets us identify code paths leading to the end of the recursive lookup or error continuations.
Finally, we compute a boolean that identifies the control flow. In other words, we determine whether to apply the continuation or not. This boolean is crucial because we must always run the part of the circuit corresponding to apply_cont(), but we restrict the circuit to use dummy variables when this boolean value is false.
The algorithm receives as input a triple (expr,env,cont), together with a variable called not-dummy, which is a Boolean whose value is false when the input expression is not a symbol. In this case, Reduce-Sym constraints are not actually used, which means those constraints will contain only dummy values. The algorithm also receives the witness, the store, and the globals as input.
The circuit described here mimics the evaluation of expressions whose tag is equal to globals.sym-tag. However, if we follow exactly the same steps as implemented in eval.rs, then some constraints would be unnecessarily repeated. Hence, in order to eliminate those constraints, we need to pay the price of making it a bit harder to guarantee that the circuit implementation corresponds to what is implemented in eval.rs. We clarify here the differences between both worlds, and show why they are equivalent.
Let extended-env=CONSTRUCT-CONS-NAMED(rec-env,fun-closed-env,names.extended-closure-env,allocated-cons-witness,extended-env-not-dummy).
Let extended-fun=CONSTRUCT-FUN(fun-arg,fun-body,extended-env).
Let val-to-use=PICK(val2-is-fun,extended-fun,val2).
Let smaller-rec-env=val-or-more-rec-env.
Let smaller-rec-env-is-nil=smaller-rec-env.IS-NIL().
Let smaller-rec-env-not-nil=smaller-rec-env-is-nil.NOT().
Let v2-not-expr=v2-is-expr.NOT().
Let otherwise-and-v2-not-expr=AND(v2-not-expr,with-cons-binding).
Let smaller-rec-env-not-dummy=AND(smaller-rec-env-not-nil,otherwise-and-v2-not-expr).
Let rec-extended-env=CONSTRUCT-CONS-NAMED(smaller-rec-env,smaller-env,names.env-to-use,allocated-cons-witness,smaller-rec-env-not-dummy).
Let env-to-use=PICK(smaller-rec-env-is-nil,smaller-env,rec-extended-env).
Let cont-is-lookup=ALLOC-TAG-EQUAL(cont.tag(),globals.lookup-cont-tag.
Let needed-env-missing=AND(sym-otherwise,env-is-nil).
Let needed-binding-missing=AND(main,binding-is-nil).
Let with-sym-binding-matched=AND(with-sym-binding,v-is-expr1).
Let with-sym-binding-unmatched=AND(with-sym-binding,v-is-expr1.NOT()).
Let with-sym-binding-unmatched-old-lookup=AND(with-sym-binding-unmatched,cont-is-lookup).
Let with-sym-binding-unmatched-new-lookup=AND(with-sym-binding-unmatched,cont-is-lookup.NOT()).
Let with-cons-binding-matched=AND(with-cons-binding,v2-is-expr).
Let with-cons-binding-unmatched=AND(with-cons-binding,v2-is-expr.NOT()).
Let with-cons-binding-unmatched-old-lookup=AND(with-cons-binding-unmatched,cont-is-lookup).
Let with-cons-binding-unmatched-new-lookup=AND(with-cons-binding-unmatched,cont-is-lookup.NOT()).
Let lookup-continuation-not-dummy=OR(with-sym-binding-unmatched-new-lookup,with-cons-binding-unmatched-new-lookup).
Let lookup-continuation=CONSTRUCT-NAMED(names.lookup,globals.lookup-cont-tag,(env,cont,default-num-pair,default-num-pair),allocated-cont-witness,lookup-continuation-not-dummy).
Calculate output predicate:
Letoutput-expr-is-expr=EQUAL(output-expr,expr).
Let output-env-is-env=EQUAL(output-env,env).
Let output-cont-is-cont=EQUAL(output-cont,cont).
Let output-cont-is-error=EQUAL(output-cont,globals.error-ptr).
Let output-expr-is-val=EQUAL(output-expr,val).
Let output-env-is-smaller-env=EQUAL(output-env,smaller-env).
Let output-cont-is-lookup=EQUAL(output-cont,lookup-continuation).
Let output-expr-is-val-to-use=EQUAL(output-expr,val-to-use).
Let output-env-is-env-to-use=EQUAL(output-env,env-to-use).
Calculate conditions:
Let output-expr-should-be-expr=OR(needed-env-missing,sym-is-self-evaluating,needed-binding-missing,with-sym-binding-unmatched,with-cons-binding-unmatched).
Let output-expr-should-be-val=with-sym-binding-matched.
Let output-expr-should-be-val-to-use=with-cons-binding-matched.
Let output-env-should-be-env=OR(needed-binding-missing,needed-env-missing,sym-is-self-evaluating,with-sym-binding-matched,with-cons-binding-matched).
Let output-env-should-be-smaller-env=with-sym-binding-unmatched.
Let output-env-should-be-env-to-use=with-cons-binding-unmatched-new-lookup.
Let output-cont-should-be-cont=OR(sym-is-self-evaluating,with-sym-binding-matched,with-sym-binding-unmatched-old-lookup,with-cons-binding-matched,with-cons-binding-unmatched-old-lookup).
Let output-cont-should-be-error=OR(with-other-binding,needed-env-missing,needed-binding-missing).
In this section we describe the Reduce-Cons() algorithm, which is responsible for taking a cons expression and reducing it to a triple (expr, env, cont) to be evaluated next, and determining if the continuation will be applied or not.
In order to avoid calculating unnecessary hashes inside the circuit, we first use a multicase to select the preimage for the next continuation, then we compute – just once – the continuation pointer. Afterward, we use a second multicase to select the final result.
Circuit3.3Reduce-cons
INPUTexpr,env,cont,witness.
OUTPUTexpr,env,cont.
Compute preimage clauses.
Let preimage=MULTICASE(preimage-clauses).
Calculate newer continuation pointer.
Compute clauses.
result=MULTICASE(clauses).
Return result.
In Reduce-Sym() we dealt with expressions that correspond to just one symbol. On the other hand, Reduce-Cons() allows us to reduce more complicated expressions, since a cons expression can represent unary and binary operations. In particular, those operations have a list of parameters that themselves can be symbols or cons expressions.
Here we can describe how to reduce cons expressions:
A cons expression can also be used to represent lambda, let, and letrec operations. The first thing we do with a cons operation is to split its arguments into head – the first element of the expression, which corresponds to the car operation – and the rest of the elements, which correspond to the cdr operation. Next, we describe how we calculate the constraints and how we add one clause to the multicase gadget for each possible head.
Let (head,rest)=CAR-CDR-NAMED(expr,cons-names.expr,allocated-cons-witness,not-dummy).
Let head-is-lambda0=ALLOC-EQUAL(head.hash(),globals.lambda-sym.hash()).
Let head-is-let=ALLOC-EQUAL(head.hash(),globals.let-sym.hash()).
Let head-is-letrec=ALLOC-EQUAL(head.hash(),globals.letrec-sym.hash()).
Let head-is-eval=ALLOC-EQUAL(head.hash(),globals.eval-sym.hash()).
Let head-is-quote0=ALLOC-EQUAL(head.hash(),globals.quote-sym.hash()).
Let head-is-cons=ALLOC-EQUAL(head.hash(),globals.cons-sym.hash()).
Let head-is-hide=ALLOC-EQUAL(head.hash(),globals.hide-sym.hash()).
Let head-is-commit=ALLOC-EQUAL(head.hash(),globals.commit-sym.hash()).
Let head-is-open=ALLOC-EQUAL(head.hash(),globals.open-sym.hash()).
Let head-is-secret=ALLOC-EQUAL(head.hash(),globals.secret-sym.hash()).
Let head-is-num=ALLOC-EQUAL(head.hash(),globals.num-sym.hash()).
Let head-is-u64=ALLOC-EQUAL(head.hash(),globals.u64-sym.hash()).
Let head-is-comm=ALLOC-EQUAL(head.hash(),globals.comm-sym.hash()).
Let head-is-char=ALLOC-EQUAL(head.hash(),globals.char-sym.hash()).
Let head-is-begin=ALLOC-EQUAL(head.hash(),globals.begin-sym.hash()).
Let head-is-car=ALLOC-EQUAL(head.hash(),globals.car-sym.hash()).
Let head-is-cdr=ALLOC-EQUAL(head.hash(),globals.cdr-sym.hash()).
Let head-is-atom=ALLOC-EQUAL(head.hash(),globals.atom-sym.hash()).
Let head-is-emit=ALLOC-EQUAL(head.hash(),globals.emit-sym.hash()).
Let head-is-plus=ALLOC-EQUAL(head.hash(),globals.plus-sym.hash()).
Let head-is-minus=ALLOC-EQUAL(head.hash(),globals.minus-sym.hash()).
Let head-is-times=ALLOC-EQUAL(head.hash(),globals.times-sym.hash()).
Let head-is-div=ALLOC-EQUAL(head.hash(),globals.div-sym.hash()).
Let head-is-mod=ALLOC-EQUAL(head.hash(),globals.mod-sym.hash()).
Let head-is-numequal=ALLOC-EQUAL(head.hash(),globals.numequal-sym.hash()).
Let head-is-eq=ALLOC-EQUAL(head.hash(),globals.eq-sym.hash()).
Let head-is-less=ALLOC-EQUAL(head.hash(),globals.less-sym.hash()).
Let head-is-less-equal=ALLOC-EQUAL(head.hash(),globals.less-equal-sym.hash()).
Let head-is-greater=ALLOC-EQUAL(head.hash(),globals.greater-sym.hash()).
Let head-is-greater-equal=ALLOC-EQUAL(head.hash(),globals.greater-equal-sym.hash()).
Let head-is-if0=ALLOC-EQUAL(head.hash(),globals.if-sym.hash()).
Let head-is-current-env0=ALLOC-EQUAL(head.hash(),globals.current-env-sym.hash()).
Let head-is-a-sym=IS-SYM(head).
Let head-is-fun=IS-FUN(head).
Let head-is-a-cons=IS-CONS(head).
Let head-is-binop0=OR(head-is-cons,head-is-strcons,head-is-hide,head-is-begin,head-is-plus,head-is-minus,head-is-times,head-is-div,head-is-mod,head-is-equal,head-is-eq,head-is-less,head-is-less-equal,head-is-greater,head-is-greater-equal,head-is-if,head-is-eval).
Let head-is-binop=AND(head-is-binop0,head-is-a-sym).
Let head-is-unop0=OR(head-is-car,head-is-cdr,head-is-commit,head-is-num,head-is-u64,head-is-comm,head-is-char,head-is-open,head-is-secret,head-is-atom,head-is-emit,head-is-eval.
Let head-is-let-or-letrec0=OR(head-is-let,head-is-letrec).
Let head-is-let-or-letrec=AND(head-is-let-or-letrec0,head-is-a-sym).
Let head-is-lambda=AND(head-is-lambda0,head-is-a-sym).
Let head-is-quote=AND(head-is-quote0,head-is-a-sym).
Let head-is-current-env=AND(head-is-current-env0,head-is-a-sym).
Let head-is-if=AND(head-is-if0,head-is-a-sym).
Let head-is-any=OR(head-is-quote,head-is-if,head-is-lambda,head-is-current-env,head-is-let-or-letrec,head-is-unop,head-is-binop).
Let head-potentially-fun-type=OR(head-is-a-sym,head-is-a-cons,head-is-fun).
Let head-potentially-fun=AND(head-potentially-fun-type,head-is-any.not()).
Let rest-is-nil=rest.IS-NIL().
Let rest-is-cons=IS-CONS(rest).
Let expr-cdr-not-dummy=AND(not-dummy,rest-is-nil.NOT(),rest-is-cons,head-is-any,head-is-current-env.NOT()).
Let is-dotted-error=AND(rest-is-nil.NOT(),rest-is-cons.NOT(),expr-cdr-not-dummy.NOT()).
Let (arg1,more)=CAR-CDR-NAMED(rest,cons-names.expr-cdr,allocated-cons-witness,expr-cdr-not-dummy).
Let more-is-nil=ALLOC-EQUAL(more,globals.nil-ptr).
Let is-binop-missing-arg-error=AND(head-is-binop,more-is-nil,head-is-begin.NOT(),head-is-eval.NOT()).
Let arg1-is-cons=IS-CONS(arg1).
Let arg1-is-str=IS-STR(arg1).
Let arg1-is-nil=arg1.IS-NIL().
Let expr-cadr-not-dummy0=OR(arg1-is-cons,arg1-is-nil,arg1-is-str).
Let expr-cadr-not-dummy=AND(expr-cdr-not-dummy,expr-cadr-not-dummy0,head-is-lambda).
Let (car-args,cdr-args)=CAR-CDR-NAMED(arg1,cons-names.expr-cadr,allocated-cons-witness,expr-cadr-not-dummy).
Let end-is-nil=more.IS-NIL().
In many cases, we need to compute a new pointer, which requires the calculation of the hash function. Since this operation is expensive in the circuit, we want to avoid it as much as possible. In order to do that, we use a multicase gadget to select the preimage in those situations. Then we compute the hash only once. Later, a second multicase is used to select the final result.
Let default-num-pair=(globals.default-num,globals.default-num).
Caseheadislambda.
A lambda expression receives a list of arguments and a body expression as input. We use one-argument lambdas as building blocks. In order to deal with multiple arguments, we recursively use nested one-argument lambdas. If the list of arguments is empty, we use a dummy argument instead. Otherwise, we take the first argument to build the function and construct an inner body that itself is another lambda expression containing the rest of the list of arguments and the same body, and use that function to call apply-continuation.
Let (args,body)=(arg1,more).
Let args-is-nil=args.IS-NIL().
Let cdr-args-is-nil=cdr-args.IS-NIL().
Let arg=PICK(args-is-nil,globals.dummy-arg-ptr,car-args).
Let arg-is-sym=arg.IS-SYM().
Let lambda-not-dummy=AND(head-is-lambda,not-dummy,cdr-args-not-nil).
Let inner-not-dummy=AND(lambda-not-dummy,cdr-args-is-nil.NOT()).
Let inner=CONSTRUCT-CONS-NAMED(cdr-args,body,cons-names.inner-lambda,allocated-cons-witness,inner-not-dummy).
Let l=CONSTRUCT-CONS-NAMED(globals.lambda-sym,inner,cons-names.lambda,allocated-cons-witness,inner-not-dummy).
Let list=CONSTRUCT-CONS-NAMED(l,globals.nil-ptr,cons-names.inner-body,allocated-cons-witness,inner-not-dummy).
Let inner-body=PICK(cdr-args-is-nil,body,list).
Let function=CONSTRUCT-FUN(arg,inner-body,env).
Let lambda-arg-error=AND(arg-is-sym.NOT(),lambda-not-dummy).
Let lambda-expr=PICK(lambda-arg-error,expr,function).
Let lambda-cont=PICK(lambda-arg-error,globals.error-ptr-cont,cont).
Because both let and letrec share common subcircuits, we are going to factor them out in order to reduce the number of constraints.
Initially, we have:
Let (bindings, body) = (arg1, more).
Let let-letrec-not-dummy=AND(not-dummy,head-is-let-or-letrec).
Let (bindings,body)=(arg1,more).
Let (body1,rest-body)=CAR-CDR-NAMED(body,cons-names.expr-cddr,allocated-cons-witness,let-letrec-not-dummy).
Let bindings-is-nil=binding.IS-NIL().
Let bindings-is-cons=ALLOC-EQUAL(bindings.tag(),globals.cons-tag).
Let body-is-nil=body.IS-NIL().
Let rest-body-is-nil=rest-body.IS-NIL().
A let or a letrec expression will receive a list of bindings to be added to the environment and a body expression to be evaluated. If the list of bindings is empty, we just return it together with the current environment and continuation. If not, we take the first element of the list, add it to the environment, and recursively use another let or letrec to evaluate the rest of the list.
Let (binding1,rest-bindings)=(car-args,cdr-args).
Let expr-caadr-not-dummy=AND(rest-body-is-nil,body-is-nil.NOT(),bindings-is-cons,bindings-is-nil.NOT(),let-letrec-not-dummy).
Let (var-let-letrec,vals)=CAR-CDR-NAMED(binding1,cons-names.expr-caadr,allocated-cons-witness,expr-caadr-not-dummy).
Let var-let-letrec-is-sym=var-let-letrec.IS-SYM().
Let var-let-letrec-is-nil=var-let-letrec.IS-NIL().
Let var-let-letrec-is-list=OR(var-let-letrec-is-sym,var-let-letrec-is-nil).
Let expr-caaadr-not-dummy=AND(expr-caadr-not-dummy,var-let-letrec-is-list).
let (val,end)=CAR-CDR-NAMED(vals,cons-names.expr-caaadr,allocated-cons-witness,expr-caadr-not-dummy).
Let end-is-nil=end.IS-NIL().
Let cond-error=OR(rest-body-is-nil.NOT(),end-is-nil.NOT(),body-is-nil,var-let-letrec-is-list.NOT()).
Let rest-bindings-is-nil=rest-bindings.IS-NIL().
Let expanded-inner-not-dummy0=AND(rest-bindings-is-nil.NOT(),end-is-nil).
Let expanded-inner-not-dummy=AND(expanded-inner-not-dummy0,let-letrec-not-dummy,body-is-nil.NOT(),rest-body-is-nil).
Let expanded0=CONSTRUCT-CONS-NAMED(rest-bindings,body,cons-names.expanded-inner,allocated-cons-witness,expanded-inner-not-dummy).
Let expanded1=CONSTRUCT-CONS-NAMED(head,expanded0,cons-names.expanded,allocated-cons-witness,expanded-inner-not-dummy).
Let expanded=PICK(rest-bindings-is-nil,body1,expanded1).
Let output-expr=PICK(bindings-is-nil,body1,val).
Let the-expr=PICK(cond-error,expr,output-expr).
Let expanded-let=expanded.
Let expanded-letrec=expanded.
Let let-continuation-components=[var-let-letrec,expanded-let,env,cont].
Let the-op=PICK(end-is-nil,globals.unop-cont-tag,globals.binop-cont-tag).
Let op1-or-op2=PICK(end-is-nil,globals.op1-eval-tag,globals.op2-eval-tag).
Let cont-or-env-tag=PICK(end-is-nil,cont.tag(),env.tag()).
Let cont-or-env-hash=PICK(end-is-nil,cont.hash(),env.hash()).
Let default-or-expr-tag=PICK(end-is-nil,globals.default-num,more.tag()).
Let default-or-expr-hash=PICK(end-is-nil,globals.default-num,more.hash()).
Let default-or-cont-tag=PICK(end-is-nil,globals.default-num,cont.tag()).
Let default-or-cont-hash=PICK(end-is-nil,globals.default-num,cont.hash()).
Let eval-continuation-components=[[op1-or-op2,globals.default-num],[env-or-cont-tag,env-or-cont-hash],[default-or-expr-tag,default-or-expr-hash],[default-or-cont-tag,default-or-cont-hash]].
Let cont-is-terminal=ALLOC-TAG-EQUAL(cont.tag(),globals.terminal-tag()).
Let cont-is-dummy=ALLOC-TAG-EQUAL(cont.tag(),globals.dummy-tag()).
Let cont-is-error=ALLOC-TAG-EQUAL(cont.tag(),globals.error-tag()).
Let cont-is-outermost=ALLOC-TAG-EQUAL(cont.tag(),globals.outermost-tag()).
Let cont-is-trivial=OR(cont-is-terminal,cont-is-dummy,cont-is-error,cont-is-outermost).
Let apply-continuation-components-not-dummy=AND(cont-is-trivial.NOT(),not-dummy).
Let (continuation-tag,continuation-components)=get-named-components(cont,cons-names.apply-continuation,allocated-cont-witness,apply-continuation-components-not-dummy).
Let cons-tag=PICK(is-strcons,globals.str-tag,globals.cons-tag).
Let comm-or-num-tag=PICK(op2-is-hide,globals.comm-tag,globals.num-tag).
Let is-cons-or-hide=OR(is-cons,op2-is-hide).
Let is-cons-or-strcons-or-hide-or-equal=OR(is-cons-or-hide,is-strcons,is-equal).
Let is-cons-or-strcons-or-hide-or-equal-or-num-equal=OR(is-cons-or-strcons-or-hide-or-equal,is-num-equal).
Let res-tag0=PICK(is-cons-or-strcons,cons-tag,comm-or-num-tag).
Let res-tag=PICK(is-equal-or-num-equal,args-equal-ptr.tag(),res-tag0).
Let res=ALLOC-FROM-PARTS(res-tag,val).
Let (is-comparison-tag,comp-val,diff-is-negative)=COMPARISON-HELPER(a,b,diff,p2.tag()).
Let field-arithmetic-result=PICK(is-comparison-tag,comp-val,res).
Let field-arithmetic-result-plus-2p64=ADD(field-arithmetic-result.hash(),globals.power2-64-num).
Let op2-is-diff=ALLOC-TAG-EQUAL(op2.tag(),globals.op2-diff-tag).
Let diff-is-negative-and-op2-is-diff=AND(diff-is-negative,op2-is-diff).
Let field-arith-and-u64-diff-result=PICK(diff-is-negative-and-op2-is-diff,field-arithmetic-result-plus-2p64,field-arithmetic-result.hash()).
Let coerce-to-u64=TO-U64(field-arith-and-u64-diff-result).
Let coerce-to-u64-ptr=from-parts(globals.u64-tag,coerce-to-u64).
Let both-args-are-u64s-and-not-comparison=AND(both-args-are-u64s,is-comparison-tag.NOT()).
Let partial-u64-result=PICK(both-args-are-u64s-and-not-comparison,coerce-to-u64-ptr,field-arithmetic-result).
Let (alloc-q,alloc-r)=ENFORCE-U64-DIV-MOD(op2-is-mod,arg1,arg2).
Let alloc-q-ptr=from-parts(globals.u64-tag,alloc-q).
Let alloc-r-ptr=from-parts(globals.u64-tag,alloc-r).
Let op2-is-div-and-args-are-u64s=AND(op2-is-div,both-args-are-u64s).
Let include-u64-quotient=PICK(op2-is-div-and-args-are-u64s,alloc-q-ptr,partial-u64-result).
Let op2-is-mod-and-args-are-u64s=AND(op2-is-mod,both-args-are-u64s).
Let op2-is-mod-and-args-are-not-u64s=AND(op2-is-mod,both-args-are-u64s.NOT()).
Let arithmetic-result=PICK(op2-is-mod-and-args-are-u64s,alloc-r-ptr,include-u64-quotient).
Let valid-types=OR(is-cons-or-strcons-or-hide-or-equal,args-are-num-or-u64).
Let real-div-or-more-and-b-is-zero=AND(not-dummy,op2-is-div-or-mod,b-is-zero).
Let valid-types-and-not-div-by-zero=AND(valid-types,real-div-or-more-and-b-is-zero.NOT()).
Let op2-not-num-or-u64-and-not-cons-or-strcons-or-hide-or-equal-or-num-equal=AND(args-are-num-or-u64.NOT(),is-cons-or-strcons-or-hide-or-equal-or-num-equal.NOT()).
Let invalid-secret-tag-hide=AND(arg1-is-u64,op2-is-hide).
Let op2-is-hide-and-arg1-is-not-num=AND(op2-is-hide,arg1-is-num.NOT()).
Let any-error=OR(valid-types-and-not-div-by-zero.NOT(),op2-not-num-or-u64-and-not-cons-or-strcons-or-hide-or-equal-or-num-equal,invalid-strcons-tag,op2-is-hide-and-arg1-is-not-num,op2-is-mod-and-args-are-not-u64s,invalid-secret-tag-hide).
Let op2-is-eval=ALLOC-TAG-EQUAL(op2.tag(),globals.op2-eval-tag).
Let the-cont0=PICK(any-error,globals.error-ptr-cont,continuation).
Let the-cont=PICK(op2-is-eval,continuation,the-cont0).
Let the-expr0=pick(any-error,result,arithmetic-result).
Let the-expr=PICK(op2-is-eval,arg1,the-expr0).
Let the-env=PICK(op2-is-eval,arg2,env).
Let make-thunk-num=BOOLEAN-TO-NUM(op2-is-eval.NOT()).
Let other-unop-continuation=continuation-components[1].
Let op1-is-emit=ALLOC-TAG-EQUAL(g.op1-emit-tag,unop-op1.tag()).
Let op1-is-eval=ALLOC-TAG-EQUAL(g.op1-eval-tag,unop-op1.tag()).
Let unop-continuation0=PICK(op1-is-emit,newer-cont2,other-unop-continuation).
Let unop-continuation=PICK(op1-is-eval,continuation,unop-continuation0).
Let result-is-cons=ALLOC-TAG-EQUAL(g.cons-tag,result.tag()).
Let result-is-str=ALLOC-TAG-EQUAL(g.str-tag,result.tag()).
Let result-is-nil=result.IS-NIL().
Let car-cdr-is-valid=OR(result-is-cons,result-is-str,result-is-nil).
Let op1-is-car=ALLOC-TAG-EQUAL(g.op1-car-tag,unop-op1.tag()).
Let op1-is-cdr=ALLOC-TAG-EQUAL(g.op1-cdr-tag,unop-op1.tag()).
Let op1-is-car-or-cdr=OR(op1-is-car,op1-is-cdr).
Let car-cdr-is-invalid=AND(op1-is-car-or-cdr,car-cdr-is-valid.NOT()).
Let op1-is-comm=ALLOC-TAG-EQUAL(globals.op1-comm-tag,unop-op1.tag()).
Let op1-is-num=ALLOC-TAG-EQUAL(globals.op1-num-tag,unop-op1.tag()).
Let op1-is-char=ALLOC-TAG-EQUAL(globals.op1-char-tag,unop-op1.tag()).
Let op1-is-open=ALLOC-TAG-EQUAL(globals.op1-open-tag,unop-op1.tag()).
Let op1-is-secret=ALLOC-TAG-EQUAL(globals.op1-secret-tag,unop-op1.tag()).
Let op1-is-u64=ALLOC-TAG-EQUAL(globals.op1-u64-tag,unop-op1.tag()).
Let tag-is-char=ALLOC-TAG-EQUAL(globals.char-tag,result.tag()).
Let tag-is-num=ALLOC-TAG-EQUAL(globals.num-tag,result.tag()).
Let tag-is-comm=ALLOC-TAG-EQUAL(gloabls.comm-tag,result.tag()).
Let tag-is-u64=ALLOC-TAG-EQUAL(globals.u64-tag,result.tag()).
Let tag-is-num-or-comm=OR(tag-is-num,tag-is-comm).
Let tag-is-num-or-char=OR(tag-is-num,tag-is-char).
Let tag-is-num-or-comm-or-char=OR(tag-is-num-or-comm,tag-is-char).
Let tag-is-num-or-comm-or-char-or-u64=OR(tag-is-num-or-comm-or-char,tag-is-u64).
Let comm-invalid-tag-error=AND(tag-is-num-or-comm.NOT(),op1-is-comm).
Let num-invalid-tag-error=AND(tag-is-num-or-comm-or-char-or-u64.NOT(),op1-is-num).
Let char-invalid-tag-error=AND(tag-is-num-or-char.NOT(),op1-is-char).
Let open-invalid-tag-error=AND(tag-is-num-or-comm.NOT(),op1-is-open).
Let secret-invalid-tag-error=AND(tag-is-num-or-comm.NOT(),op1-is-secret).
Let u64-invalid-tag-error=AND(op1-is-u64,tag-is-num.NOT()).
Let any-error=OR(car-cdr-is-invalid,comm-invalid-tag-error,num-invalid-tag-error,char-invalid-tag-error,open-invalid-tag-error,secret-invalid-tag-error,u64-invalid-tag-error).
Let the-expr=PICK(any-error,result,unop-val).
Let the-env=PICK(op1-is-eval,globals.nil-ptr,env).
Let the-cont=PICK(any-error,globals.error-ptr-cont,unop-continuation).
Let make-thunk-num=BOOLEAN-TO-NUM(op1-is-eval.NOT()).
Let newer-cont2-not-dummy0=AND(op1-is-emit,any-error.NOT()).
Let newer-cont2-not-dummy=BOOLEAN-NUM(newer-cont2-not-dummy0).
A thunk is constructed to finish a determined layer of computation. For a certain operation, when all the input expressions are evaluated and the result is obtained in the apply-cont function, we build a thunk containing this result and an appropriate continuation pointer. We use it to terminate a Lurk program, by mapping the outermost continuation into a terminal continuation, or by propagating terminal and error continuations accordingly. Beyond that a thunk is used to return a value to the next stacked continuation, which happens for unop, binop2, lookup, tail and emit.
This circuit is used to extend the recursive environment receives as input the environment env and allocated pointers to a variable var and a value val.
Let (binding-or-env,rest)=CAR-CDR-NAMED(env,cons-names.env,allocate-cons-witness,not-dummy).
Let (var-or-binding,dummy-val-or-more-bindings)=CAR-CDR-NAMED(binding-or-env,cons-names.env-car,allocated-cons-witness,not-dummy).
Let var-or-binding-is-cons=IS-CONS(var-or-binding).
Let cons=CONSTRUCT-CONS-NAMED(var,val,cons-names.new-rec-cadr,allocated-cons-witness,not-dummy).
Let list=CONSTRUCT-CONS-NAMED(cons,globals.nil-ptr,cons-names.NewRec,allocated-cons-witness,not-dummy).
Let new-env-if-sym-or-nil=CONSTRUCT-CONS-NAMED(list,env,cons-names.extended-rec,allocated-cons-witness,not-dummy).
Let cons-branch-not-dummy=AND(var-or-binding-is-cons,not-dummy).
Let cons2=CONSTRUCT-CONS-NAMED(cons,binding-or-env,cons-names.new-rec,allocated-cons-witness,cons-branch-not-dummy).
Let cons3=CONSTRUCT-CONS-NAMED(cons2,rest,cons-names.extended-rec,allocated-cons-witness,cons-branch-not-dummy).
Let is-sym=var-or-binding.IS-SYM().
Let is-nil=var-or-binding.IS-NIL().
Let is-sym-or-nil=OR(is-sym,is-nil).
Let is-cons=var-or-binding-is-cons.
Let new-env-if-cons=PICK(is-cons,cons3,globals.error-ptr).
Let extended-env=PICK(is-sym-or-nil,new-env-if-sym-or-nil,new-env-if-cons.
Return extended-env.
Low-level description
In this section, we describe how R1CS constraints are constructed for each building block previously used in the construction of the Lurk circuit. Those components are usually called gadgets. Below is a description of how to obtain R1CS constraints for each necessary gadget.
R1CS
We denote the witness by w, which corresponds to all the intermediate values of the subjacent program. Matrices A,B,C contain the public values of the program, encoding all the constraints that the witness will have to satisfy. Specifically, it constrains the composition of gadgets that implement the reduction step. Hence, it is responsible for the validation of frame transitions.
Each frame has an Input and Output (IO), both constituted by the triple (expr,env,cont). The input expression is reduced frame by frame, generating a sequence of IOs where the output of a previous frame is equal to the input of the next frame. The IO is part of the witness and therefore corresponds to private data constrained in w. We say the w satisfies A,B,C if the following relation is respected:
(A.w)∘(B.w)=(C.w)
where ∘ signifies component multiplication.
Circuit components
In this section we describe each gadget that is necessary for the construction of the Lurk circuit in detail. We start by showing how to construct logic operations like conjunctions and disjunctions, and then we show how to implement pointers using Poseidon. Next, we present gadgets to carry out arithmetic operations, bit decomposition, and comparisons. We finally describe how to implement ternary operators, which are used to construct the case gadget, which can be used to select one among many clauses, given a key element. Multiple case gadgets can be composed into a single multicase gadget, which allows us to select multiple clauses while requiring fewer constraints than if we simply repeated the case gadget multiple times.
In order to describe R1CS constraints, we use a short notation showing the multiplications involving certain linear combinations of private variables. From this, we consider it trivial to derive the formal description presented above.
Boolean operations
Here we show how to compute Boolean operations.
a.NOT(): given the field element a that is guaranteed to be either 0 or 1, we don't need to use multiplications in order to calculate the NOT function. Instead, NOT can be computed by using the linear combination (1–a).
AND(a,b): given two bits, a and b, as input, we calculate the AND function using the following multiplication:
Constrain a×b=result, where result corresponds to the output of the AND function.
OR(a,b): given bits a and b, the OR function can be calculated using both NOT and AND, using De Morgan’s law. The output is AND(a.NOT(),b.NOT()).NOT().
XOR(a,b): given bits a and b, the XOR function can be calculated using the formula 2.a×b=a+b–c, where c is the result. This relation can easily be checked to be valid if and only if c=a⊕b.
ENFORCE-IMPLICATION(a,b): given bits a and b, we say that a implies b if the following conditions hold:
Call implication=IMPLIES(a,b).
Call ENFORCE-TRUE(implication).
ENFORCE-EQUAL(a,b): given bits a and b, we enforce equality of bits using the constraint
Constrain (a–b)×1=0.
ENFORCE-TRUE(a): given bit a, we call ENFORCE-EQUAL(a, 1).
ENFORCE-FALSE(a): given bit a, we call ENFORCE-EQUAL(a, 0).
ENFORCE-BIT(a): given bit a, we
Constrain (1–a)×a=0.
IMPLIES(a,b): equivalent to AND(a,b.NOT()).NOT().
In Table 1 we show how many constraints and witnesses are required to construct each gadget.
Table 1: Logic gadgets summary
Gadget
Constraints
Witnesses
NOT
0
0
OR
1
1
AND
1
1
XOR
1
1
ENFORCE-IMPLICATION
2
1
ENFORCE-EQUAL
1
0
ENFORCE-TRUE
1
0
ENFORCE-FALSE
1
0
ENFORCE-BIT
1
0
IMPLIES
1
1
Pointers
In this section we describe the construction of gadgets for pointers. The basic building block is Poseidon gadget, which can be instantiated in different ways. What distinguishes each instantiation is the number of input field elements. As we increase the number of input elements, we also increase the circuit size of the gadget. Since each pointer needs two field elements to represent it, in order to create a gadget for cons operation, we to pass two allocated pointers as input, therefore we need 4 field elements and, consequently, we have to use the 4-ary instantiation of Poseidon gadget. Analogously, we need 6-ary instantiation of Poseidon gadget to construct a pointer for function, since it needs to pass as input 3 allocated pointers, namely the argument, the body and the environment. Finally, we may need to pass 4 allocated pointers to create generic pointers, as for example is required for some continuation pointers.
Next we describe different gadgets that we provide to construct different types of pointers.
ALLOC-NUM(n): given a field element n as input, it allocates a new variable whose value is n. There is no constraint for this gadget. It is only necessary to allocate a new witness in the circuit.
ALLOC-PTR(tag,hash): given a pair of field elements, tag and hash as input, it proceed as follows:
ALLOC-NUM(tag).
ALLOC-NUM(hash).
ALLOC-CONSTANT(c): given a field element c as input, it allocates a new pointer whose value is c. Namely, the constraint is implemented as:
Constrain allocated−output×1=c.
ALLOC-CONSTANT-PTR(c): given a pointer c to a constant value, it allocates the pointer in the circuit as follows:
Let alloc-tag=ALLOC-CONSTANT(c.tag()).
Let alloc-hash=ALLOC-CONSTANT(c.hash()).
ALLOC-CONSTANT-CONT-PTR(c): given a continuation pointer c to a constant value, it allocates the pointer in the circuit as follows:
Let alloc-tag=ALLOC-CONSTANT(c.tag()).
Let alloc-hash=ALLOC-CONSTANT(c.hash()).
ALLOC-FROM-PARTS(tag,hash): it receives as input two field elements, as allocated numbers, that can be used to allocate a new pointer in the circuit. No constraints are required for this purpose.
ALLOC-EQUAL(a,b): it receives as input two allocated numbers, a and b, and as output it gives us a Boolean that indicates if a is equal to b or not.
Let diff=SUB(a–b).
Let result=ALLOC-BIT(a==b).
Constrain result×diff=0.
Constrain (diff+result)×q=1.
ALLOC-TAG-EQUAL(a,b): it receives as input two numbers, where a is an allocated number and b is a constant, and as output it gives us a Boolean that indicates if a is equal to b or not. It works in the same way as ALLOC-EQUAL, but avoids the unnecessary allocation of tag constants as global variables.
Let diff=SUB(a–b).
Let result=ALLOC-BIT(a==b).
Constrain result×diff=0.
Constrain (diff+result)×q=1.
IS-SYM(a): the same as ALLOC-TAG-EQUAL(a,c), where c represents the field element whose value corresponds to the symbol tag.
IS-FUN(a): the same as ALLOC-TAG-EQUAL(a,c), where c represents the field element whose value corresponds to the function tag.
IS-CONS(a): the same as ALLOC-TAG-EQUAL(a,c), where c represents the field element whose value corresponds to the cons tag.
IS-STR(a): the same as ALLOC-TAG-EQUAL(a,c), where c represents the field element whose value corresponds to the string tag.
IS-NUM(a): the same as ALLOC-TAG-EQUAL(a,c), where c represents the field element whose value corresponds to the number tag.
IS-U64(a): the same as ALLOC-TAG-EQUAL(a,c), where c represents the field element whose value corresponds to the u64 tag.
IS-CHAR(a): the same as ALLOC-TAG-EQUAL(a,c), where c represents the field element whose value corresponds to the char tag.
IS-COMM(a): the same as ALLOC-TAG-EQUAL(a,c), where c represents the field element whose value corresponds to the comm tag.
IS-THUNK(a): the same as ALLOC-TAG-EQUAL(a,c), where c represents the field element whose value corresponds to the thunk tag.
ALLOC-IS-ZERO(a): it receives as input an allocated number a. The output is given by a Boolean that indicates if the input number is zero or not. It is constructed as follows:
Let is-zero=(a==0).
Let result=ALLOC-BIT(is-zero).
Constrain result×a=0.
Constrain (x+result)×q=1.
ALLOCATE-DUMMY-COMPONENTS(): it receives no input argument and is responsible for allocating dummy variables and creating a pointer for them. Concretely, we have the following:
Let value=ALLOC-FROM-PARTS(0,0).
Let cont=ALLOC-FROM-PARTS(0,0).
Let dummy-hash=CONSTRUCT-THUNK(value,cont).
ALLOCATE-THUNK-COMPONENTS(): it allocate thunk components
ALLOCATE-HASH-COMPONENTS
CONSTRUCT(components): it receives as input 4 allocated pointers, represented as 8 field elements, and calls the 8-ary Poseidon gadget.
CONSTRUCT-CONS(components): it receives as input 2 allocated pointers, represented as 4 field elements, and calls the 4-ary Poseidon gadget.
CONSTRUCT-THUNK(components): it receives as input 2 allocated pointers, represented as 4 field elements, and calls the 4-ary Poseidon gadget.
CONSTRUCT-FUN(components): it receives as input 3 allocated pointers, represented as 6 field elements, and calls the 6-ary Poseidon gadget.
CONSTRUCT-LIST(elements): it receives as input a list of n allocated pointers and uses consn–1
times in order to construct a pointer to the output list.
Table 2: Pointer gadgets summary
Gadget
Constraints
Witnesses
ALLOC-NUM
0
1
ALLOC-PTR
0
2
ALLOC-CONSTANT
1
1
ALLOC-CONSTANT-CONT-PTR
2
2
ALLOC-FROM-PARTS
0
0
ALLOC-EQUAL
4
3
ALLOC-TAG-EQUAL
3
2
IS-SYM
3
2
IS-FUN
3
2
IS-CONS
3
2
IS-STR
3
2
IS-NUM
3
2
IS-U64
3
2
IS-CHAR
3
2
IS-COMM
3
2
IS-THUNK
3
2
ALLOC-IS-ZERO
3
2
ALLOCATE-THUNK-COMPONENTS
289
293
ALLOCATE-MAYBE-DUMMY-COMPONENTS
390
398
ALLOCATE-MAYBE-FUN
339
345
CONSTRUCT
388
388
CONSTRUCT-CONS
286
284
CONSTRUCT-THUNK
286
284
CONSTRUCT-FUN
334
334
CONSTRUCT-COMMITMENT
334
334
CONSTRUCT-LIST
286(n – 1)
284(n – 1)
Functional Commitments
SECRET(commitment):
Check if the opening is known. If so, name it (secret,payload).
Let open-commitment=CONSTRUCT-COMMITMENT(secret,payload).
Let valid-opening=ALLOC-EQUAL(commitment,open-commitment).
Return secret.
NUM(value):
Let num-value=value.hash().
Let alloc-num-res=ALLOC-FROM-PARTS(globals.num-tag,num-value).
Return alloc-num-res.
CHAR(value):
Let char-value=value.hash().
Let alloc-char-res=ALLOC-FROM-PARTS(globals.char-tag,char-value).
Return alloc-char-res.
COMM(value):
Let comm-value=value.hash().
Let alloc-comm-res=ALLOC-FROM-PARTS(globals.comm-tag,comm-value).
Return alloc-comm-res.
Table 3: Functional commitments gadgets summary
Gadget
Constraints
Witnesses
HIDE
334
334
COMMIT
334
334
OPEN
334
334
SECRET
334
334
NUM
0
0
CHAR
0
0
COMM
0
0
Arithmetic operations
Here we present all the arithmetic operations that are required to implement the Lurk circuit. For each operation, we provide two gadgets. The first receives both input and output terms, constraining them so that it really corresponds to the correct calculation of that operation. The second only receives the input terms, and it is the responsibility of the gadget to allocate the output and calculate it accordingly.
SUM(a,b,res): It receives as input the two operands and the result as allocated numbers.
Constrain: (a+b)×(1)=res.
ADD(a,b): In this case, no result is provided. We first need to allocate and assign the correct value.
Let res=ALLOC(a+b).
Call SUM(a,b,res).
DIFFERENCE(a,b,res): It receives as input the two operands and the result as allocated numbers.
Constrain: (res+b)×(1)=a.
SUB(a, b): In this case no result is provided. We first need to allocate and assign the correct value.
Let res=ALLOC(a–b).
Call DIFFERENCE(a,b,res).
PRODUCT(a,b,res): It receives as input the two operands and the result as allocated numbers.
Constrain: (a)×(b)=res.
MUL(a,b): In this case no result is provided. We first need to allocate and assign the correct value.
Let res=ALLOC(a.b).
Call PRODUCT(a,b,res).
DIV(a,b): For division, we multiply by the inverse.
Let inv=ALLOC(b−1)
Let res=MUL(a,inv).
Table 4: Arithmetic gadgets summary
Gadget
Constraints
Witnesses
SUM
1
0
ADD
1
1
DIFFERENCE
1
0
SUB
1
1
PRODUCT
1
0
MUL
1
1
DIV
1
1
Comparisons
We have that a number is defined to be negative if the parity bit (the least significant bit) is odd after doubling, meaning that the field element (after doubling) is larger than the underlying prime p that defines the field, then a modular reduction must have been carried out, changing the parity that should be even (since we multiplied by 2) to odd. In other words, we define negative numbers to be those field elements that are larger than p/2.
Operations like ﹤, ≤, ﹥, ≥ are implemented using the bit decomposition 3 times. To test if a<b, we calculate the difference diff=(b–a) and test if diff is negative by seeing if the parity bit, the least significant bit, of 2diff is 1. If it is the case, it means 2diff is larger than p. Therefore, after computing the modular reduction, the parity bit is changed from 0 to 1. By composing with equality tests and other basic Boolean operations, we also obtain ≤, ﹥, ≥.
IS-NEGATIVE:
INPUTnum. OUTPUTnum-is-negative.
Let double-num=ADD(num,num).
Let double-num-bits=double-num.to-bits-le-strict().
Let lsb-2num=double-num-bits[0].
Let num-is-negative=lsb-2num.
Return num-is-negative.
In order to compare 2 field elements, we first compute the predicate Is-Negative() for a, b and (b−a), which are input parameters. Then we use a multicase to select the desired result according to the operation given by op2.
Next we define an auxiliary function that is responsible for constraining the coercion from field element to an unsigned integer. To do that, we use big number to calculate the remainder after division by an appropriate power of 2, depending on the size. Later, we use the LINEAR() gadget to constrain the relation a=b.q+r, where q is the power of 2 mentioned above, and 0≤r<q.
Let power-of-two-bn=pow(2,size)− computed as a big number.
Let (q-bn,r-bn)=field-bn.div-rem(power-of-two-bn).
Let q-num=ALLOCATE-UNCONSTRAINED-BIGNUM(q-bn).
Let r-num=ALLOCATE-UNCONSTRAINED-BIGNUM(r-bn).
Let pow2-size=field-pow(2,size)− computed as a field element.
Call LINEAR(q-num,pow2-size,r-num,field-elem).
Let r-bits=field-elem-bits[0..size].
Call ENFORCE-PACK(r-bits,r-num).
Next we present an auxiliary function that converts from num to unsigned integers by taking the least significant bits. The output is a pair of allocated numbers, where the first one corresponds to the u32 coercion, while the second corresponds to the u64 coercion.
TO-UNSIGNED-INTEGERS:
INPUTnum. OUTPUTr32-num,r64-num.
Let field-bn=from-bytes-le(num).
Let field-elem-bits=num.to-bits-le().
Let r32-num=TO-UNSIGNED-INTEGER-HELPER(maybe-unsigned,field-bn,field-elem-bits,32).
Let r64-num=TO-UNSIGNED-INTEGER-HELPER(maybe-unsigned,field-bn,field-elem-bits,64).
Return (r32-num,r64-num).
Table 5: Comparisons gadgets summary
Gadget
Constraints
Witnesses
IS-NEGATIVE
389
388
COMPARISON-HELPER
1215
1208
TO-UNSIGNED-INTEGER-HELPER
2
2
TO-UNSIGNED-INTEGERS
360
259
Coercion
TO-U64:
INPUTmaybe-u64. OUTPUTr64-num.
Let field-bn=from-bytes-le(maybe-u64.to-bytes-le).
Let field-elem-bits=maybe-u64.to-bits-le().
Let r64-num=TO-UNSIGNED-INTEGER-HELPER(maybe-unsigned,field-bn,field-elem-bits,64)?.
Return (r64-num).
Next we enforce div and mod operation for U64. We need to show that arg1=q.arg2+r, such that 0≤r<arg2.
Given that cond is satisfied, next we enforce the num<bound. This is done by proving (bound−num) is positive. num and bound must be a positive field element. cond is a Boolean condition that enforces the validation if and only if it is true.
ENFORCE-LESS-THAN-BOUND:
INPUTcond,num,bound.
Let diff-bound-num=SUB(bound,num).
Let diff-bound-num-is-negative=ALLOCATE-IS-NEGATIVE(diff-bound-num).
Next we convert from bn to num. This allocation is NOT constrained here. In the circuit we use it to prove u64 decomposition, since using bn we have division with remainder, which is used to find the quotient after dividing by 2ˆ64. Therefore we constrain this relation afterwards. In order to do that we use an external library for big number arithmetic, because in finite field we can´t compute the Euclidean division.
ALLOCATE-UNCONSTRAINED-BIGNUM:
INPUTbn. OUTPUTnum.
Let bytes-le=bn.to-bytes-le().
Pad bytes-le with zeros, such that it has length 32.
Let num=ALLOC(bytes-le).
Return num.
Table 6: Bit decomposition gadgets summary
Gadget
Constraints
Witnesses
TO-U64
258
257
ENFORCE-U64-DIV-MOD
404
403
ENFORCE-LESS-THAN-BOUND
392
390
ALLOCATE-UNCONSTRAINED-BIGNUM
0
1
Bit decomposition
We use a gadget from the bellperson 22 library, called to-bits-le-strict(a), in order to decompose field elements into bit representation.
to-bits-le-strict(a) returns a vector of Booleans corresponding to the bits of a using little endian representation.
Table 7: Bit decomposition gadgets summary
Gadget
Constraints
Witnesses
BIT-DECOMP-LE
388
387
Equality
All operations described in this section are binary operations, a detailed description of which can be found in Apply continuation Section. Here, we explain how each operation is implemented. There is not a gadget construction for each of them, however. Their implementation is part of the binop and binop2 in Apply-Continuation.
Number equality. This is easily implemented using ALLOC-EQUAL.
Equality of expressions (recursive). This is obtained by testing equality of hashes.
Conditionals
We use ternary operators to construct conditionals. The main building block is the gadget that can select a field element, given a boolean condition.
PICK-FIELD-ELEMENT(condition,a,b) If condition is true, ensure res=a. Otherwise, ensure res=b.
Let res be
If condition, then let res=ALLOC(a).
Otherwise, let res=ALLOC(b).
Constrain: (b–a)×condition=(b–res).
In order to pick a pointer we define the next gadget.
PICK(condition,a,b):
Let res-tag=PICK-FIELD-ELEMENT(condition,a.tag(),b.tag()).
Let res-hash=PICK-FIELD-ELEMENT(condition,a.hash(),b.hash()).
Return res=ALLOC-FROM-PARTS(res-tag,res-hash).
Table 8: Conditionals gadgets summary
Gadget
Constraints
Witnesses
PICK-FIELD-ELEMENT
1
1
PICK
2
2
Multicase
The multicase gadget is particularly important for Lurk, since it is a core part of Reduce-Cons() and Apply-Continuation(), which are essential components in reduction of expressions. In summary, a multicase is a combination of multiple case gadgets, eliminating common constraints.
A clause is given by a pair of field elements denoted by (key,value). A case statement is given by a set of clauses where no repeated keys appear. Also, it has a default clause, which is used when no key satisfies the one given as input.
A multicase is a set of cases where the same sequence of keys is used for each case, including their order. This way, we can calculate a selector which will be applied for every case. We constrain the selector only once, avoiding unnecessary circuit growth.
The strategy to enforce selection is the following:
Selector: allocate one bit per clause.
Test that if after adding all selectors we get 1, then exactly one is true, since each element is a bit.
Enforce selected key.
For the first case clauses, we calculate all the constraints a case gadget has, as follows:
Constrain:
acc=i∏(keyi−selected)
such that acc is zero if and only if some key is selected.
is-selected=ALLOC-IS-ZERO(acc).
Now, for the next cases, some constraints do not need to be repeated. We can proceed by computing the constraints of the result, by calculating the dot product of the selector and the values.
Let sum be initialized with zero.
For each clause c:
sum=sum+PICK(selector,c.value,0).
Finally, we need to constrain the default result, which follows:
Let res be:
If is-selected is true, return sum.
Otherwise, return default.
The number of constraints for a case gadget is given by 7+4c, where c corresponds to the number of clauses in it. Moreover, the number of witnesses is given by 11+4c.
For the multicase, we have that the number of constraint is cost-of-case+4(m–1), where m is the number of cases. Furthermore, the number of witnesses is cost-of-case+5(m–1).
Final Remarks
In this document, we presented Lurk’s circuit specification, demonstrating how Lurk programs are proved in zero-knowledge. The total size of the circuit (as of January 2023) is 12513 constraints and 12140 witnesses. Being able to reduce generic Lurk expressions to such a small frame size allows us to use recursive SNARKs efficiently. In particular, we plan to integrate Nova folding techniques to obtain further performance improvements.
The reader wanting more information about Lurk will find several references below, including the Lurk evaluation specification 23 and the Lurk reduction notes 24.
References
Footnotes
Shafi Goldwasser, Silvio Micali, and Charles Rackoff. The knowledge complexity of interactive proof-systems. In STOC 1985, pages 291–304, 1985 ↩
Benarroch, D., Gurkan, K., Kahat, R., Nicolas, A., & Tromer, E. (2019). zkInterface, a standard tool for zero-knowledge interoperability.↩
Rosario Gennaro, Craig Gentry, Bryan Parno, and Mariana Raykova. Quadratic span programs and succinct NIZKs without PCPs.↩
Bryan Parno, Jon Howell, Craig Gentry, and Mariana Raykova. Pinocchio: Nearly practical verifiable computation↩
Eli Ben-Sasson, Alessandro Chiesa, Daniel Genkin, Eran Tromer, and Madars Virza. SNARKs for C: Verifying program executions succinctly and in zero knowledge, Cryptology ePrint Archive, Report 2013/507, 2013. ↩
Eli Ben-Sasson, Alessandro Chiesa, Eran Tromer, and Madars Virza. Succinct non-interactive zero knowledge for a von Neumann architecture. In USENIX Security 2014, pages 781–796, 2014. ↩
Jens Groth. On the size of pairing-based non-interactive arguments. In proc. Eurocrypt ’16, Part II, pages 305–326, 2016. ↩↩2↩3↩4
Kothapalli, A., Setty, S., Tzialla, I. (2022 ) Nova: Recursive Zero-Knowledge Arguments from Folding Schemes, Cryptology ePrint Archive, Report 2021/370, 2021. https://ia.cr/2021/370. ↩↩2↩3↩4
Gailly, N., Maller, M., Nitulescu, A., SnarkPack: Practical SNARK Aggregation, Cryptology ePrint Archive, Paper 2021/529. ↩
A good source of information on CPS is the book “Essentials of Programming Languages” 25↩
Sean Bowe, Jack Grigg, and Daira Hopwood. Recursive proof composition without a trusted setup. Cryptology ePrint Archive, Report 2019/1021, 2019. https://ia.cr/2019/1021. ↩
Lorenzo Grassi, Dmitry Khovratovich, Christian Rechberger, Arnab Roy, and Markus Schofnegger. Poseidon: A new hash function for zero-knowledge proof systems. Cryptology ePrint Archive, Report 2019/458, 2019. https://ia.cr/2019/458. ↩↩2
Hopwood, Daira, et al. “Zcash protocol specification.” version 2022.3.8, 2016 §5.4.9.6 ↩
Benoît Libert, Somindu C. Ramanna, and Moti Yung. Functional commitment schemes: From polynomial commitments to pairing-based accumulators from simple assumptions. Cryptology ePrint Archive, Paper 2016/766, 2016. https://eprint.iacr.org/2016/766. ↩
Helger Lipmaa and Kateryna Pavlyk. Succinct functional commitment for a large class of arithmetic circuits. Cryptology ePrint Archive, Paper 2021/932, 2021. https://eprint.iacr.org/2021/932. ↩
Chris Peikert, Zachary Pepin, and Chad Sharp. Vector and functional commitments from lattices. Cryptology ePrint Archive, Paper 2021/1254, 2021. https://eprint.iacr.org/2021/1254. ↩
Dan Boneh, Wilson Nguyen, and Alex Ozdemir. Efficient functional commitments: How to commit to a private function. Cryptology ePrint Archive, Paper 2021/1342, 2021. https://eprint.iacr.org/2021/1342↩