Skip to content
Lurk Lab

Blog

A Programmer's Introduction to Lurk

From the archive. This article describes the work and implementations at its original publication date.

On this page
  1. Overview
  2. What is Lurk?
  3. Lurk is a Lisp
  4. Finite Fields
  5. Commitments
  6. Functional Commitments
  7. Higher-order Functional Commitments
  8. Minimal but Expressive
  9. Application Example
  10. Onward and upward

Overview

This is an introduction to the Lurk programming language, written for programmers.

Throughout this post, you will find simulated code blocks, like the one below. To render the outputs to these expressions, expand “Recorded output” beneath the example. These code examples are hard-coded with outputs that replicate those returned by the Lurk REPL. If you want to run this code directly from the source, you can follow the instructions at the above link to run the REPL locally.

LurkRecorded example
(cons 'hello 'world)
Recorded output
Iterations: 3
Result: (HELLO . WORLD)

The example below is editable. Each expression runs independently on the server.

LurkEditable example
;; The below code is editable. The rest of the code in this post is not. (This is a comment.)
(cons 'hello 'world)

Read on to learn more about the range of valid Lurk expressions, how to write non-trivial Lurk programs, and for some clues as to why you might want to. By the end of this post, you will understand how Lurk can leverage cryptographic commitments to enable provably correct applications of secret functions as well as verifiable computation over private data.

What is Lurk?

Lurk is a Turing-complete language for recursive zk-SNARKs. The most interesting uses of Lurk are those which require proofs of evaluation. A Lurk proof may verifiably attest that X evaluates to Y while revealing no other information. Lurk proofs reveal neither the intermediate values that must be calculated in the course of evaluating an expression, nor the actual ‘content’ of all or part of either the input X or output Y of the evaluation (identified only by type and content address) apart from their types. Even the types of input and output can be effectively hidden using explicit commitments (described in more detail below) at the application level.

This post will not focus on the mechanics of how these proofs work, or even on much detail of their characteristics. For now, readers need only know that we can produce relatively small (~2-10KiB) proofs that can be verified relatively quickly (10-30ms in the best cases) and that reveal no private information.

Lurk is a Lisp

Lurk is a Lisp, influenced by Scheme and Common Lisp. Lurk data is content-addressed, which means that every expression is identified and may be referred to by a type-tagged (cryptographic) hash digest.

Since Lurk is a Lisp, it has practically no syntax. Everything is an expression. Some expressions are self-evaluating. For example, the number 3 evaluates to itself (the number 3).

LurkRecorded example
3
Recorded output
Iterations: 1
Result: 3

When the expression (3) above has evaluated, you should see a response with the result (3) and a count of ‘Iterations’, in this case, 1. You can think of this as representing the ‘cost’ or number of ‘clock cycles’ required to evaluate the expression. In the simplest case of a self-evaluating expression, only a single iteration is required.

Strings are also self-evaluating.

LurkRecorded example
"foo"
Recorded output
Iterations: 1
Result: "foo"

Lists are printed as white-space separated expressions, enclosed in parentheses.

LurkRecorded example
'(1 2 3)
Recorded output
Iterations: 1
Result: (1 2 3)

