Skip to content

Prove with Groth16

View Markdown

Groth16 on BLS12-381 is ZeroJ’s primary proof system and the focus of the current release. It’s the path used in every tutorial, the one verified end to end on-chain against Yaci DevKit, and the one Cardano verifies with Plutus V3’s built-in BLS12-381 operations. Proofs are small (three curve points) and cheap to verify. The trade-off is a trusted setup per circuit.

Like the rest of ZeroJ, this path is research software. Its status is Beta: feature-complete and correctness-tested, but not externally audited and not for value-bearing or mainnet use. See Status & maturity.

This guide covers the zeroj-crypto API: where keys live, how to prove, how to scale up, what the fail-closed checks mean, and how to hand proofs to verifiers.

circuit ──compileR1CS(BLS12_381)──▶ R1CS ──setup / import──▶ Groth16Keys
│ │
└──calculateWitness──▶ witness ─────────────▶ keys.prove(...) ──▶ proof ──▶ verifier

Groth16Keys is the front door: one handle for the key material wherever it lives, and one prove that works the same against all of them. You make one decision, at setup time:

Key homeGet it withWhenMemory
HeapGroth16Keys.setupInMemory(...)Tests and small circuitsWhole key on the heap
Key store, sparseGroth16Keys.setupToStore(..., true)Large local circuits (recommended store format)Streamed setup; the key is memory-mapped, so it uses page cache, not heap
Key store, denseGroth16Keys.setupToStore(..., false)Interchange with older toolsSame profile, larger files
Imported ceremony keyZkeyPkStoreImporter.importToPkStore(...), then Groth16Keys.load(dir)Anything beyond local testingMemory-mapped at prove time

Groth16Keys is AutoCloseable. Use try-with-resources so store-backed keys are unmapped.

The examples use the SealedBid circuit and its filled-in inputs from Write circuits with annotations. Any CircuitBuilder works the same way.

var circuit = SealedBidCircuit.build();
var r1cs = circuit.compileR1CS(CurveId.BLS12_381);
BigInteger[] witness = inputs.calculateWitness(circuit, CurveId.BLS12_381); // witness[0] == 1
// DEV/TEST ONLY: single-party setup. Groth16 setup uses only the tau scalar.
BigInteger tau = PowersOfTauBLS381.generate(4).tauScalar();
try (var keys = Groth16Keys.setupInMemory(
r1cs.constraints(), r1cs.numWires(), r1cs.numPublicInputs(), tau)) {
Groth16ProofBLS381 proof = keys.prove(witness, r1cs.constraints());
}

Nothing touches disk. This is fine up to a few hundred thousand constraints.

setupToStore streams every proving-key point straight into memory-mapped files, so the key is never fully on the heap. Pass the packed constraints (r1cs.flat()) and a directory:

Path keysDir = Path.of("keys/sealed-bid-v1");
// DEV/TEST ONLY
try (var keys = Groth16Keys.setupToStore(
r1cs.flat(), r1cs.numWires(), r1cs.numPublicInputs(), tau, keysDir, true)) {
Groth16ProofBLS381 proof = keys.prove(witness, r1cs.constraints());
}
// Every later run reopens the bundle. Sparse or dense is detected from the manifest.
try (var keys = Groth16Keys.load(keysDir)) {
Groth16ProofBLS381 proof = keys.prove(witness, r1cs.constraints());
}

sparse = true stores points at infinity as a single bit each, which makes the on-disk key much smaller for large circuits.

For real deployments, the proving key comes from a snarkjs multi-party ceremony .zkey. Import it once into the same store layout:

ZkeyPkStoreImporter.importToPkStore(Path.of("circuit_final.zkey"), keysDir); // streaming, multi-GB safe

snarkjs appends one public-input binding row per public signal (plus one for the constant wire) after your circuit’s rows, so you must tell the prover about them:

