Introduction

On March 27th, 2026, zkSecurity started a security audit of OpenVM 2.0, spanning multiple components in different audit phases. The engagement was split into four different phases.

  • Phase 1 focused on the SWIRL proof system, the stark-backend native verifier, and the recursive verifier. The audit lasted four weeks, with two consultants, starting March 27th, 2026. We reviewed the stark-backend repository at commit 66dee8ab and the openvm repository at commit c89d052d.
  • Phase 2 focused on the continuations aggregation pipeline and the new deferral framework. The audit lasted two weeks with two consultants, starting May 18th, 2026. We reviewed the openvm repository at commit f01c912.
  • Phase 3 focused on the static-verifier crate (part of the OpenVM v2 implementation), which implements a STARK verifier using Halo2 with KZG polynomial commitments, and is used to “compress” the final STARK proof from OpenVM into a small PlonK proof. The audit lasted two weeks with two consultants, starting May 18th, 2026.
  • Phase 4 focused on OpenVM 2.0.0-rc.1. The scope was the diff between the openvm repository’s develop-v2.0.0-beta branch (commit f01c912) and its develop-v2.0.0-rc.1 branch (commit 0110850), together with the forked guest libraries that patch upstream hash crates to use OpenVM intrinsics. A Lean4 formal-verification supplement (the openvm-fv repository) accompanied the new SHA-2 and Keccak AIRs, providing extracted AIR-constraint theorems for those chips. The audit lasted two weeks with two consultants, starting May 18th, 2026.

Additional code reviewed

We additionally reviewed some small subsequent code changes:

  • PR #2863 which fixes the proving SDK for deferral circuits in the case the deferral circuits do not form a complete binary tree, by padding with absent proofs up to the next power of two.
  • PR #2900 which replaces recursion_flag with recursion_depth, which tracks the current depth of the main aggregation tree.
  • PR #2903 which implements a minor change in the EqNegBaseRandBus bus interface.
  • PR #2904 which fixes a small overcounting issue for interactions in the proof shape AIR.
  • PR #2910 which refactors and improves the deferral proving interface in the SDK. Since this change is not small, we focused our review on the parts of the PRs that touch the in-scope files, as well as parts that could introduce soundness issues.

Lastly, after the phase 1 audit, the branches were rebased onto the new v1.6.0 security release. Since the phase 1 commit hash was changed due to the rebase, we reviewed the diff between the phase 1 audit commits before and after the rebase. In particular, we ensured that:

  • The diff between stark-backend:develop-v2-before-v1.4 (260062e) and stark-backend:develop-v2 (be134de) only contains a version bump of plonky3.
  • The diff between openvm:develop-v2.0.0-beta-before-v1.6 (9709a5d) and openvm:develop-v2.0.0-beta (f01c912) is a subset of the diff between versions v1.5.0 and v1.6.0. Note that we did not review the content of the version diff itself, but we made sure that no change was introduced during the rebase that could affect the validity of the phase 1 audit.

Phase 1: STARK Backend and Recursive Verifier

This audit covered two repositories at specific commits:

  • stark-backend (branch develop-v2, commit 66dee8ab): a new implementation of the SWIRL proof system. Almost all code is new. A small number of files marked [DIFF] were reviewed only as a diff against the prior v1 codebase (tag v1.3.0); all other in-scope files were reviewed fresh without comparison to v1.
  • openvm (branch develop-v2.0.0-beta, commit c89d052d): the diff from v1.5.0 to the new branch, excluding continuations, the deferral framework, the halo2 static verifier, and SDK updates.

The primary subjects of review were the SWIRL protocol and its soundness analysis, the native SWIRL verifier in stark-backend, and the new recursive verification circuit in openvm/crates/recursion/. The SWIRL proof system involves a new GKR-based LogUp argument, a batched constraint sumcheck, a stacked polynomial opening reduction, and the WHIR polynomial commitment scheme. The recursive verifier implements the full SWIRL verification protocol as a collection of 39 AIRs connected by buses, targeting proof composition for the OpenVM zkVM.

We include the full listing of audit files below. Files marked with a [DIFF] tag have been audited as a diff compared to v1.

