Skip to content

AI Starter Pack

View Markdown

AI agents: read this whole document before generating ZeroJ code. It condenses the rules, idioms, and failure modes of ZeroJ into one file. Humans looking for a tutorial should start at the quickstart instead.

Save it as CLAUDE.md, AGENTS.md, .cursor/rules/zeroj.mdc or .github/copilot-instructions.md: curl -o CLAUDE.md https://zeroj.dev/ai/starter-pack.md. The downloadable Markdown version also contains a circuit API catalog generated from the Java sources (JSON).

ZeroJ is a Java-first zero-knowledge proof toolkit for Cardano (Java 25, Maven group org.zeroj, version 0.1.0-pre12).

  • Define circuits in Java — annotation style (@ZKCircuit, recommended) or the CircuitSpec/SignalBuilder DSL.
  • Prove with a pure-Java Groth16 prover on BLS12-381 (no native libraries; optional blst backend).
  • Verify off-chain in any JVM (zeroj-verifier-groth16) and on-chain in Cardano Plutus V3 validators compiled from Java by JuLC (zeroj-onchain-julc, JuLC 0.1.0-pre16).
  • Also: BBS selective-disclosure credentials (zeroj-bbs), snarkjs/circom interop, Poseidon-rooted authenticated state (experimental).

Status — say this honestly in anything you write for users: ZeroJ is experimental research software. It is not externally audited and must not protect real value on mainnet. “Beta” means feature-complete and correctness-tested, not audited.

Scope for the current release: Groth16 on BLS12-381 is the supported path. PlonK is experimental (prover, verifier and validators): never choose it by default and never describe it as correct or production-ready. BN254 is legacy and disabled by default — never use it for Cardano.

build.gradle
plugins {
id 'java'
id 'application'
}
java { toolchain { languageVersion = JavaLanguageVersion.of(25) } }
repositories { mavenCentral() }
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-circuit-lib' // Poseidon, Merkle, gadgets
implementation 'org.zeroj:zeroj-crypto' // prover, setup, snarkjs JSON export
implementation 'org.zeroj:zeroj-codec' // snarkjs JSON parsing
implementation 'org.zeroj:zeroj-verifier-groth16' // pure-Java verifier
// implementation 'org.zeroj:zeroj-onchain-julc' // Cardano validators + codecs
}
// Dev/test only: allows the single-party trusted setup used in examples.
application { applicationDefaultJvmArgs = ['-Dzeroj.allowInsecureTrustedSetup=true'] }
tasks.withType(Test).configureEach { systemProperty 'zeroj.allowInsecureTrustedSetup', 'true' }
  • The BOM pins the core modules. Opt-in modules are outside the BOM and need an explicit version: zeroj-verifier-plonk, zeroj-bbs, zeroj-mpf-poseidon, zeroj-jmt-poseidon.
  • Releases up to 0.1.0-pre11 used group com.bloxbean.cardano and packages com.bloxbean.cardano.zeroj.*. From 0.1.0-pre12 both are org.zeroj. Do not rename other com.bloxbean.cardano dependencies (Cardano Client Lib, JuLC).
  • Details: Installation.
SecretMultiplier.java
import org.zeroj.circuit.annotation.*;
@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);
}
}
Main.java
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.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. Circuit + witness (generated companion: SecretMultiplierCircuit)
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);
// 2. DEV-ONLY trusted setup (needs -Dzeroj.allowInsecureTrustedSetup=true)
BigInteger tau = PowersOfTauBLS381.generate(4).tauScalar();
try (var keys = Groth16Keys.setupInMemory(
r1cs.constraints(), r1cs.numWires(), r1cs.numPublicInputs(), tau)) {
// 3. Prove (fresh randomness every call)
Groth16ProofBLS381 proof = keys.prove(witness, r1cs.constraints());
// 4. Export snarkjs-compatible JSON and verify with the pure-Java verifier
BigInteger[] pub = Arrays.copyOfRange(witness, 1, 1 + r1cs.numPublicInputs());
String vkJson = SnarkjsGroth16Json.verificationKeyJson(keys);
String proofJson = SnarkjsGroth16Json.proofJson(proof);
String publicJson = SnarkjsGroth16Json.publicJson(pub);
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);
boolean valid = new Groth16BLS12381PureJavaVerifier().verify(envelope, material).proofValid();
System.out.println("valid = " + valid);
}
}
}