int numPublic = r1cs.numPublicInputs();
try (var keys = Groth16Keys.load(keysDir)) {
// List form: append snarkjs's binding rows to your compiled constraints
var proof = keys.prove(witness,
ZkeyPkStoreImporter.snarkjsConstraints(r1cs.constraints(), numPublic));
// Packed form: pass the number of binding rows instead (0 for locally generated keys)
var proof2 = keys.prove(ProverBackend.PURE_JAVA,
FlatScalars.pack(witness, witness.length), r1cs.flat(), numPublic + 1);
}

The ceremony must have been run on exactly the R1CS you compile, exported with zeroj-ceremony export-r1cs or R1CSSerializer.serialize(r1cs). For small keys (up to 128 MB) there is also an in-memory importer that can pin the file’s SHA-256 before parsing: ZkeyImporterBLS381.importZkeyFull(bytes, expectedSha256). It returns the proving key and the constraints for Groth16ProverBLS381.prove(...). Circom circuits follow the same import path; see Bring circom & snarkjs circuits.

The list form boxes every coefficient and witness value as a BigInteger. At millions of constraints, use the packed overload with R1CSFlat constraints and FlatScalars witness values:

FlatScalars w = FlatScalars.pack(witness, witness.length);
Groth16ProofBLS381 proof = keys.prove(ProverBackend.PURE_JAVA, w, r1cs.flat(), /* bindingRows */ 0);

FlatScalars.packConsuming(witness, n) does the same but nulls out the BigInteger[] as it goes, so the boxed values can be garbage-collected early. ProverBackend.PURE_JAVA is the default, multi-core pure-Java backend; the opt-in native backend is covered in Performance & large circuits.

Groth16Pipeline packages the orchestration ZeroJ uses for its ~19-million-constraint account-ownership circuit: a constraint cache written during setup, witness generation before the constraints are memory-mapped (so the two memory peaks never overlap), and a circuit fingerprint that fails fast if a key bundle doesn’t match the circuit. You supply two things: how to compile, and how to compute the witness.

Supplier<Groth16Pipeline.Compiled> compile = () -> {
var cs = SealedBidCircuit.build().compileR1CS(CurveId.BLS12_381);
return new Groth16Pipeline.Compiled(
cs.flat(), cs.numConstraints(), cs.numWires(), cs.numPublicInputs());
};
Supplier<FlatScalars> computeWitness = () -> {
var c = SealedBidCircuit.build(); // released when the lambda returns
BigInteger[] w = inputs.calculateWitness(c, CurveId.BLS12_381);
return FlatScalars.packConsuming(w, w.length);
};
// Setup (DEV/TEST ONLY): sparse store + r1cs.bin cache, bound to the circuit fingerprint
var compiled = compile.get();
String fingerprint = compiled.fingerprint(); // record this with the bundle
var setup = Groth16Pipeline.setup(compiled, tau, keysDir, true);
String vkJson = SnarkjsGroth16Json.verificationKeyJson(setup); // export the VK from the result
compiled = null; // let the compiled circuit go
// Prove: compiles only if the cache is missing or stale
try (var keys = Groth16Keys.load(keysDir)) {
Groth16ProofBLS381 proof = Groth16Pipeline.prove(keys,
keysDir.resolve(Groth16Pipeline.R1CS_CACHE),
fingerprint,
compile, computeWitness,
/* bindingRows */ 0, ProverBackend.PURE_JAVA);
}

The fingerprint has the form c<constraints>-w<wires>-p<public>-r<sha256>: the dimensions plus a hash of the exact relation. A mismatch throws IllegalStateException before any proving work. For an imported ceremony key, bind the fingerprint to the store once with Groth16PkStore.bindCircuitFingerprint(keysDir, fingerprint), and pass numPublic + 1 binding rows. Groth16Pipeline.estimateProvePhaseHeapBytes(numWires, domain) gives a lower bound on prove-phase heap for preflight checks; witness generation can need more, so measure your own circuit. An optional Groth16Pipeline.Progress listener reports stages for CLIs.

