Skip to content

Quickstart: your first proof

View Markdown

Here is the claim you’ll prove: “I know a number b such that 3 × b = 33.” You’ll convince a verifier that it’s true without ever telling it b. It’s a toy statement (anyone can divide 33 by 3), but it walks the whole path a real application uses: circuit, witness, trusted setup, proof, and verification. Real statements hide secrets that can’t be guessed, such as the preimage of a hash. Everything runs in plain Java, with no native libraries and no external tools.

What you’ll build: a small Java program that defines a circuit, proves it with Groth16 on the BLS12-381 curve, verifies the proof, and shows that a tampered claim is rejected.

What you’ll learn:

  • how a @ZKCircuit class becomes a circuit
  • what a witness is, and where the secret goes
  • why Groth16 needs a trusted setup, and why the one here is for development only
  • what a verifier sees, and what it never sees
  1. Create the project. Make an empty directory with this layout. You’ll write the two Java files in the next steps.

    • Directoryzeroj-quickstart/
      • settings.gradle
      • build.gradle
      • Directorysrc/main/java/com/example/quickstart/
        • SecretMultiplier.java
        • Main.java

    Add the build files. The BOM (zeroj-bom-core) pins every ZeroJ module to one version. It appears twice because Gradle resolves the annotation processor path separately.

    settings.gradle
    rootProject.name = 'zeroj-quickstart'
    build.gradle
    plugins {
    id 'application'
    }
    repositories {
    mavenCentral()
    }
    java {
    toolchain {
    languageVersion = JavaLanguageVersion.of(25)
    }
    }
    dependencies {
    implementation platform('org.zeroj:zeroj-bom-core:0.1.0-pre12')
    annotationProcessor platform('org.zeroj:zeroj-bom-core:0.1.0-pre12')
    // @ZKCircuit annotations + the processor that generates SecretMultiplierCircuit
    implementation 'org.zeroj:zeroj-circuit-annotation-api'
    annotationProcessor 'org.zeroj:zeroj-circuit-annotation-processor'
    implementation 'org.zeroj:zeroj-circuit-dsl' // R1CS compiler + witness calculator
    implementation 'org.zeroj:zeroj-crypto' // pure-Java Groth16 setup + prover
    implementation 'org.zeroj:zeroj-codec' // snarkjs JSON parsing
    implementation 'org.zeroj:zeroj-verifier-groth16' // pure-Java Groth16 verifier
    }
    application {
    mainClass = 'com.example.quickstart.Main'
    // Dev-only: allows the in-process, single-party trusted setup used in Main.
    applicationDefaultJvmArgs = ['-Dzeroj.allowInsecureTrustedSetup=true']
    }

    The -Dzeroj.allowInsecureTrustedSetup=true flag matters. Without it, ZeroJ refuses to run the quick in-process setup this tutorial uses, for reasons you’ll see in step 3 of Main.

  2. Write the circuit. A circuit is the statement you want to prove, written as rules the prover’s numbers must satisfy. This one says “a times b equals product”.

    src/main/java/com/example/quickstart/SecretMultiplier.java
    package com.example.quickstart;
    import org.zeroj.circuit.annotation.Prove;
    import org.zeroj.circuit.annotation.Public;
    import org.zeroj.circuit.annotation.Secret;
    import org.zeroj.circuit.annotation.ZKCircuit;
    import org.zeroj.circuit.annotation.ZkBool;
    import org.zeroj.circuit.annotation.ZkContext;
    import org.zeroj.circuit.annotation.ZkField;
    /** "I know a secret b such that a × b = product." */
    @ZKCircuit(name = "secret-multiplier", version = 1)
    public class SecretMultiplier {
    @Prove
    ZkBool prove(ZkContext zk,
    @Public ZkField a,
    @Public ZkField product,
    @Secret ZkField b) {
    return a.mul(b).isEqual(product);
    }
    }
    • @Public values are shared with the verifier. @Secret values stay with the prover.
    • ZkField is a number in the circuit’s finite field, not a Java int. Its methods (mul, isEqual, …) record constraints rather than computing a result right away.
    • @Prove returns a ZkBool, and ZeroJ requires it to be true for any valid proof.

    At compile time the annotation processor reads this class and generates SecretMultiplierCircuit, a companion with build(), a typed inputs() builder, and more. You’ll find it under build/generated/sources/annotationProcessor/ (Gradle) or target/generated-sources/annotations/ (Maven).

  3. Write the program. It compiles the circuit, builds the witness, runs a development setup, proves, verifies, and then tries to cheat.

    src/main/java/com/example/quickstart/Main.java
    package com.example.quickstart;
    import org.zeroj.api.CircuitId;
    import org.zeroj.api.CurveId;
    import org.zeroj.api.ProofSystemId;
    import org.zeroj.api.VerificationMaterial;
    import org.zeroj.api.VerificationResult;
    import org.zeroj.codec.SnarkjsJsonCodec;
    import org.zeroj.crypto.groth16.Groth16Keys;
    import org.zeroj.crypto.groth16.Groth16ProofBLS381;
    import org.zeroj.crypto.setup.PowersOfTauBLS381;
    import org.zeroj.crypto.snarkjs.SnarkjsGroth16Json;
    import org.zeroj.verifier.groth16.bls12381.Groth16BLS12381PureJavaVerifier;
    import java.math.BigInteger;
    import java.nio.charset.StandardCharsets;
    import java.util.Arrays;
    public class Main {
    public static void main(String[] args) {
    // 1. Build the circuit (generated from SecretMultiplier) and compile it to R1CS.
    var circuit = SecretMultiplierCircuit.build();
    var r1cs = circuit.compileR1CS(CurveId.BLS12_381);
    System.out.println("Constraints: " + r1cs.numConstraints()
    + ", public inputs: " + r1cs.numPublicInputs());
    // 2. Fill in the inputs. Only the prover ever knows b.
    var inputs = SecretMultiplierCircuit.inputs()
    .a(3)
    .product(33)
    .b(11); // the secret
    BigInteger[] witness = inputs.calculateWitness(circuit, CurveId.BLS12_381);
    // 3. DEV-ONLY trusted setup: this process knows the toxic waste (tau).
    BigInteger tau = PowersOfTauBLS381.generate(4).tauScalar();
    try (var keys = Groth16Keys.setupInMemory(
    r1cs.constraints(), r1cs.numWires(), r1cs.numPublicInputs(), tau)) {
    // 4. Prove.
    Groth16ProofBLS381 proof = keys.prove(witness, r1cs.constraints());
    // 5. Export the three artifacts a verifier needs (snarkjs-compatible JSON).
    BigInteger[] publicInputs = Arrays.copyOfRange(witness, 1, 1 + r1cs.numPublicInputs());
    String vkJson = SnarkjsGroth16Json.verificationKeyJson(keys);
    String proofJson = SnarkjsGroth16Json.proofJson(proof);
    String publicJson = SnarkjsGroth16Json.publicJson(publicInputs);
    System.out.println("Public inputs (a, product): " + inputs.publicValues());
    // 6. Verify. The verifier sees the VK, the proof and the public inputs — never b.
    VerificationResult ok = verify(vkJson, proofJson, publicJson);
    System.out.println("Proof valid? " + ok.proofValid());
    // 7. Tamper with a public input: claim the product is 34 instead of 33.
    String tampered = SnarkjsGroth16Json.publicJson(
    new BigInteger[]{BigInteger.valueOf(3), BigInteger.valueOf(34)});
    VerificationResult bad = verify(vkJson, proofJson, tampered);
    System.out.println("Tampered proof valid? " + bad.proofValid()
    + " (" + bad.message().orElse("") + ")");
    }
    }
    static VerificationResult verify(String vkJson, String proofJson, String publicJson) {
    CircuitId id = SecretMultiplierCircuit.circuitId();
    var envelope = SnarkjsJsonCodec.toEnvelopeFromJson(proofJson, vkJson, publicJson, id);
    var material = VerificationMaterial.of(vkJson.getBytes(StandardCharsets.UTF_8),
    ProofSystemId.GROTH16, CurveId.BLS12_381, id);
    return new Groth16BLS12381PureJavaVerifier().verify(envelope, material);
    }
    }
  4. Run it.

    Terminal window
    gradle run # or ./gradlew run if your project has a wrapper

    You should see this (build-tool chatter trimmed):

    Constraints: 6, public inputs: 2
    WARNING: Single-party Powers of Tau generation (BLS12-381) — for DEVELOPMENT and TESTING only. Use MPC ceremony outputs (Hermez, Zcash PoT) for production.
    WARNING: Single-party Groth16 Phase 2 setup (BLS12-381) — for DEVELOPMENT and TESTING only. Use snarkjs multi-party ceremony for production.
    Public inputs (a, product): [3, 33]
    Proof valid? true
    Tampered proof valid? false (Groth16 BLS12-381 pairing check failed)

    The two WARNING lines are ZeroJ reminding you that this setup is for development only.

  1. Compile. build() turns your class into a circuit, and compileR1CS flattens it into R1CS: a list of small equations of the form “(something) × (something) = (something)”. The one line a.mul(b).isEqual(product) becomes 6 of them, because isEqual needs a few helper values of its own.
  2. Witness. The typed inputs() builder names every input, so you can’t mix up the order. calculateWitness then fills in every wire of the circuit, including b and the helpers, and checks each constraint on the way. The result is the witness: a BigInteger[] whose element 0 is always 1, followed by the public inputs in declaration order.
  3. Setup. Groth16 needs keys made from secret randomness called toxic waste (tau here). Whoever knows it can forge proofs. That’s fine on your laptop and unacceptable anywhere else.
  4. Prove. keys.prove produces a proof: three elliptic-curve points (192 bytes when compressed), whatever the size of the circuit. Proving is randomized, so calling it twice gives two different proofs that both verify.
  5. Export. The verification key, proof, and public inputs are written in the JSON layout snarkjs uses, so other tools can read them too.
  6. Verify. The verifier checks a pairing equation over the proof, the key, and the public inputs [3, 33]. It never receives b.
  7. Tamper. The same proof with product = 34 fails. A proof is bound to its exact public inputs.