stark-backend scope files
crates/stark-backend/Cargo.toml
crates/stark-backend/codec-derive/Cargo.toml
crates/stark-backend/codec-derive/src/lib.rs
crates/stark-backend/src/air_builders/symbolic/dag.rs [DIFF]
crates/stark-backend/src/air_builders/symbolic/mod.rs [DIFF]
crates/stark-backend/src/air_builders/symbolic/symbolic_expression.rs [DIFF]
crates/stark-backend/src/any_air.rs
crates/stark-backend/src/codec.rs
crates/stark-backend/src/config.rs
crates/stark-backend/src/duplex_sponge.rs
crates/stark-backend/src/engine.rs
crates/stark-backend/src/hasher.rs
crates/stark-backend/src/interaction/mod.rs [DIFF]
crates/stark-backend/src/keygen/mod.rs
crates/stark-backend/src/keygen/types.rs
crates/stark-backend/src/lib.rs
crates/stark-backend/src/poly_common.rs
crates/stark-backend/src/proof.rs
crates/stark-backend/src/soundness.rs
crates/stark-backend/src/transcript.rs
crates/stark-backend/src/verifier/batch_constraints.rs
crates/stark-backend/src/verifier/evaluator.rs
crates/stark-backend/src/verifier/fractional_sumcheck_gkr.rs
crates/stark-backend/src/verifier/mod.rs
crates/stark-backend/src/verifier/proof_shape.rs
crates/stark-backend/src/verifier/stacked_reduction.rs
crates/stark-backend/src/verifier/transcript_extractor.rs # test / Fiat-Shamir reference only
crates/stark-backend/src/verifier/whir.rs
crates/stark-sdk/Cargo.toml
crates/stark-sdk/src/config/baby_bear_bn254_poseidon2.rs
crates/stark-sdk/src/config/baby_bear_poseidon2.rs
crates/stark-sdk/src/config/log_up_params.rs [DIFF]
crates/stark-sdk/src/config/mod.rs
crates/stark-sdk/src/lib.rs
openvm scope files
Cargo.toml
crates/circuits/mod-builder/Cargo.toml
crates/circuits/primitives/Cargo.toml
crates/circuits/primitives/derive/src/lib.rs
crates/circuits/primitives/src/bitwise_op_lookup/mod.rs
crates/circuits/primitives/src/lib.rs
crates/circuits/primitives/src/range/mod.rs
crates/circuits/primitives/src/range_gate/mod.rs
crates/circuits/primitives/src/range_tuple/mod.rs
crates/circuits/primitives/src/var_range/mod.rs
crates/circuits/primitives/src/xor/lookup/mod.rs
crates/recursion/Cargo.toml
crates/recursion/build.rs
crates/recursion/derive/Cargo.toml
crates/recursion/derive/src/lib.rs
crates/recursion/src/batch_constraint/bus.rs
crates/recursion/src/batch_constraint/eq_airs/eq_3b/air.rs
crates/recursion/src/batch_constraint/eq_airs/eq_3b/mod.rs
crates/recursion/src/batch_constraint/eq_airs/eq_neg/air.rs
crates/recursion/src/batch_constraint/eq_airs/eq_neg/mod.rs
crates/recursion/src/batch_constraint/eq_airs/eq_ns/air.rs
crates/recursion/src/batch_constraint/eq_airs/eq_ns/mod.rs
crates/recursion/src/batch_constraint/eq_airs/eq_sharp_uni/air.rs
crates/recursion/src/batch_constraint/eq_airs/eq_sharp_uni/mod.rs
crates/recursion/src/batch_constraint/eq_airs/eq_uni/air.rs
crates/recursion/src/batch_constraint/eq_airs/eq_uni/mod.rs
crates/recursion/src/batch_constraint/eq_airs/mod.rs
crates/recursion/src/batch_constraint/expr_eval/constraints_folding/air.rs
crates/recursion/src/batch_constraint/expr_eval/constraints_folding/mod.rs
crates/recursion/src/batch_constraint/expr_eval/interactions_folding/air.rs
crates/recursion/src/batch_constraint/expr_eval/interactions_folding/mod.rs
crates/recursion/src/batch_constraint/expr_eval/mod.rs
crates/recursion/src/batch_constraint/expr_eval/symbolic_expression/air.rs
crates/recursion/src/batch_constraint/expr_eval/symbolic_expression/mod.rs
crates/recursion/src/batch_constraint/expression_claim/air.rs
crates/recursion/src/batch_constraint/expression_claim/mod.rs
crates/recursion/src/batch_constraint/fractions_folder/air.rs
crates/recursion/src/batch_constraint/fractions_folder/mod.rs
crates/recursion/src/batch_constraint/mod.rs
crates/recursion/src/batch_constraint/sumcheck/mod.rs
crates/recursion/src/batch_constraint/sumcheck/multilinear/air.rs
crates/recursion/src/batch_constraint/sumcheck/multilinear/mod.rs
crates/recursion/src/batch_constraint/sumcheck/univariate/air.rs
crates/recursion/src/batch_constraint/sumcheck/univariate/mod.rs
crates/recursion/src/bus.rs
crates/recursion/src/gkr/bus.rs
crates/recursion/src/gkr/input/air.rs
crates/recursion/src/gkr/input/mod.rs
crates/recursion/src/gkr/layer/air.rs
crates/recursion/src/gkr/layer/mod.rs
crates/recursion/src/gkr/mod.rs
crates/recursion/src/gkr/sumcheck/air.rs
crates/recursion/src/gkr/sumcheck/mod.rs
crates/recursion/src/gkr/xi_sampler/air.rs
crates/recursion/src/gkr/xi_sampler/mod.rs
crates/recursion/src/lib.rs
crates/recursion/src/primitives/bus.rs
crates/recursion/src/primitives/exp_bits_len/air.rs
crates/recursion/src/primitives/exp_bits_len/mod.rs
crates/recursion/src/primitives/mod.rs
crates/recursion/src/primitives/pow/air.rs
crates/recursion/src/primitives/pow/mod.rs
crates/recursion/src/primitives/range/air.rs
crates/recursion/src/primitives/range/mod.rs
crates/recursion/src/proof_shape/bus.rs
crates/recursion/src/proof_shape/mod.rs
crates/recursion/src/proof_shape/proof_shape/air.rs
crates/recursion/src/proof_shape/proof_shape/mod.rs
crates/recursion/src/proof_shape/pvs/air.rs
crates/recursion/src/proof_shape/pvs/mod.rs
crates/recursion/src/stacking/bus.rs
crates/recursion/src/stacking/claims/air.rs
crates/recursion/src/stacking/claims/mod.rs
crates/recursion/src/stacking/eq_base/air.rs
crates/recursion/src/stacking/eq_base/mod.rs
crates/recursion/src/stacking/eq_bits/air.rs
crates/recursion/src/stacking/eq_bits/mod.rs
crates/recursion/src/stacking/mod.rs
crates/recursion/src/stacking/opening/air.rs
crates/recursion/src/stacking/opening/mod.rs
crates/recursion/src/stacking/sumcheck/air.rs
crates/recursion/src/stacking/sumcheck/mod.rs
crates/recursion/src/stacking/univariate/air.rs
crates/recursion/src/stacking/univariate/mod.rs
crates/recursion/src/stacking/utils.rs
crates/recursion/src/subairs/mod.rs
crates/recursion/src/subairs/nested_for_loop/air.rs
crates/recursion/src/subairs/nested_for_loop/mod.rs
crates/recursion/src/subairs/proof_idx/air.rs
crates/recursion/src/subairs/proof_idx/mod.rs
crates/recursion/src/system/dummy.rs
crates/recursion/src/system/frame.rs
crates/recursion/src/system/mod.rs
crates/recursion/src/tracegen.rs
crates/recursion/src/transcript/merkle_verify/air.rs
crates/recursion/src/transcript/merkle_verify/mod.rs
crates/recursion/src/transcript/mod.rs
crates/recursion/src/transcript/poseidon2.rs
crates/recursion/src/transcript/transcript/air.rs
crates/recursion/src/transcript/transcript/mod.rs
crates/recursion/src/utils.rs
crates/recursion/src/whir/bus.rs
crates/recursion/src/whir/final_poly_mle_eval/air.rs
crates/recursion/src/whir/final_poly_mle_eval/mod.rs
crates/recursion/src/whir/final_poly_query_eval/air.rs
crates/recursion/src/whir/final_poly_query_eval/mod.rs
crates/recursion/src/whir/folding/air.rs
crates/recursion/src/whir/folding/mod.rs
crates/recursion/src/whir/initial_opened_values/air.rs
crates/recursion/src/whir/initial_opened_values/mod.rs
crates/recursion/src/whir/mod.rs
crates/recursion/src/whir/non_initial_opened_values/air.rs
crates/recursion/src/whir/non_initial_opened_values/mod.rs
crates/recursion/src/whir/query/air.rs
crates/recursion/src/whir/query/mod.rs
crates/recursion/src/whir/sumcheck/air.rs
crates/recursion/src/whir/sumcheck/mod.rs
crates/recursion/src/whir/whir_round/air.rs
crates/recursion/src/whir/whir_round/mod.rs
crates/toolchain/instructions/src/lib.rs # rename NATIVE_AS -> DEFERRAL_AS and remove PUBLISH
crates/toolchain/openvm/src/io/mod.rs
crates/toolchain/openvm/src/io/read.rs
crates/toolchain/openvm/src/pal_abi.rs
crates/toolchain/transpiler/src/extension.rs
crates/toolchain/transpiler/src/lib.rs
crates/toolchain/transpiler/src/transpiler.rs
crates/vm/Cargo.toml
crates/vm/derive/src/lib.rs
crates/vm/src/arch/config.rs
crates/vm/src/arch/extensions.rs # delete PUBLIC_VALUES_AIR_ID
crates/vm/src/arch/integration_api.rs
crates/vm/src/arch/mod.rs
crates/vm/src/arch/vm.rs
crates/vm/src/lib.rs
crates/vm/src/system/connector/mod.rs
crates/vm/src/system/memory/merkle/public_values.rs
crates/vm/src/system/mod.rs
crates/vm/src/utils/stark_utils.rs
extensions/algebra/moduli-macros/src/lib.rs # hint_buffer
extensions/rv32-adapters/Cargo.toml
extensions/rv32im/circuit/Cargo.toml
extensions/rv32im/circuit/src/hintstore/mod.rs
extensions/rv32im/guest/src/io.rs
extensions/rv32im/guest/src/lib.rs
guest-libs/k256/Cargo.toml
guest-libs/p256/Cargo.toml
guest-libs/pairing/src/bls12_381/pairing.rs
guest-libs/pairing/src/bn254/pairing.rs

Threat Model

During the audit, we considered the following threat models for the components under review: we are mainly protecting against a malicious prover who controls the proof and may submit any byte sequence that makes the verifier accept it. The verifier operates on a pre-processed circuit whose verification key is assumed to have been computed honestly, but we do not make assumptions on the circuit itself: the proof system must be sound even if the circuit is adversarially crafted, as long as the verification key is honestly computed from it. For the recursion circuit, we also considered a similar threat model, but the malicious prover can input the full recursive circuit witness, which includes all values for all AIRs and all interactions.

Overview of SWIRL

In this section we give a high-level overview of the SWIRL proof system. We focus on the main design ideas and the overall structure of the protocol; for more technical details, we refer to the SWIRL paper.

Front-end: AIRs with interactions

The front-end of OpenVM uses the AIR with interactions framework, an extension of the algebraic intermediate representation (AIR) designed to support constraints that span multiple tables.

AIRs as variable-length tables. An AIR defines a constraint system over a trace matrix: a table of field elements with a fixed number of columns (the width) and a number of rows (the trace length) that must be a power of 2. The height is not fixed by the AIR itself: it is chosen freely by the prover at proving time, so trace matrices of different heights can all satisfy the same AIR. A circuit may define many AIRs, and the prover is allowed to omit any AIR it does not use. However, the circuit can mark individual AIRs as required (via is_required in the verification key), forcing the prover to supply a trace matrix for them.

Constraint selectors. Constraints are polynomials over the values of the current row and the “next” row of the trace. Each constraint is paired with a selector that restricts on which rows it is enforced:

  • All: the constraint must hold on every row. The “next row” of the last row wraps around cyclically to the first row.
  • First: the constraint is only enforced on the first row.
  • Last: the constraint is only enforced on the last row (using the first row as the “next row”, i.e., the cyclic wrap-around pair).
  • Transition: the constraint must hold on every row except the last one. Unlike All, there is no wrap-around, making this suitable for state transition constraints that should not hold across the table boundary.

Buses and interactions. A single AIR can only impose constraints within its own table. To express relationships across different tables, the framework introduces buses and interactions. A bus is an abstract channel identified by a nonzero field element. Any AIR can declare one or more interactions on a bus, each specified by a pair of polynomials (message, multiplicity) over the symbolic variables (x1,,xw,y1,,yw), where xi represents the i-th column of the current row and yi represents the i-th column of the next row (cyclically):

  • The message is a vector of polynomials σ=(σ1,,σs). Evaluated on a concrete row i, it produces the field element tuple σ(𝐓i,𝐓next(i))𝔽s that this row sends on the bus.
  • The multiplicity is a single polynomial m. Evaluated on row i, it produces the field element m(𝐓i,𝐓next(i))𝔽 that weights this row’s contribution.