Facts about this flow (it is the quickstart program, compiled and run against the published artifacts):

  • The witness is a BigInteger[]; witness[0] is the constant 1, followed by the public inputs in schema order, then private wires. Arrays.copyOfRange(witness, 1, 1 + numPublicInputs) is exactly what the verifier needs; inputs.publicValues() gives the same values as a List<BigInteger>.
  • For Groth16 only tauScalar() is used and the setup sizes its own domain from the constraint count, so PowersOfTauBLS381.generate(4) (the minimum; allowed range 4–32) is enough for a dev key.
  • VerificationResult.proofValid() is the cryptographic result. accepted() also requires policy validity and stays false when you call a verifier directly — check proofValid() and apply your own policy.
  • Groth16Keys is AutoCloseable — always use try-with-resources.
  • Proofs are freshly blinded on every prove call. There is no deterministic or unblinded prove API; do not try to create one.
  • Proof size: 192 bytes compressed (G1 48 + G2 96 + G1 48).

Rules that make or break soundness:

  1. Proof code uses symbolic types only: ZkField, ZkBool, ZkUInt, ZkArray<T>, ZkBits, ZkBytes (package org.zeroj.circuit.annotation).
  2. @Prove returns ZkBool (or uses explicit assertion methods). Never return a Java boolean.
  3. Never use Java if, ?:, &&, ||, !, loops with data-dependent bounds, or == on symbolic values. Circuits describe relations; they do not execute. Use ZkBool.and(..), or(..), not(), select(..).
  4. Every ZkUInt input carries @UInt(bits = N) — this emits the range constraints. Without it, field arithmetic wraps around modulo a 255-bit prime and “age − 18” can be huge instead of negative.
  5. Every ZkArray, ZkBits, ZkBytes carries @FixedSize(n) or @FixedSize(param = "name"). Shapes are fixed at compile time.
  6. Values that change the circuit’s shape (tree depth, array length) are constructor parameters annotated @CircuitParam("name"). A different parameter value is a different circuit with different keys.
  7. Inputs are either fields of the class (field style) or parameters of the @Prove method (parameter style). Static @Prove methods must use parameter style. No private @Prove methods, no private field inputs, no nested @ZKCircuit classes.
  8. Add a ZkContext zk parameter when a gadget needs it (Poseidon, Merkle, Pedersen…).
@ZKCircuit(name = "range-proof", version = 1)
public class RangeProof {
@Secret @UInt(bits = 16) ZkUInt secret;
@Public @UInt(bits = 16) ZkUInt lo;
@Public @UInt(bits = 16) ZkUInt hi;
@Prove
ZkBool inRange() {
return secret.gte(lo).and(secret.lte(hi));
}
}
@ZKCircuit(name = "merkle-bls12-381", nameTemplate = "merkle-bls-d{depth}")
public class MerkleMembership {
public MerkleMembership(@CircuitParam("depth") int depth) { }
@Prove
ZkBool prove(ZkContext zk,
@Secret ZkField leaf,
@Public ZkField root,
@Secret @FixedSize(param = "depth") ZkArray<ZkField> siblings,
@Secret @FixedSize(param = "depth") ZkArray<ZkBool> pathBits) {
return ZkMerkle.isMemberPoseidon(zk, PoseidonParamsBLS12_381T3.INSTANCE,
leaf, root, siblings, pathBits);
}
}
// MerkleMembershipCircuit.build(32); MerkleMembershipCircuit.inputs(32)

The annotation processor generates <ClassName>Circuit with: build(...), schema(...), inputs(...) (a typed builder with one method per input, plus toWitnessMap(), publicValues(), toPublicInputs(), calculateWitness(circuit, curve)), calculateWitness(circuit, inputs, curve), circuitId(), metadata() and proofEnvelopeBuilder(...). Parameterized circuits take their @CircuitParam values in build(...), inputs(...), schema(...), circuitId(...) and metadata(...).

  • Poseidon with explicit BLS12-381 parameters: ZkPoseidon.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, left, right), ZkPoseidonN.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, x, y, z).
  • Merkle: ZkMerkle.isMemberPoseidon, verifyPoseidon, computeRootPoseidon with the same params.
  • Never for Cardano: ZkMiMC (BN254-only), the no-params Poseidon overload, ZkMerkle.HashType.MIMC or HashType.POSEIDON.
  • Heavier gadgets exist for real-world cryptography: ZkSha512, ZkHmacSha512, ZkBlake2b, ZkCip1852 (BIP32-Ed25519 / CIP-1852 derivation), ZkPedersen, ZkJubjubPoint, ZkEdDSAJubjub. Bind prover-supplied Jubjub points with ZkJubjubPoint.witnessAffine(zk, u, v). See Gadget library.

