The zkDSL is a Python-syntax language that compiles to leanVM bytecode (4 basic instructions and 2 special ones (precompile): poseidon / extension operations). For the underlying VM, and proving system, see minimal_zkVM.pdf.
Source files use the .py extension. They are not currently runnable as
real Python, but the syntax is kept Python-compatible so that one day they
could be (TODO).
To recycle python tooling/linting on zkDSL files (which import snark_lib), point your editor at the compiler crate. With VSCode (for instance in leanVM/.vscode/settings.json):
{
"python.analysis.extraPaths": [
"./crates/lean_compiler"
]
}Programs are organized as one or more .py files. The toplevel of each file is a
sequence of:
from <module> import *statements (optional)- Top-level constant declarations (optional)
- Function definitions
Execution starts at def main(): ....
from snark_lib import * # only there to keep the Python linter happy; stripped by the zkDSL compiler
from utils import * # import other file
X = 42 # constants must come before functions
# array constants (or arbitrary dimmensions: 1D, 2D, etc)
ARR_1D = [1, 2, 3]
ARR_2D = [[1, 2, 3], [], [10, 4]]
ARR_3D = [[[1, 2, 3], [7, 8], [9]], [], [[10], [10, 4]]]
def main(): # required entry point
...
def helper(): # other functions
...from utils import * # imports utils.py (resolved from the import root)
from dir.subdir.file import * # nested module
from ..module import * # parent-directory import (relative to current file)Imports are wildcard-only (import *). Each module is loaded once even if imported
multiple times; circular imports are rejected. Constants with the same
name in two imported files cause a compile-time error.
Constants live at the top of the file, outside any function.
X = 42
ARR = [1, 2, 3]
NESTED = [[1, 2], [3]]MATRIX = [[1, 2, 3], [4, 5], [6, 7, 8, 9]]
DEEP = [[[1, 2], [3]], [[4, 5, 6]]]Indexed access uses chained subscripts at compile time:
x = MATRIX[0][2] # 3
y = DEEP[1][0][1] # 5len() works at every depth, including on a row addressed by a constant index:
len(MATRIX) # 3
len(MATRIX[0]) # 3
len(DEEP[0][0]) # 2When len() is applied with a variable index (len(ARR[i])), i must be a
compile-time constant. : Const parameters always qualify (see [Functions]
below), as do iterator variables of an unroll loop (see [For loops] below).
Example: iterating a ragged 2D table:
MATRIX = [[1, 2, 3], [4, 5], [6, 7, 8, 9]]
def main():
total: Mut = 0
for row in unroll(0, len(MATRIX)):
for col in unroll(0, len(MATRIX[row])):
total = total + MATRIX[row][col]
assert total == 45
returnThe Rust compiler API (CompilationFlags::replacements) substitutes values into the source before
parsing. By convention a placeholder is named <NAME>_PLACEHOLDER and used as a constant value:
V = V_PLACEHOLDER # replaced by the configured valueOnly whole identifiers are matched, so a placeholder is never replaced inside a larger name like
xV_PLACEHOLDER or V_PLACEHOLDER_2.
def add(a, b):
return a + b
def swap(a, b):
return b, a
def main():
x, y = swap(1, 2)
returnEvery function must contain at least one return. The compiler infers the number
of returned values from the return statements; all returns in a function must
agree. A function that "returns nothing" uses a bare return.
| Syntax | Meaning |
|---|---|
x |
normal (immutable) runtime parameter |
x: Const |
compile-time parameter |
def repeat(n: Const): # Const enables unroll(0, n)
sum: Mut = 0
for i in unroll(0, n):
sum = sum + i
return sum
def double(x): # parameter is immutable; shadow with a local
y: Mut = x
y = y * 2
return y@inline expands a function at every call site instead of generating a JUMP
instruction to another part of the bytecode. Useful for performance (calling a function costs a few cycles).
@inline
def square(x):
return x * xConstraints on inline functions (compiler limitations): Exactly one return, placed as the last statement of the body, not nested inside if, a loop, or match. Inlining rewrites the return into a plain assignment in place, so early or conditional returns cannot be expressed.
| Declaration | Mutability | Notes |
|---|---|---|
x = 10 |
immutable | cannot be reassigned |
x: Mut = 10 |
mutable | reassignable |
x: Imm |
immutable | forward declaration; assign exactly once later |
x: Mut |
mutable | forward declaration; reassignable later |
Use x: Imm when you want an immutable binding but the value comes from a
branch:
result: Imm
if cond == 1:
result = 10
else:
result = 20
# result is now immutableUse x: Mut when you want to keep mutating the variable after the branch:
x: Mut
if cond == 1:
x = 10
else:
x = 20
x = x + 1 # OK: x is mutableTo make a single component of a tuple-return mutable, forward-declare it:
b: Mut
a, b, c = some_function()
b = b + 1 # OK
# a = 5 # ERROR: a is immutablebuffer = Array(16) # allocate 16 field elements
buffer[0] = 42
buffer[0] = 42 # Valid
# buffer[0] = 41 # ERROR: conflicting write (read only memory)
buffer[5] = 34
x = buffer[5] # x = 34
matrix = Array(64) # 2D via manual indexing
matrix[row * 8 + col] = value
ptr2 = buffer + 5 # pointer arithmetic
ptr2[0] = 100 # same as buffer[5] = 100Array(n) returns a pointer to a freshly allocated block of n field
elements. n may be a compile-time constant (more efficient, analogy: allocated on the stack) or a runtime
value (less efficient, analogy: allocated on the heap). Memory is write-once: a cell may be
written more than once only if all writes store the same value.
if x == 0:
y = 1
elif x == 1:
y = 2
else:
y = 3Comparison operators on conditions: ==, !=, <, <=. There is no >
or >= (flip the operands to get the same effect).
Patterns must be a set of integers of the form [n, n+1, n + 2, ...]:
match value:
case 5:
result = 500
do_stuf()
case 6:
result = 600
do_other_stuf()
case 7:
result = 700
...The matched value must lie inside the listed range; out-of-range values produce
undefined behaviour: It's the responsability of the program to ensure this (no checks added by the compiler). Letting a prover-controlled value escape the range in a range is a critical vulnerability.
match_range enables to automatically generate a match with repeated arms.
result = match_range(n, range(1, 5), lambda i: compute(i))is expanded by the compiler to:
result: Imm
match n:
case 1: result = compute(1)
case 2: result = compute(2)
case 3: result = compute(3)
case 4: result = compute(4)It's possible to chain several (range, lambda) pairs, provided the ranges are
contiguous (the end of one is the start of the next):
result = match_range(n,
range(0, 1), lambda i: special_case(),
range(1, 8), lambda i: normal_case(i))Multiple return values are supported via tuple unpacking. Like any binding, a
match_range result is immutable; declare the target : Mut if you assign it
more than once in a scope (assigning it once per unroll iteration stays
immutable, each iteration is a fresh scope):
a: Mut
a, b = match_range(n, range(0, 4), lambda i: two_values(i))
a += 1 # second assign in this scope ⇒ a is : Mut
for k in unroll(0, n):
c = match_range(k, range(0, 4), lambda i: one_value(i)) # immutable: a fresh binding each iterationIdiomatic use: enables to dispatch a runtime value to a const-parameter function.
def helper_const(n: Const):
return n * n
def compute(value):
assert value < 10
return match_range(value, range(0, 10), lambda i: helper_const(i))Similar to match, range validity of the matched value is the responsibility of the program, not the compiler. Letting a prover-controlled value escape the range in a match_range is a critical vulnerability.
Three loop forms, all written for i in <range_kind>(start, end):. The
iterator visits start, start + 1, ..., end - 1.
Restrictions shared by all three forms:
- No
breakorcontinue(not in the grammar).
Empty / reversed bounds (start >= end). The two loop kinds behave differently:
unroll(start, end)withstart >= endexpands to nothing (zero iterations).range(start, end)/parallel_range(start, end)count up until the counter equalsend, sostart > endwraps around modulopand may run up to ≈psteps. A runtimerangemust always havestart <= end.
The general-purpose runtime loop. a and b may be runtime values. The
compiler lowers the loop to a recursive function.
for i in range(0, n):
out[i] = f(i)Mutability: A range range connot mutate variables defined outside its scope.
total: Mut = 0
for i in range(0, n):
total = total + a[i] # ERROR: loop-carried mutableMutable variables defined inside the loop are fine:
y = 9
for i in range(0, n):
x: Mut = f(i)
x += 4
x *= y
assert x < 455Under the hood: a range loop gets transformed into a recursive function, breaking compiler SSA renaming.
The solution, to mutate data beyond the loop's scope, is to use (read-only) buffers:
def sum(arr, n):
total_buf = Array(n + 1)
total_buf[0] = 0
for i in range(0, n):
total: Mut = total_buf[i] # loop-LOCAL mutable: fine
total += arr[i]
total_buf[i + 1] = total
result = total_buf[n] # final value, after the loop
return resultAdditional restrictions: no return inside the body (dos not concern unroll loops).
The loop is expanded at compile time: the body is duplicated once per iteration
with i substituted by its concrete value. Both a and b must be
compile-time constants.
for i in unroll(0, 4):
buffer[i] = i * iparallel_range compiles to exactly the same bytecode as range. It
differs only in the runner's scheduling policy: iterations are dispatched
concurrently across worker threads rather than evaluated in sequence. The only advantage is faster witness generation.
Iteration a is executed first, in isolation, to determine the per-iteration
memory footprint; the remaining iterations are then evaluated in parallel
without inter-iteration synchronization.
for i in parallel_range(0, n):
process(i, inputs[i], outputs[i])Because there is no synchronization, the loop body must be iteration-independent:
- Each iteration writes only to its own call frame and to addresses disjoint from every other iteration (no concurrent writes).
- Identical memory footprint per iteration.
- Identical hint consumption per iteration (witness hints, XMSS-specific decomposition hints, Merkle hints, etc.).
These constraints are not checked at compile time. Violating them produces silently wrong proofs.
Every line must either be a declaration, an assignment, a control-flow form, an
assertion, a return, or a side-effecting call (hint_witness, precompile,
print, or a function call). A bare expression like x + 1 on its own line is
a compile error.
+, -, *, / are field operations and work at runtime, modulo p = 2^31 - 2^24 + 1 (koalabear prime).
Division by zero is undefined behaviour.
% (modulo) and ** (exponentiation) are compile-time only — both operands
must be constants known at compile time.
x: Mut = 10
x += 5 # x = x + 5
x -= 3 # x = x - 3
x *= 2 # x = x * 2
x /= 4 # x = x / 4Only a single target is allowed on the LHS of a compound assignment.
These functions are evaluated at compile time only — their arguments must be constants:
log2_ceil(x) # ceil(log2(x))
next_multiple_of(x, n) # smallest multiple of n that is >= x
div_ceil(a, b) # (a + b - 1) // b
div_floor(a, b) # a // b
saturating_sub(a, b) # max(0, a - b)
len(array) # length of a constant array (any depth)Inside a tuple-unpacking LHS, _ discards the value at that position. The
compiler rewrites each _ to a fresh anonymous name so they don't collide.
_, b = swap(a, b) # only keep b
_ = compute() # discard a single return valueThe zkDSL provides two assertion forms with very different semantics:
| Form | Enforced by | Use for |
|---|---|---|
assert |
The proof system | Invariants the verifier must check |
debug_assert |
The prover only (at witness generation) | Sanity checks; preconditions the verifier does not need to re-check |
assert x == y
assert x != y
assert x < y
assert x <= yThe four supported comparison operators are ==, !=, <, <= (no > or
>=; flip the operands).
- When the inequality is strict:
assert a < b, The program must ensureb <= 2^16. - When the inequality is non-strict:
assert a <= b, The program must ensureb < 2^16.
The compiler does not check this (b may be a runtime value). Violating the bound is a critical soundness vulnerability.
Under the hood: the compiler proves a < b by emitting two DEREF instructions,
which check that a and b - 1 - a are both valid memory addresses. An
address is valid iff it is < M, where M is the memory size. To stay sound
for every admissible memory size, the construction relies on the smallest one,
M_min = 2^16 (= 2^MIN_LOG_MEMORY_SIZE), giving the bound b <= 2^16.
assert False is the unconditional failure form. It compiles to a Panic and
accepts an optional message:
assert False
assert False, "human-readable message"debug_assert(x < y)debug_assert accepts the same four comparison operators. It is evaluated by
the prover at trace-generation time and does not emit any constraint, so
the verifier never re-checks it. Use it for invariants the prover is expected
to maintain but that the verifier can take for granted — typically the
range-validity preconditions of match / match_range dispatches.
# single-line comment
"""
block comment
"""As in Python:
- Implicit continuation inside
(...)or[...]. - Explicit continuation with
\at end of line.
result = function_call(arg1,
arg2,
arg3) # implicit continuation inside parens
y = 1 + 2 + \
3 + 4 # explicit continuation with backslashA hint is data the prover writes into memory without adding any constraint — the program must still constrain the written value if it wants the verifier to believe anything about it. There are two flavours of hint:
Writes the next buffer queued under the label name into memory starting at
ptr. The guest must allocate ptr large enough to hold the data; no length
is checked at runtime.
The buffer comes from the host (Rust side), not from the guest. Before
running the program, the host fills ExecutionWitness::hints with one queue
of buffers per label; each hint_witness("name", ptr) call pops the next
buffer from hints["name"].
ExecutionWitness lives in crates/lean_vm/src/execution/runner.rs:
pub struct ExecutionWitness {
...
pub hints: HashMap<String, Vec<Vec<F>>>,
...
}Each map key is a label; the value is the ordered list of buffers the
guest will consume under that label. The N-th hint_witness("name", ptr) call
the guest executes pops the N-th Vec<F> from hints["name"] and writes it
at ptr.
For example, the guest below issues three hint_witness calls — two against
"input_data" and one against "other_stuff":
data_buf_1 = Array(64)
hint_witness("input_data", data_buf_1)
n = data_buf_1[0]
data_buf_2 = Array(64)
hint_witness("input_data", data_buf_2)
m = data_buf_2[3]
assert n == m + 8
data_buf_3 = Array(10)
hint_witness("other_stuff", data_buf_3)
...The matching Rust side must register two buffers under "input_data" (in
the order the guest will read them) and one under "other_stuff":
let mut hints: HashMap<String, Vec<Vec<F>>> = HashMap::new();
hints.insert(
"input_data".to_string(),
vec![
first_input_buffer, // consumed by the first hint_witness("input_data", ...)
second_input_buffer, // consumed by the second hint_witness("input_data", ...)
],
);
hints.insert("other_stuff".to_string(), vec![other_buffer]);
let witness = ExecutionWitness { hints, ..Default::default() };A missing label, or running out of buffers under a label, is a runner-side panic: each call requires its corresponding entry to exist.
Custom hints are a fixed set of built-in calls the prover uses to compute values that would be expensive to derive in-circuit — bit decompositions, comparisons, integer division, etc. Each is invoked like an ordinary function and writes its result into a caller-supplied memory location.
Like every hint, the result is unconstrained: the verifier checks nothing about the hinted value. The guest program must add its own constraints binding the hinted bits / quotient / remainder / boolean to the original input — otherwise a malicious prover can substitute any value. The typical pattern is "hint, then assert the relationship":
# hint the bits...
bits = Array(8)
hint_decompose_bits(value, bits, 8)
# ...then constrain them to actually equal `value`
acc: Mut = 0
for i in unroll(0, 8):
assert bits[i] * (bits[i] - 1) == 0 # boolean
acc = acc * 2 + bits[i]
assert acc == valueThe full list:
| Hint | Arguments | Effect |
|---|---|---|
hint_decompose_bits |
(value, ptr, n_bits) |
Writes n_bits big-endian 0/1 field elements at ptr (MSB at ptr[0]). Requires n_bits <= 31. |
hint_decompose_bits_merkle_whir |
(decomposed_ptr, value, chunk_size) |
Writes 24 / chunk_size little-endian chunk_size-bit chunks of value at decomposed_ptr (chunk_size must divide 24). |
hint_decompose_bits_xmss |
(decomposed_ptr, to_decompose_ptr, num_to_decompose, chunk_size) |
For each of num_to_decompose values at to_decompose_ptr[..], writes its 24 / chunk_size little-endian chunks at decomposed_ptr. |
hint_less_than |
(a, b, result_ptr) |
1 at result_ptr if a < b (canonical integer compare), else 0. |
hint_log2_ceil |
(n, result_ptr) |
ceil(log2(n)) at result_ptr. |
hint_div_floor |
(a, b, q_ptr, r_ptr) |
floor(a / b) at q_ptr, a mod b at r_ptr (requires b != 0). |
Precompiles are special instructions in the leanVM ISA, alongside the four basic ones (ADD, MUL, DEREF, JUMP). The zkDSL exposes them as built-in functions. There are two families: Poseidon hashing and extension-field operations.
The variants are as follows:
- compress vs. permute —
compressapplies the feed-forward addition (Poseidon(L || R) + L);permuteis the raw permutation, no feed-forward. - output length — the suffix is the fraction of the 16-cell permutation
kept: no suffix = full 16 (permute only),
_half= 8,_quarter= 4. Cells beyond the kept prefix are unconstrained. - hardcoded-left —
_hardcoded_leftreads the first 4 cells of the left input from a compile-time address instead of fromm[L..L+4](the last 4 cells still come fromm[L..L+4]); composes with both compress and permute.
Common arguments: L, R are 8-cell input buffers; O is the output
buffer; off (where present) is a compile-time address.
| Function | Cells written to O |
Notes |
|---|---|---|
poseidon16_compress_half(L, R, O) |
O[0..8] |
Poseidon(L || R) + L |
poseidon16_compress_quarter(L, R, O) |
O[0..4] |
O[4..8] is unconstrained |
poseidon16_compress_half_hardcoded_left(L, R, O, off) |
O[0..8] |
left = m[off..off+4] || m[L..L+4] |
poseidon16_compress_quarter_hardcoded_left(L, R, O, off) |
O[0..4] |
quarter-output + hardcoded-left composition |
poseidon16_permute(L, R, O) |
O[0..16] |
raw Poseidon permutation, no feed-forward |
poseidon16_permute_half(L, R, O) |
O[0..8] |
permutation, low 8 only (high 8 discarded) |
poseidon16_permute_half_hardcoded_left(L, R, O, off) |
O[0..8] |
permute (low 8) + hardcoded-left |
Six built-in functions, each reading two length-n vectors a and b and
writing one extension-field element to result. n defaults to 1 and must
be a compile-time constant when given.
add_ee(a, b, result, n=1) # result = sum_i (a[i] + b[i])
dot_product_ee(a, b, result, n=1) # result = sum_i a[i] * b[i]
poly_eq_ee(a, b, result, n=1) # result = prod_i (a[i]*b[i] + (1-a[i])*(1-b[i]))The _ee suffix means both a and b are vectors of extension-field
elements (each occupying DIM = 5 consecutive cells). The _be variants
(add_be, dot_product_be, poly_eq_be) are identical except a is a
vector of base-field elements (1 cell each); b and result are still
extension-field.
result always points to a single extension-field element (5 cells).
For a runtime n, dispatch through match_range:
def dot_product_ee_dynamic(a, b, res, n):
debug_assert(n <= 256)
match_range(n, range(1, 257), lambda i: dot_product_ee(a, b, res, i))Common idioms:
# Multiply two extension elements (n defaults to 1)
dot_product_ee(x, y, z) # z = x * y
# Copy an extension element by multiplying by 1
# (ONE_EF_PTR is a constant materialized in the preamble)
dot_product_ee(src, ONE_EF_PTR, dst)
# Extension subtraction: write-once memory turns "c = a + b" into
# the constraint "b + c = a", i.e. c = a - b
add_ee(b, c, a) # c = a - bprint(value)
print(a, b, c)print flushes its output during execution; a Rust-side panic mid-program drops
buffered prints. When you need a print to survive a panic, temporarily change
the print hint in lean_vm/src/isa/hint.rs (Self::Print) to eprint! directly.
The runner lays out memory as
[ public_input (PUBLIC_INPUT_LEN cells) | preamble_memory | runtime ]public_inputis fixed atPUBLIC_INPUT_LEN = DIGEST_LEN = 8cells (a hash digest), occupyingmemory[0..8].preamble_memoryis a region ofwitness.preamble_memory_lencells the runner reserves immediately after the public input but does not initialize. The guest program is expected to fill this region with whatever helper constants it relies on (e.g. a vector of zeros fordot_product_ee-as-copy, an extension-field one for multiply-by-one tricks, a vector of ones for batched accumulations, …) at the start ofmain. The names and offsets of these constants are not enshrined within leanVM. Seecrates/rec_aggregation/zkdsl_implem/utils.py (build_preamble_memory)for a concrete example.- The runtime region holds the program's stack frames, working memory, and any prover-supplied witness data, all governed by the write-once rule.
- Prefer
unrolloverrangefor small, fixed-size loops. - Reach for
: Constparameters when the function body needsunrollover the parameter. if/elifbranches that assign to the same outer variable should forward-declare it (x: Immorx: Mut) before the branch.- Function parameters are always immutable. To mutate a parameter's value
inside a function, introduce a local
: Mutalias at the top of the body (e.g.y: Mut = x).
Look at the recursive aggregation program (to aggregate XMSS) at its entrypoint main.py.
Starting program:
def main():
x: Mut = 0
y: Mut = 3
x += y
y += x
x_buf = Array(3) # 2 iterations + 1 slot
y_buf = Array(3)
x_buf[0] = x
y_buf[0] = y
for i in range(4, 6):
idx = i - 4
x_cur: Mut = x_buf[idx]
y_cur: Mut = y_buf[idx]
x_cur += i
x_cur += y_cur
y_cur = i
y_cur += x_cur
x_buf[idx + 1] = x_cur
y_buf[idx + 1] = y_cur
x = x_buf[2]
y = y_buf[2]
assert x == 35
assert y == 40
returnStep 1 — SSA-rename all reassignments to fresh names:
def main():
x = 0
y = 3
x2 = x + y
y2 = y + x2
x_buf = Array(3)
y_buf = Array(3)
x_buf[0] = x2
y_buf[0] = y2
for i in range(4, 6):
idx = i - 4
x_cur1 = x_buf[idx]
y_cur1 = y_buf[idx]
x_cur2 = x_cur1 + i
x_cur3 = x_cur2 + y_cur1
y_cur2 = i
y_cur3 = y_cur2 + x_cur3
x_buf[idx + 1] = x_cur3
y_buf[idx + 1] = y_cur3
x3 = x_buf[2]
y3 = y_buf[2]
assert x3 == 35
assert y3 == 40
returnStep 2 — lower the runtime loop to a recursive function:
def main():
x = 0
y = 3
x2 = x + y
y2 = y + x2
x_buf = Array(3)
y_buf = Array(3)
x_buf[0] = x2
y_buf[0] = y2
loop_helper(4, x_buf, y_buf)
x3 = x_buf[2]
y3 = y_buf[2]
assert x3 == 35
assert y3 == 40
return
def loop_helper(i, x_buf, y_buf):
if i == 6:
return
else:
idx = i - 4
x_cur1 = x_buf[idx]
y_cur1 = y_buf[idx]
x_cur2 = x_cur1 + i
x_cur3 = x_cur2 + y_cur1
y_cur2 = i
y_cur3 = y_cur2 + x_cur3
x_buf[idx + 1] = x_cur3
y_buf[idx + 1] = y_cur3
loop_helper(i + 1, x_buf, y_buf)
return