Every row of every present trace matrix thus contributes one message-multiplicity pair to the bus. A bus is balanced if, for every possible message tuple τ𝔽s, the sum of multiplicities over all rows (across all present AIRs) whose message evaluates to exactly τ is zero as a field element. A circuit is satisfied only when all its buses are balanced.

This balancing condition is expressive enough to encode the two main cross-table constraint patterns used throughout OpenVM, which we discuss in later sections:

  • Lookup buses: expose two operations. add_key declares a value as belonging to the table, contributing multiplicity n where n is left unconstrained by the bus. lookup_key asserts that a value belongs to the table, contributing multiplicity +1. Bus balancing then forces n to equal the actual number of lookups for that key, guaranteeing every queried value was declared in the table.
  • Permutation buses: expose send (multiplicity +1) and receive (multiplicity 1) operations, so balancing enforces that the multiset of sent messages equals the multiset of received messages.

Because balancing is checked modulo the field characteristic p, a sum of multiplicities that is a nonzero multiple of p would incorrectly appear balanced. To ensure that the lookup and permutation semantics hold, each bus type assigns a count weight to its operations: for lookup buses, each lookup_key call contributes weight 1 while add_key contributes weight 0; for permutation buses, both send and receive contribute weight 1. The total weighted count of interactions across all rows and all present AIRs must remain strictly below p to avoid a wrap-around.

Proof system overview

At a high level, the SWIRL proof system proceeds in four stages, each reducing the problem to a simpler claim:

  1. Interactions via LogUp+GKR. Bus balancing is rephrased as the vanishing of a fractional sum over all rows of all present traces. GKR reduces this to a claim about two polynomials p and q (the stacked numerator and denominator of the LogUp sum) evaluated at a random point. That claim is in turn reduced to evaluations of the message and multiplicity polynomials of each present AIR trace via a batched sumcheck.

  2. Constraints via ZeroCheck. The AIR constraints (that certain polynomials vanish on the trace domain) are batched and reduced to evaluations of the trace column polynomials at a random point via a batched sumcheck with a ZeroCheck argument.

  3. Stacked opening reduction. Steps (1) and (2) are arranged to reduce to evaluations of the same trace columns at the same random point. Those evaluations are then reduced to evaluations of the relevant stacked column polynomials at a single shared random point, using the injection from AIR column positions to stacked column positions and a batched sumcheck.

  4. WHIR opening proofs. The stacked column evaluations are proved using WHIR as a multilinear polynomial commitment scheme.

LogUp+GKR for interactions

LogUp reformulation. Bus balancing requires that, for every message τ, the sum of multiplicities across all rows and all present AIRs whose message equals τ is zero. LogUp allows this check to be expressed using a single algebraic equality: for random challenges α,β sampled by the verifier, the fractional sum

T,(σ^,m^,b)IT𝐳𝔻nTm^(𝐓(𝐳),𝐓rot(𝐳))α+hβ(σ^(𝐓(𝐳),𝐓rot(𝐳))b)

must equal zero. Here hβ is the linear hash defined by

hβ(σ^b):=βlen(σ^)·b+j=1len(σ^)βj1σ^j

which evaluates the polynomial bXlen(σ^)+σ^lenXlen1++σ^1 at X=β. The multi-bus check is reduced to a single-bus check by concatenating each message with its bus index before hashing: since different buses have different values of b, the term βlen(σ^)b distinguishes their hashes with high probability over the random choice of β. If this sum is zero, then all buses are balanced with high probability over the choice of α and β.

Remark. The bus index b must be nonzero. If b=0, the leading term βlen(σ^)·0 vanishes, so the hash of a message (σ1,,σs) on bus 0 equals hβ((σ1,,σs1)σs), i.e., the same value as a shorter message (σ1,,σs1) on the bus indexed by σs. Domain separation between buses therefore breaks down: an adversary could craft interactions on a zero-indexed bus that collide with interactions on a different, legitimately indexed bus, defeating the balancing check.

GKR reduction. Computing this sum directly is expensive for the verifier, since it involves one fraction per row per interaction across all present AIRs. The GKR protocol computes it as a layered arithmetic circuit: each layer combines adjacent pairs of fractions using the identity p1q1+p2q2=p1q2+p2q1q1q2, halving the number of terms at each step. The circuit output is the total fractional sum, which must equal zero.

GKR reduces the claim that the circuit output is zero to a claim about the circuit’s input layer by applying a sumcheck at each layer, working backwards from output to input. At the end of this reduction the verifier holds a claim about two polynomials at the input layer: a numerator polynomial p and a denominator polynomial q, evaluated at a random point ξ.

Stacking interactions. All interactions across all present AIRs and all rows are embedded into a single domain using an injection j that maps each (AIR, row, interaction) triple into a position in a single boolean hypercube +nLogUp. The polynomials p and q are then defined as functions on this joint domain, zero-padded outside the image of j. This means the GKR circuit operates on a single pair (p,q) rather than one pair per AIR, keeping the circuit uniform and allowing the GKR sumchecks to be batched.

Reduction to trace evaluations. The claims about p(ξ) and q(ξ) expand, via the definition of j, into sums over each present trace matrix of terms involving the message and multiplicity polynomials evaluated on rows of 𝐓. A batched sumcheck over all present trace matrices reduces these to evaluations 𝐓^(𝐫) and 𝐓^rot(𝐫) at a shared random point 𝐫, which are the same column evaluations needed by ZeroCheck in the next step.

ZeroCheck for AIR constraints

Each AIR defines constraint polynomials that must vanish on every applicable row of the trace. For a trace matrix 𝐓 satisfying an AIR A, each constraint (C,S)A must satisfy C(𝐓(𝐳),𝐓rot(𝐳))·S(𝐳)=0 for all 𝐳 in the trace domain. The selector S restricts which rows are checked (All, First, Last, or Transition).

All constraints for the present AIRs are algebraically batched into a single polynomial using a random challenge λ, and their simultaneous vanishing is checked via a batched sumcheck (ZeroCheck). This sumcheck reduces to an evaluation of the batched constraint polynomial at a single random point 𝐫, which in turn requires knowing the values of the trace columns 𝐓^(𝐫) and 𝐓^rot(𝐫) at that point.

Crucially, 𝐫 is set to the same point ξ that was produced at the end of the LogUp+GKR reduction. The ZeroCheck sumcheck and the LogUp input-layer sumcheck (which computes p(ξ) and q(ξ) from the message and multiplicity polynomials) both reduce to evaluations of the trace columns at ξ. These two batched sumchecks are merged into one, so a single evaluation of each trace column at ξ covers both the constraint check and the interaction check simultaneously.

Selectors as multiplicative masks. Each constraint C is paired with a selector polynomial S:𝔻nT{0,1} that encodes which rows must satisfy C=0. The condition is written as C(𝐓(𝐳),𝐓rot(𝐳))·S(𝐳)=0 for all 𝐳. The selector evaluates to 1 on rows where the constraint applies and to 0 on rows where it does not, so multiplying by S masks out exempt rows. The four selector types have explicit prismalinear extensions (Section 3.3.1 of the SWIRL paper):

  • All^=1: every row is selected.
  • First^: evaluates to 1 only on the first row (the element ωD0D paired with 0n).
  • Last^: evaluates to 1 only on the last row.
  • Transition^=1Last^: every row except the last.

This is analogous in spirit to the traditional STARK approach of dividing a constraint polynomial by a vanishing polynomial that is zero on exempt rows, but the selector-multiplication approach avoids division and works directly with multilinear polynomials. Checking that C·S vanishes everywhere on the domain is handled by the ZeroCheck.

The rotation kernel. Constraint polynomials refer to the current row and the “next” row. In the 𝔻n=D×n domain, rows are ordered lexicographically by the D coordinate first, then the binary coordinates. The “next” row is defined by the rotation map

rot:𝔻n𝔻n,𝐳ord,n((ord,n1(𝐳)+1)mod2+n)

which advances to the next position in this lexicographic ordering, wrapping around cyclically (Section 2.5 of the SWIRL paper). Given a column polynomial t^, the rotated column t^rot(𝐳):=t^(rot(𝐳)) is computed via the rotation kernel κ^rot as the convolution t^κ^rot, which has an explicit polynomial formula in terms of equality polynomials. This lets the verifier express T^rot(r) algebraically and reduce both T^(r) and T^rot(r) to the stacked column evaluations in the next step.

