zorch_12

openvm-zorch

zkvm1

Lean OpenVM prover on zorch blocks: SWIRL (LogUp-GKR, batched ZeroCheck with univariate skip, stacked opening reduction, WHIR PCS), byte-matched against openvm-stark-backend.

Run in Playground ↗GitHub ↗

Quickstart

# Fibonacci through SWIRL: prove it, then check the proof.
#
# openvm-zorch is OpenVM's prover rebuilt on zorch blocks. prove() composes
# SWIRL's five stages over ONE Fiat-Shamir transcript -- a stacked commitment,
# LogUp-GKR over any interaction buses, a batched ZeroCheck with univariate
# skip, a stacked opening reduction, and a WHIR opening -- and verify() is its
# stage-for-stage dual. Both run below, on the AIR this file builds.
#
# A prover's input here is a TRACE, not an ELF. Compiling a guest program and
# executing it into columns is the openvm SDK's job; openvm-zorch starts where
# the trace does, which is where all five stages and every GPU win live.
#
# So the guest is the AIR itself -- openvm's Fibonacci. Two columns (a, b),
# row i = (F_i, F_{i+1}); the public values are the seed (a0, b0) and the
# claimed F_n. Change the seed A0, B0 or the height LOG_HEIGHT below and re-run
# -- it proves whatever sequence you pick. The default (0, 1) over 64 rows is
# byte-identical to openvm-stark-backend v2.0.0's fixture, whose constraints
# and trace this repo byte-matches.
import frx.numpy as fnp
from zk_dtypes import babybear_mont as F

from openvm_zorch.logup_zerocheck.constraints import ConstraintsDag
from openvm_zorch.poly_common import VerificationError
from openvm_zorch.poseidon2.babybear16 import babybear16_hasher
from openvm_zorch.prove import prove
from openvm_zorch.transcript import new_transcript
from openvm_zorch.types import AirInstance, AirVk, SystemParams
from openvm_zorch.verify import verify
from openvm_zorch.whir.prover import WhirConfig

A0, B0 = 0, 1   # the seed row (F_0, F_1)
LOG_HEIGHT = 6  # 2^6 = 64 rows, so the claim is F_64


def fibonacci_trace(a0: int, b0: int, log_height: int):
    """The witness: row i = (F_i, F_{i+1}) from the seed (a0, b0), plus the
    final F_n as a field element. The columns are BabyBear, so each add reduces
    mod the field on its own -- no explicit modulus."""
    rows = [(fnp.full((), a0, F), fnp.full((), b0, F))]
    for _ in range((1 << log_height) - 1):
        a_i, b_i = rows[-1]
        rows.append((b_i, a_i + b_i))
    return fnp.array(rows, dtype=F), rows[-1][1]


trace, f_n = fibonacci_trace(A0, B0, LOG_HEIGHT)
public_values = (A0, B0, int(f_n))  # a0, b0, the claimed F_n

# The AIR, as the symbolic node DAG keygen hands the prover: nodes in
# topological order, and the indices of the nodes asserted to be zero.
nodes: list[dict] = []


def node(**kw) -> int:
    nodes.append(kw)
    return len(nodes) - 1


def main(index: int, offset: int) -> int:
    """Column `index` of this row (offset 0) or the next one (offset 1)."""
    return node(kind="variable", entry="main", part_index=0, index=index, offset=offset)


def public(index: int) -> int:
    return node(kind="variable", entry="public", part_index=None, index=index, offset=None)


def add(left: int, right: int) -> int:
    return node(kind="add", left=left, right=right)


def sub(left: int, right: int) -> int:
    return node(kind="sub", left=left, right=right)


def mul(left: int, right: int) -> int:
    return node(kind="mul", left=left, right=right)


first, trans, last = (
    node(kind="is_first_row"),
    node(kind="is_transition"),
    node(kind="is_last_row"),
)
a, b = main(0, 0), main(1, 0)
a_next, b_next = main(0, 1), main(1, 1)
a0, b0, claimed = public(0), public(1), public(2)

# Each row asserts `selector * expr == 0`, which is what pins the trace to the
# public values at both ends and to the recurrence in between.
constraint_idx = (
    mul(first, sub(a, a0)),               # the trace starts at the given input,
    mul(first, sub(b, b0)),               # both columns
    mul(trans, sub(b, a_next)),           # a' = b
    mul(trans, sub(add(a, b), b_next)),   # b' = a + b
    mul(last, sub(b, claimed)),           # and it ends at the claimed F_n
)

# Fibonacci is a pure constraint AIR: no LogUp interactions, no bus. With
# nothing to fold, the LogUp-GKR stage folds an empty input layer -- exactly
# what the reference does for any AIR that declares no interactions -- so the
# proof's weight rides the ZeroCheck over the five constraints above and the
# WHIR opening that pins them to the committed trace.
dag = ConstraintsDag(
    nodes=tuple(nodes), constraint_idx=constraint_idx, interactions=()
)

# Production-shaped params, as the reference fixture pins them.
params = SystemParams(
    l_skip=4,
    n_stack=8,
    log_blowup=1,
    logup_pow_bits=2,
    max_constraint_degree=3,
    whir=WhirConfig(
        k=4,
        num_queries=[10, 3, 2],
        mu_pow_bits=3,
        folding_pow_bits=2,
        query_phase_pow_bits=1,
    ),
)

