Guides

Prepared execution

Build a PreparedSync once per operation kind and configuration, reuse it for every request, and know what it shares, what stays private to each request and when to rebuild it.

The fresh calls prove_sync_op and verify_sync_op compile a circuit and compute its commitment again on every call, in addition to the work that depends on the request. prepare moves that setup out of the request path: you build a PreparedSync once per operation kind and configuration, then pass it to the prepared calls for every request of that kind.

Before you begin#

  • Build requests as described in Proving state operations.
  • Decide the configuration your service runs with: tree depth, leaf-encoding bound and layer strategy. A preparation is valid for exactly one configuration.

What preparation does#

StateSyncProver::prepare(kind) compiles the circuit for one operation kind with the wiring hints, derives the verifier's wiring from those hints, and computes the full circuit commitment. It returns a PreparedSync, or SyncError::Compile when the configuration cannot be compiled.

CallWork done on every call
prove_sync_opCompile the circuit, generate the witness, compute the commitment, prove
prove_sync_op_preparedGenerate the witness, prove
verify_sync_opCompile the circuit with wiring hints, derive the wiring, compute the commitment, verify
verify_sync_op_preparedCheck that the request's operation kind matches, verify

Both proving paths run the same prove body, so a prepared proof is byte for byte the proof that the fresh path produces for the same request, and either verifier accepts it.

Prepare once, reuse for every request#

A service typically prepares all three operation kinds at startup and selects the preparation by each request's own kind. The program below does that, then proves eight requests at depth 24, the default configuration:

src/main.rsRust
use std::process::ExitCode;

use statesync_gkr::compiler::{
    AssetId, LayerStrategy, LeafPayload, LeafState, MerklePath, PublicInputs, SmtOpKind,
    SmtOperation, SmtParams, SmtWitness,
};
use statesync_gkr::primitives::field::{BaseField, PrimeCharacteristicRing};
use statesync_gkr::primitives::hash::{Digest, HashGadget, Poseidon2Gadget};
use statesync_gkr::{PreparedSync, StateSyncGkrConfig, StateSyncProver, SyncRequest, SyncResult};

/// Long-lived proving state: one prover and one preparation per operation kind.
pub struct PreparedProver {
    prover: StateSyncProver,
    membership: PreparedSync,
    non_membership: PreparedSync,
    update: PreparedSync,
}

impl PreparedProver {
    /// Compile, commit and derive wiring once per operation kind. Call at startup.
    pub fn new(config: StateSyncGkrConfig) -> Result<Self, String> {
        let prover = StateSyncProver::new(config);
        let membership = prepare_kind(&prover, SmtOpKind::Membership)?;
        let non_membership = prepare_kind(&prover, SmtOpKind::NonMembership)?;
        let update = prepare_kind(&prover, SmtOpKind::Update)?;
        Ok(Self {
            prover,
            membership,
            non_membership,
            update,
        })
    }

    /// The preparation for the request's own kind, so kinds can never be mixed.
    fn prepared_for(&self, request: &SyncRequest) -> &PreparedSync {
        match request.operation.kind() {
            SmtOpKind::Membership => &self.membership,
            SmtOpKind::NonMembership => &self.non_membership,
            SmtOpKind::Update => &self.update,
        }
    }

    /// Prove one request and return the result only if verification accepts it.
    pub fn prove(&self, request: &SyncRequest) -> Result<SyncResult, String> {
        let prepared = self.prepared_for(request);
        let result = self
            .prover
            .prove_sync_op_prepared(prepared, request)
            .map_err(|error| format!("proving failed: {error:?}"))?;
        if !self.prover.verify_sync_op_prepared(prepared, request, &result) {
            return Err("the proof or its request binding was rejected".to_owned());
        }
        Ok(result)
    }
}

fn prepare_kind(prover: &StateSyncProver, kind: SmtOpKind) -> Result<PreparedSync, String> {
    prover
        .prepare(kind)
        .map_err(|error| format!("preparing {kind:?} failed: {error:?}"))
}

fn field(value: u32) -> BaseField {
    BaseField::from_u32(value)
}