Stacked commitments

A circuit may contain dozens of AIRs, but only present AIRs contribute trace matrices in a given proof. Each present trace matrix is additionally partitioned into preprocessed, common main, and cached columns. A naive commitment scheme would commit to each column of each one of them independently. SWIRL instead applies the same stacking construction per commitment: all common main partitions from the present AIRs are stacked into one shared matrix, while each nonempty preprocessed partition and each nonempty cached partition is stacked and committed separately. We now describe the stacking construction in more detail.

The stacked matrix. In the SWIRL paper, 𝔻n denotes the hyperprism Dn=D×n for n0, where |D|=2 is the univariate skip domain. The domain 𝔻n has size 2+n, and the extended definition for negative n preserves the same formula, for n. The stacked matrix Q is a map

Q:𝔻nstack×[w]𝔽

for some width w that depends on nstack, and on the heights and widths of the stacked trace matrices.

Each stacked column has height 2+nstack, where nstack is a global parameter chosen so that every included trace dimension satisfies nTnstack. For an AIR column with nT0, the column height is 2+nT, so it fits within the stacked height. Multiple AIR columns can be packed end-to-end within a single stacked column: for example, two AIR columns of height 2+nstack1 fit exactly into one stacked column of height 2+nstack.

Packing algorithm. The allocation of AIR columns into stacked columns follows a greedy bin-packing strategy that exploits the power-of-2 structure of trace heights. For packing, let n~T=max(nT,0), since traces with nT<0 are first lifted into one full D-slot as described below.

  1. Sort all AIR columns by height from tallest to shortest.
  2. Assign each column to the current stacked column, advancing to the next stacked column only when the current one is full.

Because every height is a power of 2 and n~Tnstack, this greedy strategy is gap-free except possibly at the end of the final stacked column. After sorting from tallest to shortest, the remaining capacity in the current stacked column is always a multiple of the next column height. The next column therefore either fills the remaining capacity exactly or consumes one divisor-sized slot inside it. The only possible waste is at the very end of the last stacked column, where the remaining rows are padded with zeros.

Example of stacking 4 traces with varying heights and widths without negative values.

Stacked layout. For each AIR column, the packing algorithm records a stacked layout entry consisting of three values:

  • the index j of the stacked column it was placed into,
  • the row offset 𝐛{0,1}nstackn~T encoding where within that stacked column the AIR column begins,
  • the packed height 2+n~T.

Because the prover chooses which AIRs are present and the height of each trace matrix at proving time, the stacked layout cannot be fixed in the verification key. Instead, the prover declares the heights of the present AIRs as part of the proof, and the verifier recomputes the stacked layout for each commitment from scratch by running the same deterministic packing algorithm on the declared heights and the relevant column partitions. The result is an injection ι from AIR column positions to stacked column positions that both parties agree on for that specific proof.

Commitment. Each stacked matrix is committed by applying Reed-Solomon encoding to its columns and building a Merkle tree over the resulting codeword matrix (as in WHIR). The resulting Merkle root is the stacked polynomial commitment for that matrix. The common main columns share one such commitment, while each nonempty cached or preprocessed partition has its own stacked commitment.

Opening reduction. Whenever the verifier needs an evaluation of an AIR column Tj at a random point 𝐫, this is reduced to an evaluation of the corresponding stacked column qj at a related point, using a sumcheck. For nT0, the reduction uses the injection ι to express the AIR column evaluation as a sum over the stacked column:

t^j(𝐫)=𝐳𝔻nstackq^j(𝐳)·eq(𝐫,𝐳nT)·eq(𝐛,𝐳>nT)

where 𝐳nT includes the D coordinate and the first nT boolean coordinates, and the second equality polynomial pins the rows to the AIR column’s slot within the stacked column. For nT<0, the full protocol inserts the inD,nT correction factor described below. All such reductions across the relevant trace columns and commitments are batched into a single sumcheck (Protocol 3.6.1 of the SWIRL paper), ultimately collapsing to evaluations of the stacked polynomials at a single shared random point, which are then proved using WHIR as a batched PCS.

Adjustments for univariate skip

The stacked discussion above uses SWIRL’s hyperprism notation. The key point for the univariate skip is that 𝔻n=D×n has one smooth multiplicative coordinate and n boolean coordinates. The prismalinear extension t^ of a column t:𝔻n𝔽 has degree <2 in the Z variable (the D coordinate) and is multilinear in the remaining n boolean variables X1,,Xn.

The univariate skip exploits this structure in sumchecks over 𝔻n: instead of running separate binary sumcheck rounds for the D coordinate, the prover handles all of D in a single round by sending a univariate polynomial of degree <2, from which the verifier samples a single challenge r0𝔽ext. The remaining n binary rounds proceed as normal. This saves 1 prover rounds at the cost of the verifier performing a larger interpolation and a small soundness loss.

Contrast with p, q, and the stacked matrix. The LogUp polynomials p and q are defined over the full boolean hypercube +nLogUp, with all +nLogUp variables binary. They do not use the D coordinate at all, but is instead encoded in the first boolean variables. By contrast, stacked columns are functions on 𝔻nstack and have height 2+nstack. When passed to WHIR, each stacked column is also viewed through the corresponding multilinear representation over +nstack, which has the same number of points.

Short columns and lifting. For an AIR with very few rows, the hypercube dimension nT may be negative. Specifically, if nT=i for some 0i, then the column domain is 𝔻nT=D(2i), a subgroup of D of order 2i, and the prismalinear extension t^ is a univariate polynomial of degree <2i. To bring such columns into the uniform framework, SWIRL defines the lift t~(Z):=t^(Z2i), a degree <2 polynomial over all of D. The lift repeats each column value 2i times across D with stride 2i.

The minimum concrete column height handled by the proof system is 2, regardless of the original trace height. This is mainly due to the fact that the first univariate sumcheck round has to be executed, and cannot be partially skipped. For packing into the stacked matrix, a column with nT<0 is treated as having effective stacking dimension n~T=max(nT,0)=0, meaning it occupies one full D-slot of 2 rows in the stacked matrix.

Example of stacking 4 traces with varying heights and widths with negative values.

Correction factors. Because the lift oversamples the actual column values, two correction factors appear in the protocol for nT<0:

  • In LogUp: the fractional sum for a trace of dimension nT<0 is computed by summing over all of D using the lifted polynomials T~ and T~rot. Since each actual row appears 2nT times in D, the sum overcounts by that factor. A weight of 2nT is applied to the entire trace’s contribution to the fractional sum to correct for this (Equation 3.11 of the SWIRL paper).

  • In stacked opening reduction: when reducing the evaluation t^j(r0,𝐫) to a stacked column evaluation, the factor inD,nT(Z) is inserted. For nT<0 this polynomial equals 2nT·Z21Z2+nT1, which is 1 on the subgroup D(2nT) and 0 elsewhere in D, weighted by 2nT to account for the lift. For nT0 it is identically 1.

Native SWIRL verifier

The native verifier is the Rust implementation of the SWIRL verifier in stark-backend.

Plonky3 AIR interface

The SWIRL proof system is built on top of the standard Plonky3 AIR interface, making it compatible with any circuit that implements Plonky3’s Air trait. The interface centers on two traits: BaseAir<F>, which declares the trace width and an optional preprocessed (constant) trace, and Air<AB: AirBuilder>, which exposes a single eval method where the circuit author writes algebraic constraints against the trace columns using a builder pattern. The builder provides selectors (is_first_row, is_last_row, is_transition) and the assertion method assert_zero, from which all constraint types are derived.

OpenVM extends this base interface with two additions. First, PartitionedBaseAir<F> splits the main trace into a set of cached partitions and a common partition, allowing different parts of the trace to be committed at different points in the protocol. Second, InteractionBuilder augments the builder with a push_interaction method, through which a circuit registers sends and receives on named buses; these interactions are compiled into the LogUp-GKR multiset argument described above. Any circuit that implements Air, BaseAirWithPublicValues, and PartitionedBaseAir satisfies the AnyAir trait object bound used internally by SWIRL, and can be passed directly to the prover without any additional glue code.

Interaction builders

Interactions between trace rows are expressed through typed buses. A bus is simply a u16 index that namespaces a set of interactions; any number of AIRs, or even different rows within the same AIR, communicate by sending and receiving on a shared bus index. Self-communication within a single AIR is in fact the common case: for example, an AIR may send a value on one row and receive it on another to enforce a range check or a memory consistency constraint, all without involving a second AIR. OpenVM provides two bus abstractions:

