Skip to content

Bring circom & snarkjs circuits

View Markdown

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 .zkey and .wtns files and prove in Java
  • how snarkjs orders public signals
  • how to export ZeroJ keys and proofs as snarkjs JSON

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
settings.gradle
rootProject.name = 'zeroj-snarkjs-interop'
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')
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']
}
src/main/java/com/example/interop/Main.java
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”
  1. Write the circom circuit and its input in the circuit/ directory. It’s the same statement as the Quickstart: “I know b such that a × 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" }
  2. Compile for BLS12-381 and compute the witness. circom targets BN254 by default; Cardano needs --prime bls12381.

    Terminal window
    cd circuit
    circom multiplier.circom --r1cs --wasm --sym --prime bls12381
    node multiplier_js/generate_witness.js multiplier_js/multiplier.wasm input.json witness.wtns

    witness.wtns contains every signal, including the secret b. Treat it like a private key: don’t commit it or ship it anywhere.

  3. Run a Groth16 setup with snarkjs. Phase 1 is the universal “powers of tau”, phase 2 is specific to this circuit. Each contribute mixes in randomness.

    Terminal window
    # Phase 1 (dev-only, tiny): 2^8 constraints is plenty here
    snarkjs powersoftau new bls12-381 8 pot_0000.ptau
    snarkjs 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 keys
    snarkjs groth16 setup multiplier.r1cs pot_final.ptau multiplier_0000.zkey
    snarkjs zkey contribute multiplier_0000.zkey multiplier.zkey --name="dev contribution" -e="more random text"
    snarkjs zkey export verificationkey multiplier.zkey verification_key.json

    For comparison later, also let snarkjs make its own proof:

    Terminal window
    snarkjs groth16 prove multiplier.zkey witness.wtns proof.json public.json
    snarkjs groth16 verify verification_key.json public.json proof.json
    cd ..

    The last command prints [INFO] snarkJS: OK!.

  4. Prove in Java. ZkeyImporterBLS381 reads the snarkjs proving key (with the circuit’s constraints, which a .zkey carries) 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));
    }
    }
  5. Run it from the project directory. Main also refers to ToSnarkjs, 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 -> true
    snarkjs proof + ZeroJ verifier -> true

    Now 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!

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.

  • Pin the key. A .zkey from a ceremony is a security-critical input. Use the ZkeyImporterBLS381.importZkeyFull(bytes, expectedSha256) overload so the import fails unless the file matches the hash published with the ceremony transcript.
  • Large circuits. importZkeyFull loads the whole key into the heap. For big keys, ZkeyPkStoreImporter.importToPkStore(zkeyFile, dir) converts the .zkey once into a memory-mapped key store that you open with Groth16Keys.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 .zkey is 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.

  1. 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 {
    @Prove
    ZkBool prove(ZkContext zk,
    @Public ZkField a,
    @Public ZkField product,
    @Secret ZkField b) {
    return a.mul(b).isEqual(product);
    }
    }
  2. Prove and export. SnarkjsGroth16Json produces 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 setup
    try (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);
    }
    }
  3. Run it, then verify with snarkjs.

    Terminal window
    gradle run --args='to-snarkjs zeroj-out'
    cd zeroj-out
    snarkjs groth16 verify verification_key.json public.json proof.json
    [INFO] snarkJS: OK!
  4. Tamper and check again. Claim the product is 34:

    Terminal window
    echo '["3","34"]' > tampered-public.json
    snarkjs 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.

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.

  • Change input.json to { "a": "3", "b": "12" }, regenerate the witness, and prove again in Java. The proof is valid, for the public signals [36, 3]: circom computed c for you. To reject a wrong product, c would have to be a public input that the circuit constrains, not an output.
  • Edit circuit/zeroj-public.json to ["33","4"] and run the snarkjs groth16 verify command from Direction 1 again. It prints Invalid proof.