/// A synthetic membership request with fixture siblings, as in the Quickstart.
fn membership_request(params: &SmtParams, index: u32) -> Result<SyncRequest, String> {
    let hasher = Poseidon2Gadget::new(params.leaf_max_fields as usize);
    let key = AssetId(u64::from(index));
    let payload = LeafPayload {
        sync_state: vec![field(42 + index), field(7)],
        identity_digest: [9_u8; 32],
    };
    let leaf = LeafState::Occupied(payload.clone());
    let path = MerklePath {
        siblings: (0..params.depth)
            .map(|level| Digest([field(level * 13 + 1); 8]))
            .collect(),
    };
    let root = path
        .compute_root(&hasher, params, key, &leaf)
        .map_err(|error| format!("root construction failed: {error:?}"))?;
    let value_digest = hasher
        .hash_leaf(&leaf.encode())
        .map_err(|error| format!("leaf hashing failed: {error:?}"))?;
    let operation = SmtOperation::Membership { key, payload };
    let op_kind_tag = PublicInputs::kind_tag(operation.kind());
    Ok(SyncRequest {
        operation,
        witness: SmtWitness { leaf, path },
        public_inputs: PublicInputs {
            old_root: root,
            new_root: root,
            op_kind_tag,
            asset_id: key,
            value_digest,
        },
    })
}

fn run() -> Result<(), String> {
    // Depth 24 and the 31-field leaf-encoding bound are the defaults.
    let config = StateSyncGkrConfig {
        smt: SmtParams::default(),
        layer_strategy: LayerStrategy::A,
        ..Default::default()
    };
    let params = config.smt;
    let engine = PreparedProver::new(config)?;

    for index in 0..8 {
        let request = membership_request(&params, index)?;
        engine.prove(&request)?;
    }
    println!("prepared-requests=PASS");
    Ok(())
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("prepared-requests=FAIL: {error}");
            ExitCode::FAILURE
        }
    }
}

The program prints prepared-requests=PASS.

PreparedProver::new pays the setup cost three times, once per kind, and every later request pays only for its witness, its proof and its verification. A verifier service that never proves follows the same pattern: it builds its own preparations from the same configuration and calls verify_sync_op_prepared, or verify_encoded_sync_op for proofs received as bytes (Encoding and transport).

What is shared and what stays per request#

ItemHeld byLifetime
Compiled circuit for one operation kind and configurationPreparedSyncProcess
Full circuit commitmentPreparedSync, readable through circuit_commitment()Process
Derived verifier wiringPreparedSyncProcess
Public inputs: roots, operation tag, key and value digestThe requestOne request
Private witness: leaf and sibling pathThe requestOne request
Circuit witness: every wire valueBuilt inside each proving call, or held in a ProveJobOne request
Fiat-Shamir transcript and challengesInside each prove or verify callOne call
GKR proofSyncResultOne request

Sharing stops at setup by design. Each request's transcript starts from its own public inputs, and everything the prover computes after that depends on the request's challenges and witness. Sharing any of that state across requests would change proof bytes, so preparation shares only work that cannot influence them. Faster throughput for many requests comes from running independent jobs in parallel, covered in Batching and parallelism.

Thread safety and lifetime#

  • PreparedSync is immutable after construction and Sync, so one instance serves every worker thread. Share it by reference inside a thread pool, or through std::sync::Arc in a long-lived service.
  • PreparedSync implements Clone, but a clone copies the whole compiled circuit. Share one instance instead of cloning it per request.
  • PreparedSync itself cannot be serialized. Apart from the reviewed material described in Loading reviewed prepared material, build it when the process starts.
  • A PreparedSync does not record the configuration it was built with; every prepared call uses the configuration of the prover you call it on. Keep each preparation with the prover that built it, or with a prover configured identically. StateSyncProver::config is a public field, so never change it after preparing.

When to rebuild#

Build a new PreparedSync when any of these change:

  • The operation kind. Each kind has its own circuit, so a service needs one preparation per kind it serves.
  • smt.depth, smt.leaf_max_fields or layer_strategy. Together with the kind, these are the only values that prepare reads.
  • The source revision of StateSync-GKR. A preparation lives only in memory, so building it at process start keeps it matched to the code that uses it.

No rebuild is needed when roots, keys, payloads or paths change, because those belong to individual requests. Changing StateSyncGkrConfig::batching does not affect preparation either.