PermutationCheckBus enforces a multiset equality between its senders and receivers: the multiset of messages sent (with multiplicity +1) must equal the multiset of messages received (with multiplicity 1), so the signed sum over all rows and all participating AIRs is zero. A circuit calls bus.send(builder, message, enabled) to contribute a message with boolean multiplicity enabled and bus.receive(builder, message, enabled) to consume one with multiplicity -enabled. The two-sided nature means every message that is sent must be received somewhere, and vice versa.

LookupBus enforces a subset relation: a querying AIR asserts that its values appear in a fixed table. The table AIR calls add_key_with_lookups to register a key with a negative multiplicity equal to the total number of lookups for that key, and the querying AIRs call lookup_key to send each query with multiplicity +1. Soundness only requires bounding the total number of queries (the positive side); the table side is unbounded by design.

Both bus types ultimately lower to push_interaction, which records an Interaction { message, count, bus_index, count_weight }. The count expression is the signed multiplicity for that row. The count_weight field is a u32 that controls a separate verifier-side linear constraint on trace heights: at key generation time, for each bus the verifier accumulates icount_weighti·hi across all AIRs i with height hi, and checks that this sum does not exceed the field characteristic p. This ensures the total number of interactions of the bounded kind stays below p, which is a necessary condition for soundness of the LogUp argument (a wraparound in the count would cancel to zero without representing a genuine multiset equality). For the permutation bus, both senders and receivers carry count_weight = 1 because both sides must be bounded. For the lookup bus, table keys carry count_weight = 0 (no bound needed on the negative side) while queries carry count_weight = 1.

Fiat-Shamir security

The SWIRL proof system is made non-interactive via the Fiat-Shamir transform: all verifier challenges are replaced by outputs of a duplex sponge transcript that absorbs proof elements as they are produced. We verified that every value on which a subsequent challenge depends is absorbed into the transcript before that challenge is sampled, ruling out the class of “weak Fiat-Shamir” attacks where a challenge is sampled before all relevant commitments or claims are bound to the transcript.

Sponge collisions. Duplex sponges are inherently susceptible to trivial collisions between transcripts that absorb different sequences of values yet arrive at the same internal state:

  • In XOR mode, absorbing 0 is a no-op: the state is unchanged because s0=s.
  • In overwrite mode, absorbing the value that is already present in the rate slot is a no-op: the state is unchanged because overwriting s with s leaves s.

In both cases, there exist pairs of distinct absorption sequences that produce identical sponge states and therefore identical challenges. For instance, in XOR mode:

[absorb(42), absorb(0), sample()]
[absorb(42),            sample()]

yield the same challenge.

This raises a concern in a protocol where the prover dynamically controls how many values it absorbs, for example because AIR heights are prover-chosen and some AIRs are optional. A malicious prover could attempt to shift the transcript by absorbing fewer elements, or by inserting absorptions that are no-ops, in order to obtain a challenge it could not otherwise produce.

In the SWIRL verifier this is handled by the fact that the transcript encodes the prover’s choices in a self-describing way that admits a unique sequential decoding. Every degree of prover freedom is made explicit in the transcript before the values that depend on that freedom are absorbed. For example, for each AIR the prover first absorbs a presence bit (1 if the AIR is used, 0 if omitted); only if that bit is 1 does the prover then send the relevant trace commitments. The verifier reads these bits in order and knows, at each step, exactly how many and which field elements to expect next. There is therefore exactly one way to decode the field elements stream into a sequence of prover choices and absorbed values. A prover that tries to shift the transcript, say by skipping a commitment or inserting a no-op absorption, produces a byte stream whose presence bits and subsequent values are read by the verifier in a different order than intended, yielding a completely different set of commitments and claims that will fail verification.

Recursive verification circuit

Multiproof verification

The recursion circuit is parameterized by a compile-time constant MAX_NUM_PROOFS and is designed to verify up to that many proofs of the same child verification key simultaneously. Supporting multiple proofs in a single circuit is important for recursion efficiency: rather than wrapping each inner proof in its own separate recursion step, a single recursion circuit can absorb a batch of inner proofs at once, amortizing the fixed overhead of the outer circuit across all of them.

The proof_idx index serves as a routing key throughout the circuit. All inter-module buses are per-proof: every message sent or received on a bus carries proof_idx as its first field, so the multiset-equality or lookup argument only matches messages that belong to the same proof. This is realized by two families of typed bus macros in bus.rs: define_typed_per_proof_lookup_bus! and define_typed_per_proof_permutation_bus!, which both prepend proof_idx to every message automatically. As a result, each protocol module (GKR, constraint batching, stacking, WHIR, transcript) operates independently on each proof’s data, with the bus arguments enforcing consistency within but not across different proofs.

Recursion circuit architecture

The recursive circuit is composed of 39 AIRs organized into five modules plus shared primitives, all wired together through a rich network of buses. The modules mirror the sequential stages of the SWIRL verifier.

Transcript bus. The backbone of the circuit is the TranscriptBus, a per-proof permutation bus that encodes the Fiat-Shamir sponge operations. The TranscriptAir (in the Transcript module) is the sole sender on this bus: it replays the complete transcript log, emitting one message (tidx, value, is_sample) per field element, where is_sample = 0 denotes an observe (absorb) and is_sample = 1 denotes a sample (squeeze). Every other AIR in the circuit is a receiver: whenever a module needs to absorb a commitment or squeeze a challenge, it receives the corresponding TranscriptBus message. Bus balancing then ensures that the entire transcript is consistent and that every observe/sample performed by any AIR was actually executed in the correct order by the Poseidon2 sponge.

Module handoff buses. The five protocol modules are chained together by a sequence of four permutation buses, each carrying exactly one message per proof: GkrModuleBus, BatchConstraintModuleBus, StackingModuleBus, and WhirModuleBus. Each bus carries the output of one stage to the input of the next, together with the current transcript index tidx so the receiving module knows where in the transcript to continue.

Forced computation via bus balancing. The design enforces that the full verification pipeline must execute. The ProofShape module starts by sending messages on its output buses: those messages must be received by GKR, which in turn must send on BatchConstraintModuleBus, and so on down the chain. The WhirModuleBus is the terminal bus: it is sent by the Stacking module but received by the WHIR module, and the WHIR module itself sends no further handoff messages. If any module fails to produce its output messages, the bus imbalance propagates and the overall circuit has no satisfying witness. In this sense the circuit computes a pull pipeline: the WHIR module can only balance if it receives from Stacking, which can only balance if it receives from BatchConstraint, and so on, forcing every prior stage to run.

Overview of protocol modules

ProofShape module. The ProofShape module (3 AIRs: ProofShapeAir, PublicValuesAir, RangeCheckerAir) is the preamble of the verifier. It processes the proof’s structural metadata: it verifies that each AIR’s trace height is a valid power of two, observes the VK pre-hash and all trace commitments into the transcript, computes the two key dimensions used by downstream modules (nmax, the maximum hypercube dimension across all AIRs, and nlogup, the number of GKR layers), and broadcasts AIR shape data on a set of lookup buses (AirShapeBus, HyperdimBus, LiftedHeightsBus, CommitmentsBus, AirPresenceBus) for other modules to consume.

GKR module. The GKR module (4 AIRs) receives the parameters from ProofShape via GkrModuleBus and then executes the GKR reduction for the LogUp argument layer by layer, verifying the sumcheck at each GKR layer. At the end of the reduction it holds two polynomial claims at the input layer (a numerator and a denominator) and forwards them to the BatchConstraint module via BatchConstraintModuleBus.

BatchConstraint module. The BatchConstraint module (13 AIRs) receives the GKR input-layer claims and verifies them by evaluating the constraint and interaction expressions of the child circuit at a random point. It reads the child circuit’s column evaluations from the ColumnClaimsBus and the public values from PublicValuesBus, evaluates the full batched constraint/interaction polynomial using the symbolic expression DAG embedded in the cached trace, and reduces everything to a single polynomial opening claim. The StackingModuleBus signals to the Stacking module that this is complete.

Stacking module. The Stacking module (6 AIRs) receives the column opening claims from BatchConstraint and reduces them to a single batched claim on the stacked polynomial, using a univariate sumcheck followed by a multilinear sumcheck. The final batched claim, together with the mu batching challenge, is forwarded to the WHIR module via WhirModuleBus and WhirMuBus.

WHIR module. The WHIR module (8 AIRs) is the terminal stage. It receives the batched opening claim and verifies it using the WHIR polynomial commitment protocol: multiple rounds of folding sumcheck, Merkle path queries, and a final low-degree polynomial check. It has no output handoff bus; its only obligation is to balance all incoming messages, which it does by fully executing the WHIR verification.

