Quickstart: your first proof
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
@ZKCircuitclass 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
Build it
Section titled “Build it”-
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 SecretMultiplierCircuitimplementation 'org.zeroj:zeroj-circuit-annotation-api'annotationProcessor 'org.zeroj:zeroj-circuit-annotation-processor'implementation 'org.zeroj:zeroj-circuit-dsl' // R1CS compiler + witness calculatorimplementation 'org.zeroj:zeroj-crypto' // pure-Java Groth16 setup + proverimplementation 'org.zeroj:zeroj-codec' // snarkjs JSON parsingimplementation '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']}With Maven, use this
pom.xmlinstead of the two Gradle files.pom.xml <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><groupId>com.example</groupId><artifactId>zeroj-quickstart</artifactId><version>1.0-SNAPSHOT</version><properties><zeroj.version>0.1.0-pre12</zeroj.version><maven.compiler.release>25</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties><dependencyManagement><dependencies><dependency><groupId>org.zeroj</groupId><artifactId>zeroj-bom-core</artifactId><version>${zeroj.version}</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement><dependencies><dependency><groupId>org.zeroj</groupId><artifactId>zeroj-circuit-annotation-api</artifactId></dependency><dependency><groupId>org.zeroj</groupId><artifactId>zeroj-circuit-dsl</artifactId></dependency><dependency><groupId>org.zeroj</groupId><artifactId>zeroj-crypto</artifactId></dependency><dependency><groupId>org.zeroj</groupId><artifactId>zeroj-codec</artifactId></dependency><dependency><groupId>org.zeroj</groupId><artifactId>zeroj-verifier-groth16</artifactId></dependency></dependencies><build><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.14.0</version><configuration><annotationProcessorPaths><path><groupId>org.zeroj</groupId><artifactId>zeroj-circuit-annotation-processor</artifactId><version>${zeroj.version}</version></path></annotationProcessorPaths></configuration></plugin><plugin><groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.5.0</version><configuration><mainClass>com.example.quickstart.Main</mainClass></configuration></plugin></plugins></build></project>The
-Dzeroj.allowInsecureTrustedSetup=trueflag matters. Without it, ZeroJ refuses to run the quick in-process setup this tutorial uses, for reasons you’ll see in step 3 ofMain. -
Write the circuit. A circuit is the statement you want to prove, written as rules the prover’s numbers must satisfy. This one says “
atimesbequalsproduct”.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 {@ProveZkBool prove(ZkContext zk,@Public ZkField a,@Public ZkField product,@Secret ZkField b) {return a.mul(b).isEqual(product);}}@Publicvalues are shared with the verifier.@Secretvalues stay with the prover.ZkFieldis a number in the circuit’s finite field, not a Javaint. Its methods (mul,isEqual, …) record constraints rather than computing a result right away.@Provereturns aZkBool, 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 withbuild(), a typedinputs()builder, and more. You’ll find it underbuild/generated/sources/annotationProcessor/(Gradle) ortarget/generated-sources/annotations/(Maven). -
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 secretBigInteger[] 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);}} -
Run it.
Terminal window gradle run # or ./gradlew run if your project has a wrapperTerminal window mvn -q compile exec:java -Dzeroj.allowInsecureTrustedSetup=trueYou should see this (build-tool chatter trimmed):
Constraints: 6, public inputs: 2WARNING: 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? trueTampered proof valid? false (Groth16 BLS12-381 pairing check failed)The two
WARNINGlines are ZeroJ reminding you that this setup is for development only.
What the program does
Section titled “What the program does”- Compile.
build()turns your class into a circuit, andcompileR1CSflattens it into R1CS: a list of small equations of the form “(something) × (something) = (something)”. The one linea.mul(b).isEqual(product)becomes 6 of them, becauseisEqualneeds a few helper values of its own. - Witness. The typed
inputs()builder names every input, so you can’t mix up the order.calculateWitnessthen fills in every wire of the circuit, includingband the helpers, and checks each constraint on the way. The result is the witness: aBigInteger[]whose element0is always1, followed by the public inputs in declaration order. - Setup. Groth16 needs keys made from secret randomness called toxic waste (
tauhere). Whoever knows it can forge proofs. That’s fine on your laptop and unacceptable anywhere else. - Prove.
keys.proveproduces 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. - Export. The verification key, proof, and public inputs are written in the JSON layout snarkjs uses, so other tools can read them too.
- Verify. The verifier checks a pairing equation over the proof, the key, and the public
inputs
[3, 33]. It never receivesb. - Tamper. The same proof with
product = 34fails. A proof is bound to its exact public inputs.
What just happened?
Section titled “What just happened?”| You did | The idea behind it | Read more |
|---|---|---|
Wrote a × b = product as constraints | A circuit is a statement expressed as arithmetic rules | Circuits, constraints & witnesses |
Computed a witness containing b | The witness is the prover’s secret solution to those rules | Circuits, constraints & witnesses |
Ran PowersOfTauBLS381 and setupInMemory | Groth16 keys come from a one-time setup whose randomness must be destroyed | Trusted setup, explained |
Called keys.prove | Groth16 makes a short proof that the prover knows a valid witness | Groth16, PlonK & BBS |
Verified without b | The verifier learns the statement is true, and nothing else | Zero-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.
Try this
Section titled “Try this”- Change the secret to
.b(12).calculateWitnessthrowsArithmeticException: Constraint violation: ..., because3 × 12isn’t33. 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 getIllegalArgumentException: Missing secret input: b. - Inside the
tryblock, callkeys.prove(...)a second time and compare the twoproofJsonstrings. They differ, yet both verify against the same key.
Next steps
Section titled “Next steps”- Prove you’re over 18: range proofs, invalid witnesses, and keeping the verifier separate from the prover.
- Verify your proof on Cardano: run this same proof through a Plutus V3 validator.
- Annotations guide: everything
@ZKCircuitcan do. - Groth16 guide: key stores, large circuits, and production keys.