Bring circom & snarkjs circuits
Maybe you already have circuits written in circom, or keys from a snarkjs trusted-setup ceremony. You don’t have to rewrite anything to use ZeroJ. Groth16 on BLS12-381 speaks the same formats in both directions:
circom + snarkjs ZeroJ (pure Java) ---------------- ----------------- .zkey (proving key) ──── import ────► prove without Node.js .wtns (witness) ──── import ────► verification_key.json ◄─── same JSON ───► verify snarkjs or ZeroJ proofs proof.json, public.json ◄─── same JSON ───► export for `snarkjs groth16 verify`What you’ll build: a tiny circom circuit set up with snarkjs, proved by ZeroJ’s pure-Java prover; and a ZeroJ circuit whose proof snarkjs verifies.
What you’ll learn:
- how to compile circom for BLS12-381 and run a snarkjs Groth16 setup
- how to import
.zkeyand.wtnsfiles and prove in Java - how snarkjs orders public signals
- how to export ZeroJ keys and proofs as snarkjs JSON
Set up the Java project
Section titled “Set up the Java project”Both directions share one small project. Main picks the direction from its first argument,
and the other Java files follow in the two sections below; create all of them before the first
run.
Directoryzeroj-snarkjs-interop/
- settings.gradle
- build.gradle
Directorycircuit/
- multiplier.circom
- input.json
Directorysrc/main/java/com/example/interop/
- Main.java
- FromSnarkjs.java
- ToSnarkjs.java
- SecretMultiplier.java
rootProject.name = 'zeroj-snarkjs-interop'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')
implementation 'org.zeroj:zeroj-circuit-annotation-api' annotationProcessor 'org.zeroj:zeroj-circuit-annotation-processor' implementation 'org.zeroj:zeroj-circuit-dsl' implementation 'org.zeroj:zeroj-crypto' implementation 'org.zeroj:zeroj-codec' implementation 'org.zeroj:zeroj-verifier-groth16'}
application { mainClass = 'com.example.interop.Main' // Dev-only: needed by the ZeroJ -> snarkjs direction, which runs an in-process setup. applicationDefaultJvmArgs = ['-Dzeroj.allowInsecureTrustedSetup=true']}package com.example.interop;
import org.zeroj.api.CircuitId;import org.zeroj.api.CurveId;import org.zeroj.api.ProofSystemId;import org.zeroj.api.VerificationMaterial;import org.zeroj.codec.SnarkjsJsonCodec;import org.zeroj.verifier.groth16.bls12381.Groth16BLS12381PureJavaVerifier;
import java.nio.charset.StandardCharsets;import java.nio.file.Path;
public class Main {
public static void main(String[] args) throws Exception { Path dir = Path.of(args[1]); switch (args[0]) { case "from-snarkjs" -> FromSnarkjs.run(dir); case "to-snarkjs" -> ToSnarkjs.run(dir); default -> throw new IllegalArgumentException("usage: from-snarkjs|to-snarkjs <dir>"); } }
/** Pure-Java Groth16 BLS12-381 verification of snarkjs-format JSON. */ static boolean verify(String vkJson, String proofJson, String publicJson) { var id = new CircuitId("multiplier"); 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).proofValid(); }}Direction 1: circom and snarkjs in, ZeroJ proves
Section titled “Direction 1: circom and snarkjs in, ZeroJ proves”-
Write the circom circuit and its input in the
circuit/directory. It’s the same statement as the Quickstart: “I knowbsuch thata × b = c.”circuit/multiplier.circom pragma circom 2.0.0;// "I know a secret b such that a * b = c." a and c are public.template Multiplier() {signal input a;signal input b;signal output c;c <== a * b;}component main {public [a]} = Multiplier();circuit/input.json { "a": "3", "b": "11" } -
Compile for BLS12-381 and compute the witness. circom targets BN254 by default; Cardano needs
--prime bls12381.Terminal window cd circuitcircom multiplier.circom --r1cs --wasm --sym --prime bls12381node multiplier_js/generate_witness.js multiplier_js/multiplier.wasm input.json witness.wtnswitness.wtnscontains every signal, including the secretb. Treat it like a private key: don’t commit it or ship it anywhere. -
Run a Groth16 setup with snarkjs. Phase 1 is the universal “powers of tau”, phase 2 is specific to this circuit. Each
contributemixes in randomness.Terminal window # Phase 1 (dev-only, tiny): 2^8 constraints is plenty heresnarkjs powersoftau new bls12-381 8 pot_0000.ptausnarkjs powersoftau contribute pot_0000.ptau pot_0001.ptau --name="dev contribution" -e="some random text"snarkjs powersoftau prepare phase2 pot_0001.ptau pot_final.ptau# Phase 2: circuit-specific keyssnarkjs groth16 setup multiplier.r1cs pot_final.ptau multiplier_0000.zkeysnarkjs zkey contribute multiplier_0000.zkey multiplier.zkey --name="dev contribution" -e="more random text"snarkjs zkey export verificationkey multiplier.zkey verification_key.jsonFor comparison later, also let snarkjs make its own proof:
Terminal window snarkjs groth16 prove multiplier.zkey witness.wtns proof.json public.jsonsnarkjs groth16 verify verification_key.json public.json proof.jsoncd ..The last command prints
[INFO] snarkJS: OK!. -
Prove in Java.
ZkeyImporterBLS381reads the snarkjs proving key (with the circuit’s constraints, which a.zkeycarries) and the witness. After that no Node.js is involved.src/main/java/com/example/interop/FromSnarkjs.java package com.example.interop;import org.zeroj.codec.SnarkjsJsonCodec;import org.zeroj.crypto.groth16.Groth16ProverBLS381;import org.zeroj.crypto.groth16.ZkeyImporterBLS381;import org.zeroj.crypto.snarkjs.SnarkjsGroth16Json;import java.io.IOException;import java.math.BigInteger;import java.nio.file.Files;import java.nio.file.Path;import java.util.Arrays;/** circom + snarkjs artifacts in, pure-Java proof out. */public class FromSnarkjs {static void run(Path dir) throws IOException {// 1. Load what circom and snarkjs produced.var zkey = ZkeyImporterBLS381.importZkeyFull(Files.readAllBytes(dir.resolve("multiplier.zkey")));BigInteger[] witness;try (var in = Files.newInputStream(dir.resolve("witness.wtns"))) {witness = ZkeyImporterBLS381.importWtns(in);}String vkJson = Files.readString(dir.resolve("verification_key.json"));int nPublic = SnarkjsJsonCodec.parseVerificationKey(vkJson).nPublic();// 2. Prove with the pure-Java prover. No Node.js involved from here on.var proof = Groth16ProverBLS381.prove(zkey.provingKey(), witness, zkey.constraints(), zkey.numWires());BigInteger[] publicInputs = Arrays.copyOfRange(witness, 1, 1 + nPublic);String proofJson = SnarkjsGroth16Json.proofJson(proof);String publicJson = SnarkjsGroth16Json.publicJson(publicInputs);Files.writeString(dir.resolve("zeroj-proof.json"), proofJson);Files.writeString(dir.resolve("zeroj-public.json"), publicJson);System.out.println("Public signals (snarkjs order): " + Arrays.toString(publicInputs));// 3. Verify ZeroJ's proof against snarkjs' own verification key.System.out.println("ZeroJ proof + snarkjs VK -> " + Main.verify(vkJson, proofJson, publicJson));// 4. Verify the proof snarkjs generated, with ZeroJ's verifier.String snarkjsProof = Files.readString(dir.resolve("proof.json"));String snarkjsPublic = Files.readString(dir.resolve("public.json"));System.out.println("snarkjs proof + ZeroJ verifier -> " + Main.verify(vkJson, snarkjsProof, snarkjsPublic));}} -
Run it from the project directory.
Mainalso refers toToSnarkjs, so add the two files from Direction 2 first:Terminal window gradle run --args='from-snarkjs circuit'Public signals (snarkjs order): [33, 3]ZeroJ proof + snarkjs VK -> truesnarkjs proof + ZeroJ verifier -> trueNow close the loop and let snarkjs judge ZeroJ’s proof:
Terminal window snarkjs groth16 verify circuit/verification_key.json circuit/zeroj-public.json circuit/zeroj-proof.json[INFO] snarkJS: OK!
Public signal order
Section titled “Public signal order”Look at the public signals: [33, 3], not [3, 33]. snarkjs lists a circuit’s outputs
first, then its public inputs, in declaration order. Here c is an output and a a public
input. In the witness they sit right after the constant 1, which is why FromSnarkjs takes
elements 1 to nPublic. Anything that consumes these values, such as a Cardano datum, must
use exactly this order.
Importing keys you care about
Section titled “Importing keys you care about”- Pin the key. A
.zkeyfrom a ceremony is a security-critical input. Use theZkeyImporterBLS381.importZkeyFull(bytes, expectedSha256)overload so the import fails unless the file matches the hash published with the ceremony transcript. - Large circuits.
importZkeyFullloads the whole key into the heap. For big keys,ZkeyPkStoreImporter.importToPkStore(zkeyFile, dir)converts the.zkeyonce into a memory-mapped key store that you open withGroth16Keys.load(dir). The Groth16 guide shows that flow, including the extra argument that snarkjs-made keys need at prove time. - The witness must match the key. A
.zkeyis made for one exact constraint system, so the witness must come from that same circuit, here circom’s generated witness calculator. Rewriting the circuit in ZeroJ’s DSL gives a different constraint system, which needs its own setup.
Direction 2: ZeroJ proves, snarkjs verifies
Section titled “Direction 2: ZeroJ proves, snarkjs verifies”Any ZeroJ Groth16 proof can be exported in snarkjs’ JSON format. SnarkjsGroth16Json writes
the same bytes snarkjs 0.7.6 writes, so any tool that reads snarkjs files can check ZeroJ
proofs.
-
Add the Quickstart circuit to the project.
src/main/java/com/example/interop/SecretMultiplier.java package com.example.interop;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);}} -
Prove and export.
SnarkjsGroth16Jsonproduces the three files snarkjs expects.src/main/java/com/example/interop/ToSnarkjs.java package com.example.interop;import org.zeroj.api.CurveId;import org.zeroj.crypto.groth16.Groth16Keys;import org.zeroj.crypto.setup.PowersOfTauBLS381;import org.zeroj.crypto.snarkjs.SnarkjsGroth16Json;import java.io.IOException;import java.math.BigInteger;import java.nio.file.Files;import java.nio.file.Path;/** A ZeroJ circuit, proved in Java, exported in exactly the files snarkjs expects. */public class ToSnarkjs {static void run(Path dir) throws IOException {var circuit = SecretMultiplierCircuit.build();var r1cs = circuit.compileR1CS(CurveId.BLS12_381);var inputs = SecretMultiplierCircuit.inputs().a(3).product(33).b(11);BigInteger[] witness = inputs.calculateWitness(circuit, CurveId.BLS12_381);BigInteger tau = PowersOfTauBLS381.generate(4).tauScalar(); // DEV-ONLY setuptry (var keys = Groth16Keys.setupInMemory(r1cs.constraints(), r1cs.numWires(), r1cs.numPublicInputs(), tau)) {var proof = keys.prove(witness, r1cs.constraints());Files.createDirectories(dir);Files.writeString(dir.resolve("verification_key.json"), SnarkjsGroth16Json.verificationKeyJson(keys));Files.writeString(dir.resolve("proof.json"), SnarkjsGroth16Json.proofJson(proof));Files.writeString(dir.resolve("public.json"),SnarkjsGroth16Json.publicJson(inputs.publicValues().toArray(BigInteger[]::new)));}System.out.println("Wrote verification_key.json, proof.json, public.json to " + dir);}} -
Run it, then verify with snarkjs.
Terminal window gradle run --args='to-snarkjs zeroj-out'cd zeroj-outsnarkjs groth16 verify verification_key.json public.json proof.json[INFO] snarkJS: OK! -
Tamper and check again. Claim the product is 34:
Terminal window echo '["3","34"]' > tampered-public.jsonsnarkjs groth16 verify verification_key.json tampered-public.json proof.json[ERROR] snarkJS: Invalid proof
This direction uses ZeroJ’s in-process setup, which is development-only (hence the
-Dzeroj.allowInsecureTrustedSetup=true flag in build.gradle). Exporting its key to
snarkjs doesn’t make it any more trustworthy. For keys you’ll rely on, run a multi-party
ceremony and import the resulting .zkey, as in Direction 1.
Where the formats meet Cardano
Section titled “Where the formats meet Cardano”snarkjs artifacts can go on-chain too. SnarkjsToCardano in zeroj-onchain-julc converts
verification_key.json and proof.json into the compressed parameters and redeemer that the
Plutus V3 Groth16 verifier takes, the same shapes that
Verify your proof on Cardano builds from a ZeroJ proof. The
public inputs go into the datum in snarkjs’ order, outputs first. The warnings on that page
about binding proofs to their context apply here unchanged.
Try this
Section titled “Try this”- Change
input.jsonto{ "a": "3", "b": "12" }, regenerate the witness, and prove again in Java. The proof is valid, for the public signals[36, 3]: circom computedcfor you. To reject a wrong product,cwould have to be a public input that the circuit constrains, not an output. - Edit
circuit/zeroj-public.jsonto["33","4"]and run thesnarkjs groth16 verifycommand from Direction 1 again. It printsInvalid proof.
Next steps
Section titled “Next steps”- Groth16 guide: key stores and proving with imported ceremony keys.
- Running a trusted setup ceremony: replace the dev setup with a real one.
- Off-chain verification: verifier backends and envelopes.
- Verify your proof on Cardano: take a Groth16 proof on-chain.