DAG commitment. When the SymbolicExpressionAir operates in non-cached mode (i.e., when there is no pre-committed cached trace), it computes a hash of the constraint/interaction DAG row by row using an inline Poseidon2 computation (DagCommitSubAir). The resulting digest is exported as a public value of the SymbolicExpressionAir. This lets the calling circuit check that the recursion circuit evaluated the correct constraint DAG without having to supply a pre-committed VK.

Phase 2: Continuations and Deferral

The audit covered the following components in the OpenVM repository:

  • crates/continuations/: aggregation circuits for the recursion tree (leaf, internal-for-leaf, internal-recursive, and root layers), plus the deferral framework core under src/circuit/deferral/. The deferral framework is a parallel aggregation tree that links into the VM continuations tree; the continuations tree adds the constraints needed to bind the deferral side correctly.
  • extensions/deferral/: VM-side interface to deferral circuits: a new VM opcode, the transpiler shim, the guest library, and the circuit linking deferral input/output commits to VM memory through the RV32IM memory interface.
  • crates/verify/: host-side Rust verifier for the final aggregated VM STARK proof, intended for use cases such as the ethproofs WASM verifier.
  • guest-libs/verify-stark/{circuit, guest}/: circuits and guest library wrapper that wrap the deferral framework to provide a verify_stark capability for Rust guest programs, replacing the older guest-libs/verify_stark.

Overview

This section provides a high-level technical overview of the audited modules.

Continuations aggregation tree

OpenVM handles long executions by splitting them into VM segments and then recursively aggregating the resulting segment proofs. Each segment proof exposes the public values needed to connect it to the next segment: the app program commitment, the initial and final program counters, the exit code, the termination flag, and the initial and final memory roots. The continuations tree verifies child proofs and folds these public values upward until a single proof represents the whole execution.

The STARK aggregation pipeline has four main layers:

  • Leaf verifies app segment proofs.
  • Internal-for-leaf verifies leaf proofs.
  • Internal-recursive verifies internal-for-leaf proofs at the first recursive level, then verifies internal-recursive proofs at later levels.
  • Root wraps one final internal-recursive proof and checks the final conditions needed by the outer verifier.

The first three layers use the same inner aggregation subcircuit. The verifier part of the circuit checks each child proof against the relevant child verifying key, while the public-value aggregation part checks that adjacent VM segments line up. In particular, a non-terminal child must suspend successfully, and its final_pc and final_root must match the next child’s initial_pc and initial_root. The app program commitment is also kept constant across all valid children. The output proof re-exposes the first child’s initial state and the last child’s final state, so the same invariant can be checked again at the next aggregation layer.

The inner circuit also carries verifier public values that describe which verifier keys have been fixed so far. This is tracked by two flags:

Layer Internal flag Recursion depth Newly exposed VK commit
Leaf 0 0 app_vk_commit
Internal-for-leaf 1 0 leaf_vk_commit
First internal-recursive 2 1 internal_for_leaf_vk_commit
Second internal-recursive 2 2 internal_recursive_vk_commit
n-th internal-recursive 2 n internal_recursive_vk_commit

Each VK commit contains the cached commitment to the verifier circuit’s constraint DAG together with the child verifying key pre-hash. Unused VK commits are constrained to be unset, and already exposed commits are propagated unchanged. This gives later layers enough information to know which verifier circuit was used below them without exposing the full verifying keys.

The important fixed point is the internal-recursive layer. The internal-recursive prover is constructed so that, after the first internal-recursive proof has exposed internal_for_leaf_vk_commit, later internal-recursive proofs can use the internal-recursive verifying key as their own child key. From that point on, the verifier circuit is stable: an internal-recursive proof can verify other internal-recursive proofs, and the tree can keep reducing an arbitrary number of child proofs until only one remains.

Deferral framework: aggregation tree

The deferral framework has its own aggregation tree, separate from the VM segment aggregation tree. Each deferral circuit proof exposes DeferralCircuitPvs, which are just the input_commit and output_commit for one deferred computation. The deferral aggregation tree verifies these proofs, collects the IO commitments into a Merkle root, and keeps a count of how many real deferral circuit proofs were included.

At the leaf layer, DeferralAggPvsAir compresses (folded_input_commit, output_commit) into a leaf merkle_commit, and the proof count is 1 for each present child. At internal layers, the children already expose merkle_commits and counts, so the parent either passes through a single child or compresses two child roots into a new parent root while adding the counts.

The AIR supports three trace shapes:

Shape Meaning
One row Wrapper node: pass the child merkle_commit and count through unchanged.
Two present rows Binary aggregation node: hash the left and right child roots and add both counts.
One present row plus padding Tail node: hash the present child with the padding subtree root, while the count only includes the present child.

The resulting instance of this tree is a final internal-recursive deferral aggregation proof for one deferral circuit. The merkle_commit is a commitment to the list of folded input/output commitment pairs, and its num_def_circuit_proofs counts how many deferral circuit proofs were included, thus how many leaves the hook layer should expect to open.

Deferral framework: hook layer

The deferral hook layer sits between a per-circuit deferral aggregation proof and the combined VM/deferral continuations tree. It verifies one final internal-recursive deferral aggregation proof, decommits the proof’s merkle_commit into IO leaves, folds those leaves into the VM-style deferral accumulators, and exposes the result as DeferralPvs.

MerkleDecommitAir rebuilds the IO Merkle tree whose root is the child proof’s merkle_commit. Each real leaf is a pair (input_commit, output_commit). The trace may include padded leaves to make the tree size a power of two, but only the real prefix is sent onward through IoCommitBus; non-sent leaf rows are constrained to contain zero commitments.

OnionHashAir receives those IO pairs in order and folds them into two Poseidon2 onion accumulators:

input_onion_0  = def_circuit_commit
input_onion_i  = H(input_onion_{i-1}, input_commit_i)

output_onion_0 = 0
output_onion_i = H(output_onion_{i-1}, output_commit_i)

The initial input onion is the def_circuit_commit, not zero. This is how the VM-side accumulator is seeded with the identity of the deferral circuit whose outputs are being consumed.

DeferralHookPvsAir computes def_circuit_commit from the deferral aggregation VK commits, checks that the child proof is from the internal-recursive deferral layer, and receives the final onion values. It then exposes DeferralPvs:

  • initial_acc_hash: the memory-subtree leaf representing the initial accumulator state, built from def_circuit_commit and a zero output accumulator.
  • final_acc_hash: the memory-subtree leaf representing the final accumulator state, built from the final input and output onions.
  • depth = 1: the hook proof represents one input/output accumulator pair for a single deferral circuit.

These hook proofs are then aggregated by the combined VM/deferral continuations tree. There, DeferralPvsAir recursively Merklizes the hook outputs across deferral circuits by hashing child initial_acc_hash values together, hashing child final_acc_hash values together, and incrementing depth at each binary aggregation step. The prover can choose to omit some proofs for some subtree, but in this case the initial and final roots for those subtrees must be equal. Since the initial tree is checked to be equal to the expected deferral address space commitment, this means that any omitted subtrees must be unchanged from the initial state, so the omitted subtrees cannot contribute any new deferral IO pairs.

Root wrapper

The root wrapper is the final STARK layer before the static/EVM-oriented proof format. It takes one internal-recursive child proof and replaces the recursive aggregation public values with the compact public claims expected by the outer verifier.

The wrapper checks that the child proof represents a successful terminated execution, that it comes from the internal-recursive layer, and that the child verifier commitment matches either the first internal-recursive case or the self-recursive case. It then computes:

  • app_exe_commit: a commitment to the app program commitment, the initial memory root, and the initial program counter.
  • app_vm_commit: a commitment to the app, leaf, and internal-for-leaf VK commits.

The user public values are exposed by the root proof itself through UserPvsCommitAir. This AIR chunks them into digests and builds a Merkle root, while UserPvsInMemoryAir proves that this public-values root opens at PUBLIC_VALUES_AS under the child’s final memory root. This is the step that links the user-visible public values to the VM execution state.

When deferrals are enabled, the root also receives the child proof’s VerifierDefPvs and DeferralPvs. If the child has accumulated deferrals, the root checks that the propagated def_hook_commit matches the expected hook circuit commitment and sends the initial_acc_hash, final_acc_hash, and depth to DeferralAccMerklePathsAir. That AIR opens the initial accumulator hash to the VM’s initial memory root and the final accumulator hash to the VM’s final memory root under the DEFERRAL_AS subtree. If the child claims no deferrals, the root constrains the deferral public values to be unset and proves the relevant deferral address-space region stayed unchanged.