Above, the quote character (') introduces a literal expression to which the input evaluates.


Unquoted lists are evaluated by treating the first element as a potential function.

LurkRecorded example
(+ 2 3)
Recorded output
Iterations: 3
Result: 5

Technically, + is a built-in operator: it does not evaluate to a Lurk function but behaves like one here.


A list whose first element is neither a built-in operator nor evaluates to a function yields an error when evaluated.

LurkRecorded example
(1 2 3)
Recorded output
Iterations: 1
Result: ERROR!

This is because lists are evaluated by first evaluating each element of the list (in order), then treating the first result as a function to be applied to the remaining. In the case above, the number 1 does not name a function, so it cannot be applied to the list (2 3).


Built-in operators cannot be independently evaluated.

LurkRecorded example
+
Recorded output
Iterations: 1
Result: ERROR!

The following example makes clear that the arguments to + are first evaluated before being added. Evaluation proceeds recursively, following a strict, left-to-right evaluation order.

LurkRecorded example
(+ (* 2 7) (* 3 3))
Recorded output
Iterations: 9
Result: 23

Pairs are constructed with CONS, and their elements are separated by a . when printed.

LurkRecorded example
(cons 1 2)
Recorded output
Iterations: 3
Result: (1 . 2)

A linked list of pairs forms a list, and if terminated by the special self-evaluating symbol, NIL it is a ‘proper’ list, represented with no final dot.

LurkRecorded example
(cons 9 nil)
Recorded output
Iterations: 3
Result: (9)

A proper list can be extended by consing a new element onto its head.

LurkRecorded example
(cons 8 '(9))
Recorded output
Iterations: 3
Result: (8 9)

Consing pairs that are not proper lists allows creation of arbitrary binary-tree structures.

LurkRecorded example
(cons (cons (cons 1 2) (cons 3 4)) (cons (cons 5 6) (cons 7 8)))
Recorded output
Iterations: 21
Result: (((1 . 2) 3 . 4) (5 . 6) 7 . 8)

NIL, the list terminator, is self-evaluating.

LurkRecorded example
nil
Recorded output
Iterations: 1
Result: NIL

Note also that symbols are normalized to all-caps when read (parsed from character strings).


The built-in EQ operator compares Lurk expressions for equality. (Note that = can also be used for this purpose when both arguments are numbers.)

LurkRecorded example
(eq 1 2)
Recorded output
Iterations: 3
Result: NIL

NIL is false.

LurkRecorded example
(eq 1 1)
Recorded output
Iterations: 3
Result: T

T, the canonical true value, is also self-evaluating.

LurkRecorded example
t
Recorded output
Iterations: 1
Result: T

Conditionals are implemented by IF, which is an expression.

LurkRecorded example
(if (eq 1 1) 9 8)
Recorded output
Iterations: 6
Result: 9
LurkRecorded example
(if (eq 1 2) 9 8)
Recorded output
Iterations: 6
Result: 8

Only the selected branch of an IF expression is actually evaluated.

LurkRecorded example
(if (eq 1 1) (+ 9 8) 17)
Recorded output
Iterations: 8
Result: 17

Note the iteration count (8) above and compare with that below.

LurkRecorded example
(if (eq 1 2) (+ 9 8) 17)
Recorded output
Iterations: 6
Result: 17

The second example only requires 6 iterations because a cheaper branch of the computation was followed.


Lurk data is immutable, and Lurk expressions are content-addressed — constructed by cryptographic hash-consing, so equality checks are deep and cheap.

LurkRecorded example
(eq '(1 (2 . "monkey") ((x (y)))) '(1 (2 . "monkey") ((x (y)))))
Recorded output
Iterations: 3
Result: T

Notice the iteration count above is the same as that for a simple equality check of two numbers.


Variable bindings are introduced by LET.

LurkRecorded example
(let ((a 9))
  (+ a 1))
Recorded output
Iterations: 6
Result: 10

Above, the symbol A is bound to the value 9 in the addition which adds 1 to it.


Quoting stops evaluation of symbols too.

LurkRecorded example
(let ((a 9))
  (cons 'a a))
Recorded output
Iterations: 6
Result: (A . 9)

Lexical bindings can be shadowed by inner bindings.

LurkRecorded example
(let ((a 9))
  (let ((a 10))
    (+ a 1)))
Recorded output
Iterations: 8
Result: 11

A single LET form can bind multiple variables.

LurkRecorded example
(let ((a 9)
      (b (+ a 1)))
  (* a b))
Recorded output
Iterations: 13
Result: 90

Multiple LET bindings are performed sequentially (so the following won’t work).

LurkRecorded example
(let ((b (+ a 1))
      (a 9))
  (* a b))
Recorded output
Iterations: 3
Result: ERROR!

Anonymous functions are created with LAMBDA.

LurkRecorded example
(lambda (x) (* x x))
Recorded output
Iterations: 1
Result: <FUNCTION (X) (* X X))>

The example above creates a function of one argument (X), which squares its argument. Within the body of the function, X (the formal parameter) is bound to the value passed (the actual parameter) when the function is applied.


Anonymous functions can be immediately applied.

LurkRecorded example
((lambda (x) (* x x)) 5)
Recorded output
Iterations: 7
Result: 25

Above, X is bound to 5, then the body (* X X) is evaluated.


Anonymous functions can be named by binding them to symbols.

LurkRecorded example
(let ((square (lambda (x) (* x x))))
  (square 5))
Recorded output
Iterations: 9
Result: 25

Since bindings can be in scope when functions are defined, those functions may act as lexical closures.

LurkRecorded example
(let ((val 123)
      (add-val (lambda (x) (+ val x))))
  (add-val 5))
Recorded output
Iterations: 13
Result: 128

Above, the function named ADD-VAL captures (‘closes over’) the binding of VAL.


The bindings captured by a closure can themselves be established by a function call, as in the classic example.

LurkRecorded example
(let ((make-adder (lambda (n)
                    (lambda (x) (+ n x))))
      (adder-1 (make-adder 1))
      (adder-9 (make-adder 9)))
  (cons (adder-1 3) (adder-9 4)))
Recorded output
Iterations: 38
Result: (4 . 13)

LET bindings are not in scope when the expressions defining their values are evaluated.

LurkRecorded example
(let ((nope (lambda (x) (nope x))))
  (nope 123))
Recorded output
Iterations: 8
Result: ERROR!

However, functions named using LETREC can be recursive: they can call themselves.

LurkRecorded example
(letrec ((length (lambda (list)
                   (if (eq list nil) ;; Base case
                       0
                       (+ 1 (length (cdr list)))))))
  (length '(a b c d e)))
Recorded output
Iterations: 87
Result: 5

Recursion can be used to implement loops.

LurkRecorded example
(letrec ((sum-upto (lambda (n) (if (= n 0)
                                   0
                                   (+ n (sum-upto (- n 1)))))))
  (sum-upto 100))
Recorded output
Iterations: 1612
Result: 5050

Functions can take functions as arguments.

LurkRecorded example
(letrec ((map (lambda (f list)
                (if (eq list nil)
                    nil
                    (cons (f (car list))
                          (map f (cdr list))))))
         (square (lambda (x) (* x x))))
  (map square '(1 2 3 4 5)))
Recorded output
Iterations: 186
Result: (1 4 9 16 25)

Recall that the classic MAKE-ADDER returns a function.

LurkRecorded example
(let ((make-adder (lambda (x)
                    (lambda (n)
                      (+ x n)))))
  (make-adder 123))
Recorded output
Iterations: 6
Result: <FUNCTION (N) (+ X N))>

As demonstrated above, functions are first-class. They can be manipulated and passed as ordinary data. This means that Lurk supports higher-order functions (HOF). That is, Lurk functions can receive functions as input and return functions as output — allowing for a wide range of expressive functional programming idioms.

LurkRecorded example
(letrec ((filter (lambda (pred list)
                   (if (eq list nil)
                       nil
                       (if (pred (car list))
                           (cons (car list)
                                 (filter pred (cdr list)))
                           (filter pred (cdr list))))))
         (sums-to (lambda (n pair)
                    (= (+ (car pair) (cdr pair))
                       n))))
  (filter (sums-to 11)
          '((1 . 2) (5 . 6) (4 . 7) (9 . 9))))
Recorded output
Iterations: 199
Result: ((5 . 6) (4 . 7))

Note also that Lurk supports partial evaluation, so we could implement MAKE-ADDER more simply.

LurkRecorded example
(let ((make-adder (lambda (a b) (+ a b))))
  (make-adder 123))
Recorded output
Iterations: 6
Result: <FUNCTION (B) (+ A B))>

In other words, passing one argument to a two-argument function yields a one-argument function.

LurkRecorded example
(let ((make-adder (lambda (a b) (+ a b)))
      (adder (make-adder 123)))
  (adder 5))
Recorded output
Iterations: 17
Result: 128

In fact, the two definitions are directly equivalent because Lurk automatically curries multi-argument functions into nested unary functions.

LurkRecorded example
(eq (lambda (a b) (+ a b))
    (lambda (a) (lambda (b) (+ a b))))
Recorded output
Iterations: 3
Result: T

Given higher-order recursive functions, we can define REDUCE.

LurkRecorded example
(letrec ((reduce (lambda (acc f list)
                   (if (eq list nil)
                       acc
                       (reduce (f acc (car list))
                               f
                               (cdr list)))))
         (sum (reduce 0 (lambda (a b) (+ a b)))))
  (sum '(1 2 3 4)))
Recorded output
Iterations: 214
Result: 10

Combined with our previous definition of MAP, we can implement MAP-REDUCE.

LurkRecorded example
(letrec ((reduce (lambda (acc f list)
                   (if (eq list nil)
                       acc
                       (reduce (f acc (car list))
                               f
                               (cdr list)))))
         (map (lambda (f list)
                (if (eq list nil)
                    nil
                    (cons (f (car list))
                          (map f (cdr list))))))
         (map-reduce (lambda (map-fn reduce-fn initial list)
                       (reduce initial reduce-fn (map map-fn list))))
         (plus (lambda (a b) (+ a b)))
         (square (lambda (x) (* x x))))
  (map-reduce square plus 0 '(1 2 3 4)))
Recorded output
Iterations: 407
Result: 30

Although we do not fixate on ‘performance’ here, do note that MAP-REDUCE can be implemented more efficiently, if also less clearly, by using a single recursive function. [Compare reported iteration counts.]

LurkRecorded example
(letrec ((map-reduce (lambda (map-fn reduce-fn acc list)
                       (if (eq list nil)
                           acc
                           (map-reduce map-fn reduce-fn (reduce-fn acc (map-fn (car list))) (cdr list)))))
         (plus (lambda (a b) (+ a b)))
         (square (lambda (x) (* x x))))
  (map-reduce square plus 0 '(1 2 3 4)))
Recorded output
Iterations: 305
Result: 30

Or even more so by factoring the constant (for the life of the MAP-REDUCE call) functions out of the recursion.

LurkRecorded example
(let ((map-reduce (lambda (map-fn reduce-fn acc list)
                    (letrec ((aux (lambda (acc list)
                                    (if (eq list nil)
                                        acc
                                        (aux (reduce-fn acc (map-fn (car list))) (cdr list))))))
                      (aux acc list))))
      (plus (lambda (a b) (+ a b)))
      (square (lambda (x) (* x x))))
  (map-reduce square plus 0 '(1 2 3 4)))
Recorded output
Iterations: 268
Result: 30

Lurk is somewhat unusual as a programming language, in that its fundamental performance metric (iteration count) is explicitly available and easily accessed. This can be useful when reasoning about code optimization, which is why we have mentioned it here.

Finite Fields

Lurk proofs are implemented in arithmetic circuits whose fundamental numeric type is the finite field. This is because zk-SNARKs use elliptic curves for their cryptographic properties. In practice, this means that arithmetic is performed modulo some large prime (the order of the field).

Lurk’s primary target is the Nova proving system, which is a recursive SNARK. This requires a curve cycle, and by default we use the Pasta curves. What this means for programmers, is that Lurk’s default numbers are elements of Fq, the scalar field of Pallas.

LurkRecorded example
(- 0 1)
Recorded output
Iterations: 3
Result: 0x40000000000000000000000000000000224698fc0994a8dd8c46eb2100000000

The result, 0x40000000000000000000000000000000224698fc0994a8dd8c46eb2100000000 is the largest value representable in Fq. That is to say, it is one less than q, the modulus of Fq.


q=0x40000000000000000000000000000000224698fc0994a8dd8c46eb2100000001q = 0x40000000000000000000000000000000224698fc0994a8dd8c46eb2100000001

LurkRecorded example
(+ 0x40000000000000000000000000000000224698fc0994a8dd8c46eb2100000000 1)
Recorded output
Iterations: 3
Result: 0

q0modqq \equiv 0 \mod{q}


Division works in a finite field,

LurkRecorded example
(/ 10 2)
Recorded output
Iterations: 3
Result: 5

but the results are not intuitive if the dividend is not a multiple of the divisor.

LurkRecorded example
(/ 5 6)
Recorded output
Iterations: 3
Result: 0x0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab061197f56ee1c24ecb67c8580000001

However, since ‘division’ is implemented as the product with the multiplicative inverse, arithmetic manipulation works as expected, which is the point.

LurkRecorded example
(* (/ 1 2) 2)
Recorded output
Iterations: 6
Result: 1

You can use ‘fractional’ notation, but the result is still a single field element.

LurkRecorded example
5/6
Recorded output
Iterations: 1
Result: 0x0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab061197f56ee1c24ecb67c8580000001

The same result as a sum:

LurkRecorded example
(+ 1/3 1/2)
Recorded output
Iterations: 1
Result: 0x0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab061197f56ee1c24ecb67c8580000001

Lurk defines an ordering of field elements, but take note that since finite-field arithmetic is modular, there must be a counter-intuitive case.

LurkRecorded example
(let ((largest-unsigned (- 0 1))
      (most-positive (/ largest-unsigned 2))
      (most-negative (+ 1 most-positive)))
  (< most-negative most-positive))
Recorded output
Iterations: 21
Result: T

Above, the most-negative number is produced by adding one to the most-positive.

Note that although Lurk’s field-element ordering allows for sorting and comprehensible comparisons between ‘small’ (i.e. less than MOST-POSITIVE) field elements, elements derived from operations which overflow will violate expectations. This follows from the fact that finite fields are not ordered fields. Nevertheless, when an application knows the domain of its operations is restricted to ‘small’ field elements, or when an opaque consistent ordering is required, Lurk’s native comparisons can be useful.


In all other cases, the expected identity (n<n+1 n < n + 1) holds.

LurkRecorded example
(< 0 1)
Recorded output
Iterations: 3
Result: T

Remember, (- 0 1) is (in some sense) the largest field element directly representable in Lurk.

LurkRecorded example
(- 0 1)
Recorded output
Iterations: 1
Result: 0x40000000000000000000000000000000224698fc0994a8dd8c46eb2100000000

This is a large number, but it compares as less than zero.

LurkRecorded example
(< 0x40000000000000000000000000000000224698fc0994a8dd8c46eb2100000000 0)
Recorded output
Iterations: 3
Result: T

Since finite field elements are required, by definition, to have additive inverses, we must treat the ‘largest’ elements as negative.

LurkRecorded example
(+ 1 (- 0 1))
Recorded output
Iterations: 6
Result: 0

Lurk provides an explicit negative-number syntax for convenience.

LurkRecorded example
-1
Recorded output
Iterations: 1
Result: 0x40000000000000000000000000000000224698fc0994a8dd8c46eb2100000000

For pedantic or curious readers (others are advised to skip this brief tangent), who want a precise definition of what we mean when we say that -1 is ‘(in some sense) the largest’ field element, even though we define it as negative:

Please consider the definition of LARGENESS-IN-SOME-SENSE-BASE-ONE illustrated below. We note in passing that this humorously-intended (but still informative) definition also demonstrates the use of STRCONS to construct strings from characters.

LurkRecorded example
(letrec ((aux (lambda (acc n)
                (if (= n 0)
                  acc
                 (aux (strcons #\1 acc) (- n 1)))))
         (largeness-in-some-sense-base-one (lambda (n) (aux "" n))))
  (largeness-in-some-sense-base-one 5))
Recorded output
Iterations: 146
Result: "11111"

Using extended numeric syntax, it’s easy to remember how to express the most negative and most positive field elements.

LurkRecorded example
(let ((most-positive -1/2)
      (most-negative 1/2))
 (= most-negative (+ 1 most-positive)))
Recorded output
Iterations: 13
Result: T

1/2+1=1/2-1/2 + 1 = 1/2

But please note that use of fractional syntax does not yield ‘real’ fractions. The resulting field elements do not always compare as expected.

LurkRecorded example
(< 3/4 7/8)
Recorded output
Iterations: 3
Result: NIL

Commitments

Lurk has built-in support for cryptographic commitments.

When the value corresponding to a commitment is unknown, it cannot be opened.

LurkRecorded example
(open (comm 0x2937881eff06c2bcc2c8c1fa0818ae3733c759376f76fc10b7439269e9aaa9bc))
Recorded output
Iterations: undefined
Result: Evaluation Error: Lookup error: hidden value could not be opened

We can create a commitment to any Lurk expression with COMMIT.

LurkRecorded example
(commit 123)
Recorded output
Iterations: 2
Result: (comm 0x2937881eff06c2bcc2c8c1fa0818ae3733c759376f76fc10b7439269e9aaa9bc)

Now that the Lurk evaluation environment knows that (comm 0x2937881eff06c2bcc2c8c1fa0818ae3733c759376f76fc10b7439269e9aaa9bc) is a commitment to 123, it can successfully open the commitment.

LurkRecorded example
(open (comm 0x2937881eff06c2bcc2c8c1fa0818ae3733c759376f76fc10b7439269e9aaa9bc))
Recorded output
Iterations: 4
Result: 123

(Note: if you happened to ‘run’ this before committing, you would have seen the same output. Remember that this is because the code examples in this post are returning pre-populated outputs. In actuality, open will not work if a corresponding commitment has not been made.)


Because Lurk commitments are based on Poseidon hashes (just as all compound data in Lurk is), it is computationally infeasible to discover a second preimage to the digest represented by a commitment. This means that Lurk commitments are (computationally) binding.

Lurk allows OPEN to operate on field elements (so the (COMM …) wrapper can be omitted).

LurkRecorded example
(open 0x2937881eff06c2bcc2c8c1fa0818ae3733c759376f76fc10b7439269e9aaa9bc)
Recorded output
Iterations: 2
Result: 123

For simplicity of examples, we will use bare field elements as commitments to be opened in the remainder of this post.


Lurk also supports explicit hiding commitments. For when hiding is unimportant, COMMIT creates commitments with a default secret of 0.

LurkRecorded example
(hide 0 123)
Recorded output
Iterations: 3
Result: (comm 0x2937881eff06c2bcc2c8c1fa0818ae3733c759376f76fc10b7439269e9aaa9bc)

However, any field element can be used as the secret, which makes Lurk commitments hiding as well as binding.

LurkRecorded example
(hide 999 123)
Recorded output
Iterations: 3
Result: (comm 0x3cb2f9666edb8e4444633e960ed56ef0486ea0c3628428680542e2b55f3bf67f)

Note that the returned commitment is different from the one returned by both (COMMIT 123) and (HIDE 0 123).


However, both commitments open to the same value.

LurkRecorded example
(= (open 0x2937881eff06c2bcc2c8c1fa0818ae3733c759376f76fc10b7439269e9aaa9bc)
   (open 0x3cb2f9666edb8e4444633e960ed56ef0486ea0c3628428680542e2b55f3bf67f))
Recorded output
Iterations: 7
Result: T

Since the committer/prover has free choice of the secret used, no information about the committed value can be deduced from the commitment when the secret is chosen at random.

For the remainder of this post, all commitments will be created with COMMIT for simplicity of the examples. Just remember that such commitents can always be made completely hiding if required.

Functional Commitments

Because Lurk allows commitments to any Lurk expression, we can also commit to functions.

LurkRecorded example
(commit (lambda (x) (+ 7 (* x x))))
Recorded output
Iterations: 2
Result: (comm 0x1aec2d8cfaba19acda002925ad87fa2da559f8ae431b12e96c9f2d16be3b0964)

The above is a commitment to a function that squares its input then adds seven.

We can construct and evaluate an expression that can only be proven to evaluate to one value: the result of applying the function to a given input, in these examples, 9 and 11.

LurkRecorded example
((open 0x1aec2d8cfaba19acda002925ad87fa2da559f8ae431b12e96c9f2d16be3b0964) 9)
Recorded output
Iterations: 12
Result: 88
LurkRecorded example
((open 0x1aec2d8cfaba19acda002925ad87fa2da559f8ae431b12e96c9f2d16be3b0964) 11)
Recorded output
Iterations: 12
Result: 128

Functional commitments are an extremely powerful construct.

Because the act of opening a functional commitment reveals only a small amount of information about the hidden function (its output when applied to a single input), functional commitment schemes require that application openings be accompanied with proofs. In order that no information about the committed function be leaked, those must be zero-knowledge proofs. As we have seen above, Lurk implements a language expressive enough to implement such a functional commitment scheme. In later posts, we will demonstrate the process of generating and verifying the proofs that make such schemes truly useful.

Higher-order Functional Commitments

Interestingly, even though the set of primitive operations Lurk supports is quite small (see the spec), they enable the possibility of higher-order functional commitments ‘for free’.

The following example is almost trivial in its simplicity, but this also highlights the complete generality enabled. Here, we commit to a function that simply applies its input to a secret internal value.

LurkRecorded example
(let ((secret-data 555)
      (data-interface (lambda (f) (f secret-data))))
  (commit data-interface))
Recorded output
Iterations: 7
Result: (comm 0x03836e2e16553f58294acf5eb1322b2ca65ace271cc7527402d7ca5fadced968)
LurkRecorded example
((open 0x03836e2e16553f58294acf5eb1322b2ca65ace271cc7527402d7ca5fadced968) (lambda (data) (+ data 111)))
Recorded output
Iterations: 14
Result: 666

We coin the term Higher-Order Functional Commitments, and as far as we are aware, Lurk is the first and only extant system enabling this powerful usage in the bare language, ‘on the metal’, of the proving system itself.

Minimal but Expressive

What we mean by this is that when Lurk is instantiated with a given backend (Nova over the scalar field of Pallas, in the case of our initial complete implementation), the result is a transparent proving system with a fixed verification key. Any statement in the relation represented by Lurk’s deterministic evaluation function can be proved in constant time. This makes Lurk a minimalist (RISC: Reduced Instruction-Set Computer) virtual machine, distinguished by its direct implementation of a content-addressed functional programming language.

The Lurk language relation consists of tuples of the form (exprin,envin,contin,exprout,envout,contout,n)(expr_{in}, env_{in}, cont_{in}, expr_{out}, env_{out}, cont_{out}, n). Put differently, there is a mapping of input triples to a set of (output triple, iteration count), where a triple consists of an expression, a lexical environment, and a continuation. Notice that not every input triple has an output triple corresponding to a completed evaluation. Not only must the input be well-formed to guarantee provable terminal output, it must also correspond to an input expression whose evaluation terminates. Since Lurk is Turing-complete, this is not guaranteed. In fact, it is guaranteed that there are many valid inputs that do not terminate. However, Lurk can prove partial evaluation to any depth. For any n<=tn <= t (where tt is the total iteration count of a terminating evaluation), Lurk can provide a proof that the input expression evaluates to the nnth intermediate reduction.

When new backends are implemented, proofs in one system will be data-compatible with those in another. So a hypothetical Halo2 backend over the scalar field of Pallas will accept and reject exactly the same sets of statements as the Nova backend does.

We describe higher-order functional commitments as being implemented ‘on the bare metal’ because no further language constructs need be implemented in order to enable them. While any Turing-complete proving system (Lurk is one of the few, but not the only such system) can — by definition — be used to first implement a functional programming language, then use the resulting language to implement a construct like higher-order functional commitments, only Lurk can do so ‘out-of-the-box’ with no further ado.

While all transparent SNARK systems avoid an additional trusted setup, Lurk further avoids the need for both a per-program verification and the need for a separate compilation step to preprocess the program. The ‘instruction set’ of the Lurk VM allows direct evaluation of expressive functional-programming code as data. This is a consequence of Lurk’s primary design decision. By implementing Lisp’s EVAL in its core circuit, Lurk exploits recursive SNARKs to circumvent the limitations of bounded circuits and accomplish Turing-completeness with a very small set of primitive operations. Because Lisp’s fundamental ‘memory allocator’ (CONS) can be implemented directly in RAM machines, Lurk allows for extremely concise expression of recursive algorithms over recursive data structures. We avoid the general cost of logarithmic-time lookups in linear memory by enabling computation over natural data structures in which each non-deterministic ‘memory lookup’ is shallow.

The inner Lurk circuit, which implements the Lurk reduction step, is currently about 20,000 R1CS constraints. With known optimizations, we estimate it can be further reduced to about 10,000 constraints.

The public inputs to a Lurk proof consist of 6 Lurk values (an input and output tuple) and an iteration count, where each Lurk value is represented by two field elements (a type tag and a value). Given ~256-bit (32 byte) field elements, that is 13 * 32 = 416 bytes.

Application Example

In Efficient Functional Commitments, Boneh et al introduce a credit-score example: a credit bureau wants to preserve the secrecy of its rating algorithm while also proving it applies its proprietary algorithm fairly to all parties. This can be accomplished by first commiting to a function that implements the secret algorithm, then opening function applications on each individual’s credit data. This demonstrates that the same function (the one initially committed to) is used in each case, but only the result is revealed.

Although not fully realistic, a map-reduce based example seems sufficiently suggestive.

LurkRecorded example
(letrec ((reduce (lambda (acc f list)
                   (if (eq list nil)
                       acc
                       (reduce (f acc (car list))
                               f
                               (cdr list)))))
         (map (lambda (f list)
                (if (eq list nil)
                    nil
                    (cons (f (car list))
                          (map f (cdr list))))))
         (map-reduce (lambda (map-fn reduce-fn initial list)
                       (reduce initial reduce-fn (map map-fn list))))
         (plus (lambda (a b) (+ a b)))
         (square (lambda (x) (* x x)))
         (secret-function (lambda (credit-data)
                            (map-reduce square plus 0 credit-data))))
  (commit secret-function))
Recorded output
Iterations: 15
Result: (comm 0x0ea21fab822e5414364b70a3763f15901b7512daf814bea5a48d36b8330ca2e4)
LurkRecorded example
((open 0x0ea21fab822e5414364b70a3763f15901b7512daf814bea5a48d36b8330ca2e4) '(2 4 7 10))
Recorded output
Iterations: 409
Result: 169

Although the credit-score algorithm example is compelling, it focuses on the privacy of the credit bureau’s proprietary algorithm. An equally (if not more) important consideration is that of individual data privacy. The complementary example is one in which the algorithm is public, but the credit data is not revealed.

In this case, an individual commits to their private data wrapped in a functional interface (as in the DATA-INTERFACE example above). Then the function implementing the credit algorithm is passed as input to a higher-order functional commitment application opening.

LurkRecorded example
(let ((credit-data '(2 4 7 10))
      (data-interface (lambda (f) (f credit-data))))
  (commit data-interface))
Recorded output
Iterations: 7
Result: (comm 0x3dd3ad73dfece7ffd7913b5d77a29c58410d347a14d6cf1cb1828fa1abcbb949)
LurkRecorded example
(letrec ((reduce (lambda (acc f list)
                   (if (eq list nil)
                       acc
                       (reduce (f acc (car list))
                               f
                               (cdr list)))))
         (map (lambda (f list)
                (if (eq list nil)
                    nil
                    (cons (f (car list))
                          (map f (cdr list))))))
         (map-reduce (lambda (map-fn reduce-fn initial list)
                       (reduce initial reduce-fn (map map-fn list))))
         (plus (lambda (a b) (+ a b)))
         (square (lambda (x) (* x x)))
         (credit-score-function (lambda (credit-data)
                                  (map-reduce square plus 0 credit-data))))
  ((open 0x3dd3ad73dfece7ffd7913b5d77a29c58410d347a14d6cf1cb1828fa1abcbb949) credit-score-function))
Recorded output
Iterations: 426
Result: 169

Onward and upward

Of course, the data-privacy example raises the question of how an individual’s commitment to private credit data might be trustworthy in the first place. Those with domain knowledge may already understand, and clever programmers with good imagination may guess. In future posts, we will demonstrate how functional commitments (including higher-order functional commitments) and their variants can be used to solve this and many other problems. Hopefully the current treatment has provided enough detail that not too much imagination is required. Pre-existing knowledge of the constituents should suffice to allow you to see how powerful the expressive and composable toolkit Lurk provides can be. That is the point.

We have built to higher-order functional commitments as an exemplary and interesting use case, and we believe the construct is sufficiently general to itself act as a nearly universal primitive. Nevertheless, we note that Lurk can often solve problems even more directly, if not constrained to use that primitive. \square