sponge, comp = babybear16_hasher()
vk_pre_hash = (0,) * 8

_, proof = prove(
    new_transcript(),
    sponge,
    comp,
    params,
    vk_pre_hash,
    [
        AirInstance(
            trace=trace,
            dag=dag,
            public_values=public_values,
            constraint_degree=2,
            needs_next=True,
            is_required=True,
        )
    ],
)


def accepts(claim: tuple[int, int, int]) -> bool:
    """Verify against a claim, from the AIR's shape alone -- no trace. The
    public values are absorbed into the transcript before anything is sampled,
    so a changed claim re-derives different challenges and the proof stops
    answering the questions it was built for."""
    vk = AirVk(
        dag=dag,
        log_height=LOG_HEIGHT,
        width=2,
        public_values=claim,
        constraint_degree=2,
        needs_next=True,
        is_required=True,
    )
    try:
        verify(
            new_transcript(),
            sponge,
            comp,
            params,
            vk_pre_hash,
            [vk],
            proof.common_main_commit,
            proof,
        )
        return True
    except VerificationError:
        return False


# A wrong claim: F_n + 1 in the field, so it stays a valid element even when
# F_n is P-1 (a plain int F_n + 1 would be P, which has no field element and
# fails to construct).
lied_about = public_values[:2] + (int(f_n + fnp.ones((), F)),)
n = 1 << LOG_HEIGHT
print(f"proved the ({A0}, {B0}) Fibonacci sequence: F_{n} = {public_values[2]} over {n} rows")
print("verifier accepts the proof:      ", accepts(public_values))
print("verifier accepts a wrong F_n:    ", accepts(lied_about))

openvm-zorch

A lean OpenVM prover built on zorch's scheme-agnostic SNARK blocks.

FRX → zorch (scheme-/zkVM-agnostic blocks) → openvm-zorch (SWIRL glue)

FRX is Fractalyze's fork of JAX, and it compiles through Fractalyze's fork of XLA. Both differ from upstream in the way that matters here: finite fields are native dtypes, not emulated. Everywhere below, FRX and XLA mean those forks.

OpenVM proves with SWIRL — a sumcheck-based proof system composing LogUp-GKR (interactions), a batched ZeroCheck with univariate skip (constraints), a stacked opening reduction, and a WHIR polynomial commitment — as implemented by openvm-stark-backend at tag v2.0.0. This repo re-implements that prover on zorch, keeping only the SWIRL-specific surface here and pushing every generic block upstream.

Installation

Python 3.11 on Linux x86_64, or macOS on Apple Silicon. (frxlib ships a cp311 wheel for those two platforms only — not 3.12/3.13, not Intel Macs.)

CPU

pip install openvm-zorch

GPU (CUDA 12)

pip install openvm-zorch 'frx[cuda12]' \
    --extra-index-url https://fractalyze.github.io/pypi/simple/

The extra index carries the CUDA plugin wheels, which are too large for PyPI's per-file limit. It is not needed for the CPU tier.

Verify

python -c "import frx, openvm_zorch; print(frx.devices()); print(openvm_zorch.__version__)"

[CpuDevice(id=0)] means the CPU tier; a CUDA install prints the GPU devices.

Development

bazel test //...                              # hermetic, CPU by default

Install the git hooks with both stages named. Plain pre-commit install wires only the pre-commit stage, which leaves the commit-message linter inactive — a malformed commit message then sails through to CI:

pre-commit install --install-hooks --hook-type pre-commit --hook-type commit-msg

Commit messages follow Conventional Commits: a valid type, a lowercase summary with no trailing period, a header of at most 80 characters, and a body on everything but docs. The scope is the package the change lives in — commit, logup_gkr, logup_zerocheck, poseidon2, stacked_reduction, whir — or one of prove, verify, verify_prove, transcript, fields, poly_common, bench_common, release for the modules directly under openvm_zorch/. A change spanning several takes no scope. The same linter runs in CI over every commit in a pull request and over the PR title.

Regenerate golden fixtures (requires Rust toolchain; pinned to the reference tag, so output is reproducible):

cd tools/fixture-gen
cargo run --release -- \
  --out ../../openvm_zorch/commit/testdata/stacked_commit \
  --transcript-out ../../openvm_zorch/testdata/transcript \
  --gkr-out ../../openvm_zorch/logup_gkr/testdata/logup_gkr \
  --zerocheck-out ../../openvm_zorch/logup_zerocheck/testdata/zerocheck \
  --stacking-out ../../openvm_zorch/stacked_reduction/testdata/stacking \
  --whir-out ../../openvm_zorch/whir/testdata/whir \
  --prove-out ../../openvm_zorch/testdata/prove

Reference pin

openvm-stark-backend tag v2.0.0 (16d60de724c21dcadfde7d8315a1db507e5832d7) — the same pin the openvm v2.0.0 release consumes. Config: BabyBear base field, BabyBear⁴ challenges, Poseidon2 width-16 (default_babybear_poseidon2_16, plonky3 =0.4.3).

Documentation

See docs/ for the full index — pipeline & terminology, and development & benchmarking.

License

Licensed under the Apache License, Version 2.0 (see LICENSE).