Get started

Quickstart

Run the bundled example to prove a sparse-Merkle membership statement, verify the proof, and see the verifier reject a tampered copy.

This page runs one example program from the repository. The program builds a small membership request, proves it, verifies the proof, then changes one value inside the proof and confirms that the verifier rejects it. Along the way you will see the four stages that every StateSync-GKR request passes through: preparation, witness generation, proving and verification.

Before you begin#

  • Complete Installation: you need a clone of the repository with Rust 1.96.1 active inside it.
  • Nothing else is required. After the first build downloads its dependencies, the example runs locally and needs no network service, zkVM toolchain or account.

Run the example#

From the repository root:

Shell
cargo run --release --locked --example state_sync_prove_verify

Expected output:

Text
honest-proof=PASS
tampered-proof=REJECTED
secondary-finalized=false

The program exits with status 0. If any step fails, it prints quickstart=FAIL: and the reason to standard error and exits with a failure status. The first run compiles the workspace and its dependencies, so it takes noticeably longer than later runs.

What the output means#

LineMeaning
honest-proof=PASSThe verifier accepted the proof for the request it was produced from.
tampered-proof=REJECTEDAfter the example changed one value in the proof, the same verifier rejected it.
secondary-finalized=falseA fixed statement printed by the example: verifying a proof locally finalizes nothing on a destination chain.

The last line uses the repository's finality vocabulary. Primary finality is finality in the source domain, and secondary finality is finality on a destination chain. This example proves and verifies in memory and submits nothing, so it declares no secondary finality. Trust boundaries shows where that line falls in a deployment.

Read the example#

The example source (opens in a new tab) is short enough to read in full:

examples/state_sync_prove_verify.rsRust
//! Minimal reusable-core example: construct one membership request, prove it,
//! verify it, then demonstrate rejection after proof tampering.

use std::process::ExitCode;

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

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

fn membership_request(depth: usize) -> Result<SyncRequest, String> {
    let hasher = Poseidon2Gadget::default();
    let params = SmtParams {
        depth: depth as u32,
        ..Default::default()
    };
    let key = AssetId(5);
    let payload = LeafPayload {
        sync_state: vec![field(42), field(7)],
        identity_digest: [9_u8; 32],
    };
    let leaf = LeafState::Occupied(payload.clone());
    let path = MerklePath {
        siblings: (0..depth)
            .map(|index| Digest([field(index as u32 * 13 + 1); 8]))
            .collect(),
    };
    let old_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:?}"))?;

    Ok(SyncRequest {
        operation: SmtOperation::Membership { key, payload },
        witness: SmtWitness { leaf, path },
        public_inputs: PublicInputs {
            old_root,
            new_root: old_root,
            op_kind_tag: 0,
            asset_id: key,
            value_digest,
        },
    })
}

fn run() -> Result<(), String> {
    let depth = 4;
    let prover = StateSyncProver::new(StateSyncGkrConfig {
        smt: SmtParams {
            depth,
            ..Default::default()
        },
        layer_strategy: LayerStrategy::A,
        batching: Default::default(),
    });
    let request = membership_request(depth as usize)?;
    let mut result = prover
        .prove_sync_op(&request)
        .map_err(|error| format!("proving failed: {error:?}"))?;

    if !prover.verify_sync_op(&request, &result) {
        return Err("the honest proof was rejected".to_owned());
    }
    println!("honest-proof=PASS");

    let first_layer = result
        .proof
        .layer_proofs
        .first_mut()
        .ok_or_else(|| "the proof contains no layer proof".to_owned())?;
    first_layer.eval_x += ChallengeField::from(field(1));
    if prover.verify_sync_op(&request, &result) {
        return Err("the tampered proof was accepted".to_owned());
    }
    println!("tampered-proof=REJECTED");
    println!("secondary-finalized=false");
    Ok(())
}

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

Build the request#

membership_request assembles a SyncRequest, the value that every proving and verification call consumes. It has three parts:

  • operation is the statement to prove. Here SmtOperation::Membership claims that key AssetId(5) holds a specific payload: two sync-state field elements (42 and 7) and a 32-byte identity digest.
  • witness is private data the verifier also needs: the leaf state and the sibling digests on its path.
  • public_inputs is what the proof is bound to: the roots, the operation tag, the key and the value digest.

The tree has depth 4, so keys run from 0 to 15. The four sibling digests are fixed fixture values, not data from a real tree. compute_root derives the root that a state store holding this leaf would publish, and hash_leaf(&leaf.encode()) produces the value digest that binds the asserted payload. Membership does not change the tree, so new_root equals old_root. The example writes op_kind_tag: 0 literally; in your own code, use PublicInputs::kind_tag(operation.kind()).

Configure the prover#

StateSyncProver::new stores the configuration: depth 4, layer strategy A (the only strategy the compiler implements), the default leaf-encoding bound and the default batch policy. The circuit for a membership proof depends only on the operation kind and this configuration. prove_sync_op is the fresh path: every call compiles that circuit and computes its full circuit commitment again, in addition to the request's own witness and proof. Services that handle many requests do this once with prepare, as Prepared execution shows.

Generate the witness and prove#

The prover lays out the circuit's input values from the request: the leaf pre-image, the running hash at each level of the path, the siblings, the key bits, the root and the value digest. It evaluates the layered circuit on those inputs and proves the evaluation with one sumcheck per layer. Before any challenge is drawn, the Fiat-Shamir transcript absorbs a fixed domain tag, the circuit's shape and commitment, and the public inputs, which ties the proof to this circuit and this statement. How GKR works explains the protocol.

Verify the proof#

verify_sync_op rebuilds the circuit, its commitment and its wiring, then accepts only if every check passes:

  • the result's public inputs equal the request's public inputs;
  • the operation tag and key in the public inputs match the operation;
  • for membership and non-membership, the old and new roots are equal;
  • the key fits in a tree of this depth;
  • the value digest is the hash of the leaf that the operation asserts;
  • for non-membership, the witness leaf is Empty or Tombstone, and for an update it equals the operation's old leaf;
  • the circuit inputs rebuilt from the witness are well formed, which includes hashing the leaf and every node on the path;
  • the GKR proof verifies against all-zero circuit outputs, and the two residual claims about the input layer hold for the rebuilt inputs.

It returns a bool. Because the verifier recomputes the leaf and path hashes itself, it needs the request's private witness. Proof lifecycle follows a request through each stage.

Tamper with the proof#

The example adds one to eval_x in the first layer proof, one of the two claimed evaluations that the layer's sumcheck reduces to. The verifier's consistency check for that layer fails, verify_sync_op returns false, and the example prints tampered-proof=REJECTED.

Try a change#

In membership_request, change op_kind_tag: 0 to op_kind_tag: 1 (the non-membership tag) and run the example again. Proving still succeeds, because the prover does not judge whether the statement is consistent. The verifier rejects the mismatch between the tag and the operation, so the example stops with:

Text
quickstart=FAIL: the honest proof was rejected

This is the most important rule of the API: a returned proof is not an acceptance verdict. Check the verifier's result every time.

Next steps#