Deferral VM extension

The deferral extension adds two VM opcodes, CALL and OUTPUT, that let a guest program delegate a computation to an external deferral circuit while keeping the VM proof linked to the deferred computation through commitments. The guest calls deferred_compute::<IDX>(&input_commit), where IDX selects the deferred function/circuit and input_commit is a 32-byte commitment to the deferred input. The CALL opcode returns an OutputKey { output_commit, output_len }: output_commit commits to the deferred output bytes, while output_len tells the guest how much memory to allocate before retrieving the raw output.

The raw output is then consumed through get_deferred_output::<IDX>(&mut output, &output_key), which emits the OUTPUT opcode. OUTPUT reads the (output_commit, output_len) pair, writes the raw output bytes to guest memory, and constrains that those bytes hash back to the supplied output_commit under the selected deferral index. In the intended safe pattern, output_len should be treated as an allocation hint until OUTPUT has succeeded; application logic should branch on the actual output only after OUTPUT has authenticated it.

Internally, the extension maintains per-circuit accumulator state in the dedicated deferral address space, DEFERRAL_AS = 4. Here 4 is the address-space identifier, not the accumulator value itself: concrete memory addresses are pairs of the form (address_space, pointer), so DEFERRAL_AS[k] below is shorthand for (DEFERRAL_AS, k).

Note that there can be multiple deferral circuits, so for each deferral circuit index i, the VM stores an input accumulator and an output accumulator at fixed offsets:

input_acc_i  at DEFERRAL_AS[2*i*DIGEST_SIZE]
output_acc_i at DEFERRAL_AS[(2*i + 1)*DIGEST_SIZE]

These offsets are field-element pointers. The memory tree groups cells into CHUNK = DIGEST_SIZE = 8-field blocks, so the input_acc_i pointer corresponds to block 2*i and the output_acc_i pointer corresponds to block 2*i + 1 in the diagram below.

CALL folds the input commitment into input_acc_i and the output commitment into output_acc_i. Later, the continuation/root deferral machinery checks that these accumulator updates are consistent with the separate deferral proofs, so the final proof ties together both sides: the VM execution that requested deferred work, and the external proof that the deferred computation was valid.

Memory tree and the DEFERRAL_AS subtree

OpenVM represents VM memory with a Merkle-tree-like commitment so that each segment can summarize a large memory state with a single root. A caveat is that the internal node operation is a Poseidon2-based fixed-width compression, not a generic collision-resistant hash function by itself. This is the same design family as the Plonky3-style Merkle tree discussed in The Billion Dollar Merkle Tree: the internal compression is not a collision-resistant hash function, but instead it is a truncated permutation. The security of the construction relies on the fact that the leaf hashes are computed using a collision-resistant hash function.

During a segment, the VM only touches a small subset of memory blocks. A tree commitment lets the prover update and authenticate the changed blocks through short Merkle paths, instead of recomputing or exposing the entire memory image at every segment boundary. The resulting roots are the compact public commitments to the segment’s initial and final memory states.

OpenVM organizes memory as a tree of fixed-size memory blocks. Each leaf represents one block of 8 field elements, and the tree path first identifies the address space, then the block inside that address space. The root of this tree is a compact commitment to the whole VM memory state.

At a segment boundary, the VM exposes the memory root before and after execution as initial_root and final_root, so the verifier can check that memory changed consistently without seeing the entire memory.

OpenVM memory tree shape

DEFERRAL_AS = 4 is one subtree inside this memory tree. The deferral extension stores the per-circuit input and output accumulators in fixed slots under that subtree. CALL updates those accumulator slots during VM execution, while OUTPUT later authenticates raw output bytes against the returned output_commit without changing the accumulator state. Separately, the deferral aggregation and hook layers expose initial_acc_hash, final_acc_hash, and depth, which describe the accumulator subtree that should connect the VM execution to the deferred computations.

At the root layer, DeferralAccMerklePathsAir links these two worlds: it checks that the initial_acc_hash and final_acc_hash exposed by the deferral hook open, via Merkle paths, to the VM’s initial_root and final_root under the DEFERRAL_AS subtree. The intended invariant is that depth stays within the DEFERRAL_AS subtree height (address_height), so the accumulator opening cannot point above the deferral subtree into unrelated memory regions.

Host verifier

The host verifier is the host-side Rust verifier for a final internal-recursive VmStarkProof. It verifies the STARK proof with the supplied aggregate verifying key, checks the proof’s public values, and also aggregates/validates deferrals by linking the deferral accumulator state back to the VM memory roots. Conceptually, it plays the same boundary-verification role as the root verifier circuit, but in Rust code instead of AIR constraints; therefore, consistency between these two verifiers should be maintained as closely as possible. This verification function is exposed through the SDK as Sdk::verify_proof and is also what runs behind the scenes for the cargo openvm verify stark CLI command.

The verifier essentially verifies two things:

  1. Verify the inner proof, which contains the STARK proof of the outermost internal-recursive circuit.
  2. Verify the proof’s public values.

The first verification simply invokes stark-backend proof verification against the inner proof using the fixed VmStarkVerifyingKey. The second verification is where most of the specific checks happen:

  • Verifies that the user public values open to the claimed final memory root.
  • Recomputes and checks the application executable commitment from the VM public values.
  • Checks that execution terminated successfully.
  • Enforces that the recursion depth is in the range [1, MAX_RECURSION_DEPTH]
  • Compares the exposed app, leaf, and internal verifier commitments against the baseline.
  • Checks the recursive cached trace commitment.
  • Validates the deferral state:
    • If deferral_flag == 0: checks that no deferral public values are set and that the deferral address space is unchanged.
    • If deferral_flag == 2: checks that the exposed def_hook_commit matches the expected baseline and that the deferral accumulator roots are linked to the VM memory roots.

verify-stark guest library

The verify-stark guest library is a supported deferral use case: it lets a Rust guest ask the deferral framework to verify another OpenVM STARK proof. Instead of executing the full STARK verifier inside the guest program, the guest passes an input_commit to verify_stark::<DEF_IDX>(input_commit, expected). The DEF_IDX parameter selects the registered verify-stark deferral circuit. The input_commit is the guest-visible deferral input key for the child proof verification claim; the verify-stark circuit constrains it from the child verifier transcript state, and the deferral state supplies the corresponding output bytes.

The guest wrapper is intentionally small. At a high level, its flow is:

guest calls verify_stark::<DEF_IDX>(input_commit, expected)
        |
        v
CALL / deferred_compute
        |
        v
OutputKey { output_commit, output_len }
        |
        v
OUTPUT / get_deferred_output
        |
        v
raw output bytes
        |
        v
parse as app_exe_commit || app_vm_commit || user_public_values
        |
        v
compare parsed ProofOutput against expected

The middle part is the generic deferral pattern: CALL returns an OutputKey, OUTPUT authenticates and materializes the raw output bytes, and only then should the guest parse or branch on the output. In the verify-stark wrapper, verify_stark_unchecked::<DEF_IDX>, after calling deferred_compute::<DEF_IDX>(input_commit), it allocates a buffer of length output_len taken from the output_key, and immediately calls get_deferred_output::<DEF_IDX>(&mut output_bytes, &output_key) to validate the output buffer. After the output is validated, it checks the output length to be sufficiently large, and it starts to parse the result as ProofOutput = app_exe_commit || app_vm_commit || user_public_values. The first two fields are 32-byte commitments and user_public_values is the remaining byte encoding of the child proof’s public values. The checked wrapper, verify_stark::<DEF_IDX>, compares this parsed ProofOutput against the expected value supplied by the guest and panics if they differ.

On the deferral-circuit side, guest-libs/verify-stark/circuit proves that the verification claim represented by input_commit comes from a valid aggregated OpenVM STARK proof for the expected child verifier key and proof shape. It constrains the child proof’s VM and verifier public values, checks that the child exited successfully, binds the child user public values to the child final memory root, and computes the output commitment for the serialized ProofOutput. If the child proof itself used deferrals, the verify-stark circuit can also include the deferral accumulator Merkle-path checks needed to bind the child’s deferral public values back to its memory roots.

This composes with the generic deferral framework in the usual way: the guest-side CALL records the claimed (input_commit, output_commit) pair in the deferral accumulators, OUTPUT authenticates the raw bytes against output_commit, and the external deferral proof shows that the output was produced by the verify-stark circuit for the child proof verification claim represented by input_commit.

Phase 3: Static Verifier