When annotations don’t fit, implement CircuitSpec and use SignalBuilder/Signal:

public class SecretMultiplierSpec implements CircuitSpec {
@Override
public void define(SignalBuilder c) {
Signal a = c.publicInput("a");
Signal b = c.privateInput("b");
Signal product = c.publicOutput("product");
c.assertEqual(a.mul(b), product);
}
public static CircuitBuilder build() {
return CircuitBuilder.create("secret-multiplier")
.publicVar("a").publicVar("product").secretVar("b")
.defineSignals(new SecretMultiplierSpec());
}
}
// witness: circuit.calculateWitness(Map.of("a", List.of(BigInteger.valueOf(3)), ...), CurveId.BLS12_381)

5. Testing circuits (always generate these)

Section titled “5. Testing circuits (always generate these)”

For every circuit, generate JUnit tests for: (1) a valid witness that proves and verifies; (2) at least one invalid witness per constraint — witness calculation throws (an ArithmeticException with Constraint violation: … for a failed equality, or an exception from the range decomposition) or the proof fails to verify; (3) boundary values for every @UInt range; (4) a tampered public input that must fail verification; (5) public-input order via inputs.publicValues(). A circuit that only passes honest tests may still be under-constrained. See Test your circuits for soundness.

SituationAPI
Tests, small circuitsGroth16Keys.setupInMemory(constraints, numWires, numPublic, tau) (dev-only)
Large circuits (100k+ constraints)Groth16Keys.setupToStore(flat, numWires, numPublic, tau, dir, true) then Groth16Keys.load(dir); Groth16Pipeline for compile caching
Production keysRun a multi-party snarkjs ceremony; import once with ZkeyPkStoreImporter.importToPkStore(zkeyPath, keysDir); prove from Groth16Keys.load(keysDir) with keys.prove(witness, ZkeyPkStoreImporter.snarkjsConstraints(r1cs.constraints(), numPublic)) (snarkjs appends numPublic + 1 binding rows), or pass numPublic + 1 binding rows to the packed prove / Groth16Pipeline
Optional native speed-upzeroj-crypto-blst + ProverBackend selection; bit-identical proofs

Setup validates the relation and fails closed: every public wire (and the constant wire 0) must be referenced by some constraint, otherwise IllegalArgumentException: R1CS public wire N … is not referenced by any constraint. Fix the circuit; do not catch and retry. Details: Prove with Groth16, Performance, Trusted setup ceremony.

import com.bloxbean.cardano.julc.clientlib.JulcScriptLoader;
import org.zeroj.onchain.julc.groth16.codec.ProverToCardano;
import org.zeroj.onchain.julc.groth16.validator.Groth16BLS12381Verifier;
var vk = ProverToCardano.compressVk(keys); // alpha, beta, gamma, delta, ic
var ic = ListPlutusData.of();
vk.ic().forEach(p -> ic.add(new BytesPlutusData(p)));
var script = JulcScriptLoader.load(Groth16BLS12381Verifier.class,
new BytesPlutusData(vk.alpha()), new BytesPlutusData(vk.beta()),
new BytesPlutusData(vk.gamma()), new BytesPlutusData(vk.delta()), ic);
var p = ProverToCardano.compressProof(proof); // piA (48 B), piB (96 B), piC (48 B)
// datum = ListPlutusData of BigIntPlutusData public inputs, in order
// redeemer = ConstrPlutusData(0, [piA, piB, piC]) as BytesPlutusData
  • The VK is baked into the script as parameters (so the script hash commits to the VK); public inputs go in the datum; the proof goes in the redeemer.
  • Groth16BLS12381Verifier is crypto-only: anyone who sees a proof can replay it. For anything beyond a demo, write your own validator that composes Groth16BLS12381Lib.verify(...) and enforces ScriptContext policy: bind a public input to the spend (e.g. spendRef = blake2b_256(txId ‖ outputIndex as 32 bytes) mod r, computed by the validator from ScriptContext and prepended to the application’s public inputs), bind the recipient, track nullifiers, and check authorization.
  • Do not lock funds with Groth16BLS12381TxOutRefBindingVerifier: it reads spendRef from the datum of the UTxO it guards, and a datum can’t contain a hash of its own transaction, so no real UTxO can be spent through it. It is a reference for the check only.
  • Upgrading JuLC changes the compiled script bytes and therefore the script hash/address.
  • Step by step: Verify your proof on Cardano. Reference: Verify proofs on Cardano.