You didThe idea behind itRead more
Wrote a × b = product as constraintsA circuit is a statement expressed as arithmetic rulesCircuits, constraints & witnesses
Computed a witness containing bThe witness is the prover’s secret solution to those rulesCircuits, constraints & witnesses
Ran PowersOfTauBLS381 and setupInMemoryGroth16 keys come from a one-time setup whose randomness must be destroyedTrusted setup, explained
Called keys.proveGroth16 makes a short proof that the prover knows a valid witnessGroth16, PlonK & BBS
Verified without bThe verifier learns the statement is true, and nothing elseZero-knowledge in plain English

One detail is worth noticing. VerificationResult has both proofValid() and accepted(). The verifier only checked the math, so proofValid() is true while accepted() stays false: whether a valid proof should do anything, such as grant access or release funds, is a decision for your application.

  • Change the secret to .b(12). calculateWitness throws ArithmeticException: Constraint violation: ..., because 3 × 12 isn’t 33. A cheater could skip the witness calculator, but a proof built from a witness that breaks a constraint doesn’t verify. Prove you’re over 18 shows this in action.
  • Remove .b(11) entirely. You get IllegalArgumentException: Missing secret input: b.
  • Inside the try block, call keys.prove(...) a second time and compare the two proofJson strings. They differ, yet both verify against the same key.