Every setup and prove entry point checks the relation’s shape before doing any work, and fails closed with an exception instead of proceeding:

ErrorMeaning
numWires / numPublic out of rangeNeed numWires >= 1 and 0 <= numPublic < numWires
A wire index outside [0, numWires)A constraint references a wire that doesn’t exist
Malformed CSR offsets or coefficientsThe packed R1CSFlat is corrupt
witness length (…) must match numWires (…)The witness wasn’t computed for this circuit
witness[0] must be 1The constant wire is missing or wrong
A public wire with no nonzero coefficientSee the binding rule below

Every public wire must be bound. Native setup requires each wire 0..numPublic, including the constant wire 0, to appear with a nonzero coefficient in at least one constraint row. A public input that appears in no row would produce a verification-key entry at the point at infinity, which every ZeroJ verifier (pure Java, blst, on-chain) rejects, and it would leave that public input unbound by the proof. Circuits built with the DSL bind the constant wire through their assertions. If you have a public input your circuit doesn’t use, either drop it or make it take part in a real multiplication with another wire, for example recipient.mul(secret). A constant multiplication such as p * 1 is folded into a linear combination by the compiler and binds nothing. snarkjs binds unused public signals itself, so a circuit can set up under snarkjs and still be refused by native setup; imported ceremony keys are unaffected.

In the negligible case that the sampled randomness cancels a bound wire, setup aborts with an IllegalStateException before writing anything; run it again. These exceptions mean the relation, the dimensions, or the witness doesn’t describe the circuit the key was made for. Fix the caller; don’t catch and retry.

Groth16 proofs include two random blinding scalars. Every ZeroJ prove call draws them from SecureRandom, and nothing lets you fix, seed, or omit them. That’s what makes the proof zero-knowledge: an unblinded proof is a deterministic function of the key and the witness, and a low-entropy secret could be recovered by trying candidates. Consequences:

  • Two proofs of the same statement are different bytes. Don’t compare proofs for equality.
  • There is no deterministic or unblinded prove in any published ZeroJ artifact. To debug, keep the proof you got.

Proofs and keys travel as snarkjs-compatible JSON. The exporters in org.zeroj.crypto.snarkjs write exactly what snarkjs 0.7.6 writes, and refuse non-canonical input (points at infinity, off-curve points, out-of-range scalars):

BigInteger[] publicInputs = Arrays.copyOfRange(witness, 1, 1 + r1cs.numPublicInputs());
String vkJson = SnarkjsGroth16Json.verificationKeyJson(keys); // also accepts a SetupResult
String proofJson = SnarkjsGroth16Json.proofJson(proof);
String publicJson = SnarkjsGroth16Json.publicJson(publicInputs);

From there:

  • Off-chain: wrap the JSON in an envelope with SnarkjsJsonCodec.toEnvelopeFromJson(...) (or the generated proofEnvelopeBuilder(...) for annotated circuits) and verify with Groth16BLS12381PureJavaVerifier. See Verify off-chain.
  • With snarkjs: snarkjs groth16 verify verification_key.json public.json proof.json.
  • On-chain: ProverToCardano.compressVk(keys) and ProverToCardano.compressProof(proof) (in zeroj-onchain-julc) produce the compressed points a Plutus V3 validator consumes. See Verify on-chain.

Groth16Keys and Groth16Pipeline delegate to public lower-level seams. You rarely need them, but they exist for memory-tuned pipelines:

Entry pointPurpose
Groth16SetupBLS381.setup(...) / setupToStore(...)In-heap and streaming setup (dev only)
Groth16PkStore.load/saveThe key-store format itself
Groth16ProverBLS381.computeHFlat(...) + proveWithHCoeffs(...)Compute H, drop the constraints, then run the MSMs
Groth16ProverBLS381.proveWithReaders(...)Prove from key readers without the handle

Design notes: ADR-0036 (API facade and pipeline), ADR-0045 (public-wire binding), ADR-0046 (no unblinded prove).