❌ Don’t✅ Do
if (age.gte(t)) … or a && b on symbolic valuesage.gte(t), x.and(y), cond.select(...)
ZkUInt without @UInt(bits = N)Always declare the bit width
ZkMiMC, no-params Poseidon, BN254, -Dzeroj.allowLegacyBn254=true for CardanoPoseidon with PoseidonParamsBLS12_381T3.INSTANCE, BLS12-381
Recommending PlonK because it has a universal setupGroth16; mention PlonK only as experimental
PowersOfTauBLS381.generate / setupInMemory in productionMulti-party ceremony .zkey imported with ZkeyPkStoreImporter
Setting zeroj.allowInsecureTrustedSetup in production configOnly in tests, local demos and CI
Inventing a seeded/deterministic prover for reproducibilityStore the proof you produced
Bare Groth16BLS12381Verifier (or the datum-based Groth16BLS12381TxOutRefBindingVerifier) guarding fundsA custom validator on Groth16BLS12381Lib that computes the spend binding from ScriptContext, binds the recipient, and enforces policy
Trusting a prover-supplied fact (“my age is 27”)Have an issuer sign or commit to it and verify that inside the circuit or with BBS
Testing only the happy pathInvalid-witness and tampered-public-input tests for every constraint
Claiming “secure”, “audited”, “production-ready”“Experimental, not externally audited”
ErrorCause → fix
IllegalStateException: Single-party trusted setup is disabled by default…Dev setup without opt-in → add -Dzeroj.allowInsecureTrustedSetup=true (tests/demos only) or use ceremony keys.
IllegalStateException: BN254 is disabled by default…Legacy curve used → switch to CurveId.BLS12_381.
ArithmeticException: Constraint violation: … during witness calculationThe inputs don’t satisfy the circuit (expected for invalid-witness tests) or the inputs are wrong.
IllegalArgumentException: Missing public input: x / Missing secret input: xWitness map is missing a named input → use the generated inputs() builder.
IllegalArgumentException: R1CS public wire N … is not referenced by any constraintA public input is never constrained → constrain it or remove it from the public inputs.
IllegalArgumentException: Power must be in [4, 32]…Pick a PowersOfTauBLS381.generate power in range (4 is enough for a Groth16 dev key).
verify(...).proofValid() is falseWrong public-input order or values, a different VK, or a tampered proof. Take public inputs from witness[1..numPublicInputs].
accepted() is false although the proof is validExpected when calling a verifier directly: accepted() also needs policy validity. Check proofValid().
OutOfMemoryError on large circuitsUse setupToStore + Groth16Keys.load (mmap’d keys) and Groth16Pipeline.
On-chain script fails / budget exceededCheck datum = public inputs in order, redeemer = Constr 0 [piA, piB, piC], VK params match; see the on-chain guide for budgets.

Before handing code to a user, confirm:

  • Every relation the application depends on is constrained; no computed value is left unconstrained.
  • Every integer input is range-checked; booleans are constrained booleans (ZkBool).
  • Public vs secret assignment matches the privacy goal; public-input order is taken from the generated schema.
  • Hashes are Poseidon with BLS12-381 params; the curve is BLS12-381; the proof system is Groth16.
  • Dev trusted setup is confined to tests/demos and labelled as such.
  • On-chain: proof bound to the spend (a spendRef computed from ScriptContext, not read from the datum) and to the recipient, nullifiers or state prevent double use, ScriptContext policy enforced.
  • Inputs that come from outside (proof JSON, VK files) are parsed with ZeroJ codecs, which validate encodings; VKs are pinned by hash or ID.
  • Invalid-witness and tamper tests exist.
  • The text you give the user says ZeroJ is experimental and not externally audited.

The downloadable version of this pack (/ai/starter-pack.md) contains the full, generated list of annotations, symbolic types and gadget adapters with their exact public signatures, extracted from the Java sources at build time. It’s also available as JSON. If a method is not in the catalog or in these docs, assume it does not exist.