A prepare error is a configuration error, such as a layer strategy other than A, a depth of 0 or a leaf-encoding bound below 10. Retrying returns the same error, so fix the configuration instead.

Measured benefit#

Direct measurements at depth 24 found 3.46–4.45 times lower per-request proving-path latency on the prepared path than on the fresh path: about 3.69 for membership, 3.46 for non-membership and 4.45 for update. Each operation and mode ran in three processes of 1,000 timed requests, and the ratios compare run-matched process summaries. Encoding, transport and acceptance are outside the timer. The measurements used the 1.1.0 release source.

Repeated requests

Prepare the circuit once. Pay less for every request.

3.46–4.45×lower per-request proving time at depth 24
0 ms80 ms160 ms240 ms320 ms155.6 ms42.1 msMembership3.69× lower159.5 ms46.1 msNon-membership3.46× lower289.1 ms64.9 msSingle-leaf update4.45× lower

Depth 24, one request per call on a 192-core bare-metal host, three processes of 1,000 timed requests per operation and mode. Prepared material is reusable only for the same operation kind and configuration. Encoding, transport and acceptance are excluded.

The gap is large because preparation removes the circuit commitment as well as compilation. Direct-call medians for depth-24 membership, taken on two AMD EPYC 9R45 sockets (192 CPUs, SMT off, 384 GiB of RAM) with Rust 1.96.1 and -Ctarget-cpu=native:

Direct callMedian (ms)
Compile with wiring hints7.90
Derive verifier wiring4.42
Circuit commitment105.03
Witness generation0.55
Prove on the circuit41.54
Verify with derived wiring5.87

Each value is the median of five per-process request medians, with 300 measured requests per process. Each row times one direct call on its own, so a sum of rows is a cost model that was never measured as a single interval.

The derived wiring that a PreparedSync holds is also the faster of the two wiring evaluators. With the same proof and request, the complete prepared verification call was 10.80–23.68 times faster with derived wiring than with the sparse table wiring, across nine depth and operation profiles. That comparison excludes preparation and changes only the wiring evaluator. Benchmark results gives the full data, and Benchmark methodology defines each interval.

To see the effect on your own hardware, run the development harness, which reports single-path and prepared costs among other sections:

Shell
RUSTFLAGS="-Ctarget-cpu=native" cargo run --release --locked --bin measure

Treat its output as a development observation. Absolute values from two machines are comparable only when hardware, compiler flags, thread placement, background load and repetition policy are all fixed.

Loading reviewed prepared material#

For one profile, the facade can build a PreparedSync from serialized material instead of compiling. prepare_pinned_d24_a_membership(&bytes) accepts only the reviewed material for depth-24 membership with layer strategy A and the default 31-field leaf-encoding bound. It first requires the prover's configuration to match that profile and returns PreparedMaterialError::ConfigMismatch otherwise. It then checks the exact length and digest of the bytes, decodes them strictly, compares the circuit commitment with the reviewed value and rebuilds the derived wiring locally instead of trusting it from the input. It never falls back to compiling. This path serves the recorded external-proof route; for every other profile, use prepare.

Common mistakes#

SymptomCauseFix
A debug build stops on an assertion, or a release build panics or produces rejected proofsA request of another kind was passed to prove_sync_op_prepared or make_job_prepared; those calls check the kind only in debug buildsSelect the preparation by request.operation.kind(), as PreparedProver does
Every prepared proof is rejected after a configuration changeThe prover's configuration changed after prepare, or the preparation came from a differently configured proverRebuild the preparations whenever smt or layer_strategy changes
Verification throughput is far below expectationsverify_sync_op runs in a loop and compiles, derives wiring and commits on every callUse verify_sync_op_prepared
Each request spends time and memory copying the circuitA PreparedSync is cloned for each requestShare one instance by reference or through Arc
Startup retries prepare in a loopA configuration error was treated as transientFix the configuration; prepare fails the same way every time

Checklist#

  • One PreparedSync per operation kind and configuration, built at startup.
  • The preparation is selected by the request's own kind.
  • Workers share each preparation by reference or through Arc.
  • Preparations are rebuilt when depth, leaf-encoding bound or layer strategy changes.
  • Verifiers use verify_sync_op_prepared or verify_encoded_sync_op with preparations built from the same configuration as the prover's.
  • A request is accepted only after verification returns true.

Next steps#