The audit focused on the static-verifier crate (part of the OpenVM v2 implementation). The crate implements a STARK verifier using Halo2 (with KZG polynomial commitments) and is used to “compress” the final STARK proof from OpenVM into a small PlonK proof: 2080 bytes for STARK-in-Halo2 and 1376 + 384 = 1760 bytes for the STARK-in-Halo2-in-Halo2 proof. The goal is for this proof to be posted on-chain without excessive gas costs: posting the original STARK proof would require a large amount of calldata.

Overview of Key Concepts

In this section we give an overview of key concepts specific to the Halo2 verifier circuit.

Lazy foreign-field arithmetic

The Halo2 circuit natively enforces constraints on the scalar field of the BN254 curve. We write Fr to denote the BN254 scalar field and Fr::MODULUS to denote its modulus. In order to verify an OpenVM STARK proof, the Halo2 circuit needs to enforce constraints on BabyBear field operations. To bridge this gap, the circuit needs to implement arithmetic in a foreign field. If implemented naively, these foreign-field operations can be prohibitively costly.

One key idea in optimizing the foreign-field arithmetic is to leverage the fact that Fr is much larger than the BabyBear field. A single Fr cell can store an unreduced BabyBear element as a centered lift: value is a signed integer, with a negative -v encoded as Fr::MODULUS - v. The signed value is only recoverable as long as operations do not over- or underflow Fr::MODULUS/2; past that bound the positive and negative ranges collide and the sign is lost.

Towards this, the Halo2 circuit defines a BabyBearWire structure that stores a value value and a number of bits max_bits. The former is the signed, unreduced BabyBear element; the latter bounds its magnitude as |value| < 2^max_bits, with max_bits <= Fr::CAPACITY - RESERVED_HIGH_BITS so that no operation can reach Fr::MODULUS/2 and wrap the centered lift. It is crucial for soundness that value and max_bits are always consistent with each other: when the BabyBearWire is instantiated and through every operation applied to it.

Multi-field transcripts

In order to represent hashing operations efficiently as an Fr circuit, the static-verifier crate uses a Poseidon2 sponge over Fr. However, this sponge needs to absorb and squeeze BabyBear field elements. The static-verifier implementation therefore needs to provide a form of codec: packing BabyBear field elements into Fr elements (for absorbing) and converting Fr elements into multiple BabyBear elements (when squeezing).

Specifically, packing is implemented by treating the input BabyBear field elements as the 231-ary representation of a Fr field element:

pack(v0,,vk1)=i=0k1vi · 2i·31

This packing assumes that we are not packing more than k=8 BabyBear field elements (see NUM_OBS_PER_WORD).

Unpacking is slightly different since a base-231 decomposition of an Fr element would yield a vector of elements in [0,231) rather than a vector of BabyBear field elements. The unpack operation takes as input a value in in Fr and outputs u0,,u4 and top such that:

ui[0,p)for i{0,,4}top[0,Fr::MODULUS1p5]in=i=04ui·pi+top·p5

The elements u0,,u4 are now guaranteed to be BabyBear elements derived from the sponge output.

Phase 4: RC.1 (SHA-2, Keccak, and Memory-Adapter Removal)

The audit focused on the rc.1 changes, which can be categorized in three main areas.

  • New SHA-2 family AIRs and guest libraries. A redesigned SHA-2 implementation that adds SHA-256, SHA-384, and SHA-512 (previously only SHA-256 was supported). The relevant crates (crates/circuits/sha2-air, extensions/sha2, guest-libs/sha2, and the crates/circuits/primitives/derive/src/cols_ref/ support) were reviewed as new code.
  • New Keccak-256 AIRs and guest libraries. A simpler Keccak-256 redesign (extensions/keccak256, guest-libs/keccak256), also reviewed as a new implementation.
  • Removal of memory-access adapters. Memory-access adapters previously reconciled different block sizes on the memory bus. rc.1 removes them: every memory access now uses a single DEFAULT_BLOCK_SIZE = 4 cells, and PersistentBoundaryAir (crates/vm/src/system/memory/persistent.rs) is updated to bridge 4-cell bus accesses against the 8-cell Merkle leaves of the persistent-memory tree.

Following the guidance from the OpenVM team, we treated the two hash redesigns as new implementations and the memory change as a diff.

In addition to the main repository, the following forked guest libraries were in scope, as they patch standard hash crates to call OpenVM intrinsics on the zkvm target:

  • openvm-org/hashes (a RustCrypto hashes fork) on branches openvm/sha2-v0.10.8 and openvm/sha3-v0.10.8, along with the corresponding v0.11.0 patches.
  • openvm-org/tiny-keccak, patched for the OpenVM target.

Overview of Key Mechanisms

This subsection describes three recurring design patterns encountered during the rc.1 review. They are not findings; they illustrate how OpenVM achieves soundness across independent AIRs and help frame the findings that follow.

Cross-AIR binding of a permutation via a timestamp-keyed bus

The Keccak-f permutation is split across two AIRs that never share a row. KeccakfOpAir handles the VM-facing side of one KECCAKF instruction: it reads the state pointer, binds the 200-byte pre-state and post-state to memory, and drives the execution bus. KeccakfPermAir wraps the Plonky3 keccak-air, which actually constrains post = keccak_f(pre) over 24 rounds. Neither AIR proves the other’s part; they are joined by a single permutation-check bus. The op AIR sends (0, t, pre) and (1, t, post) and the perm AIR receives the same two messages, so multiset equality forces every claimed (pre, post) pair emitted by an op row to be matched by a perm row that internally proves it is a real permutation pair.

The two messages share a timestamp t but are sent separately (to avoid one very large message), so the bus binds the multiset of preimages at time t and the multiset of postimages at time t independently. The pre/post pairing then rests on t being unique per enabled instruction, which the perm AIR documents it assumes but does not enforce. That uniqueness is supplied by the global execution model: the execution bus is a permutation anchored by the connector, every instruction advances the timestamp by a positive amount, and all timestamps are bounded below the field by the memory checker, so two enabled instructions cannot share a timestamp. We reviewed this and consider it sound; it is a compact illustration of the project’s “correctness lives on the buses” philosophy, and the same shape recurs (the SHA-2 main chip and block-hasher, range/bitwise lookups, and the memory bus itself).

The memory bus, the boundary anchor, and 4-to-8 chunk reconciliation

OpenVM has no single global memory trace. Every chip that touches memory posts its reads and writes to a shared memory bus through a memory bridge; each access carries (address_space, pointer, value, timestamp), and the offline checker enforces that timestamps strictly increase per cell so values flow forward in time. For the bus to balance, the boundary chip is the unique source of each cell’s initial value (at timestamp 0) and the unique sink of its final value; the boundary’s initial and final states are hashed into a Merkle tree whose roots are public, anchoring the whole memory history.

The interesting rc.1 detail is the consequence of removing the access adapters. Every memory access now uses DEFAULT_BLOCK_SIZE = 4 cells, but the persistent-memory Merkle tree hashes leaves of CHUNK = 8 cells. So the boundary chip must bridge a 4-cell bus against an 8-cell Merkle leaf, which it does by splitting each 8-cell chunk into two 4-cell memory-bus messages: initial rows pin both sub-blocks’ timestamps to 0; final rows carry an independent timestamp per 4-cell sub-block; and an untouched 8-cell chunk must still match its initial value on both halves so untouched siblings cancel cleanly on the bus. The recurring audit question for this scope is whether the 4-to-8 split preserves the invariant that the boundary is the unique source and sink, with no way to inject or hide a cell. We reviewed the split and the Merkle sparse-subtree reuse and found them consistent.

hash versus final_hash: a free chaining payload

The standalone SHA-2 sub-AIR processes a sequence of compression blocks and, on each block’s digest row, stores two state-shaped fields that look interchangeable but are not. final_hash is the computed output of the block’s compression (range-checked, the real result). hash is a free field whose addition carries are not range-checked on digest rows, so the prover may set it to any bit-valid value; it serves only as the outbound payload of an internal chaining bus, where each block sends hash and receives the next block’s prev_hash. So hash is not “this block’s result” but “the value to hand to the next block”: within a single multi-block hash the honest prover sets hash = final_hash, and at an invocation boundary it sets hash to the next invocation’s IV. VM-level correctness does not flow through hash at all; the wrapper chip binds each block’s state to memory and to the SHA-2 bus using prev_hash and final_hash. This pattern surprised several reviewers, who each re-derived “hash is never constrained to equal final_hash” and briefly read it as a soundness gap before seeing that final_hash is what is authenticated downstream. The lesson, shared with the first mechanism above, is that an unconstrained-looking column can be intentional communication slack rather than an under-constraint.