Write circuits with annotations
Annotations are the recommended way to write ZeroJ circuits. You write an ordinary Java class,
mark its inputs @Public or @Secret, and put the rules in one @Prove method. At compile
time, the annotation processor generates a companion class (MyCircuit gives you
MyCircuitCircuit) that builds a normal ZeroJ circuit, describes its inputs, and fills witnesses
for you.
Nothing about proving changes. The companion’s build() returns the same CircuitBuilder that
the lower-level DSL produces, so everything in Prove with Groth16
applies unchanged.
Set up the processor
Section titled “Set up the processor”You need zeroj-circuit-annotation-api on the compile classpath,
zeroj-circuit-annotation-processor on the annotation processor path, and usually
zeroj-circuit-lib for gadgets such as Poseidon:
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-lib'}Maven and Kotlin DSL versions are on Installation. If your circuits live
in test sources, use testAnnotationProcessor too.
Anatomy of an annotated circuit
Section titled “Anatomy of an annotated circuit”This sealed-bid circuit proves “my hidden bid matches the public commitment, and it is at least the reserve price” without revealing the bid:
import org.zeroj.circuit.annotation.Prove;import org.zeroj.circuit.annotation.Public;import org.zeroj.circuit.annotation.Secret;import org.zeroj.circuit.annotation.UInt;import org.zeroj.circuit.annotation.ZKCircuit;import org.zeroj.circuit.annotation.ZkBool;import org.zeroj.circuit.annotation.ZkContext;import org.zeroj.circuit.annotation.ZkField;import org.zeroj.circuit.annotation.ZkUInt;import org.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3;import org.zeroj.circuit.lib.zk.ZkPoseidon;
@ZKCircuit(name = "sealed-bid", version = 1)public class SealedBid { @Prove ZkBool prove( ZkContext zk, @Public ZkField bidCommitment, @Public @UInt(bits = 64) ZkUInt reservePrice, @Secret @UInt(bits = 64) ZkUInt bidAmount, @Secret ZkField salt) { var commitmentMatches = ZkPoseidon.hash( zk, PoseidonParamsBLS12_381T3.INSTANCE, bidAmount.asField(), salt) .isEqual(bidCommitment);
return commitmentMatches.and(bidAmount.gte(reservePrice)); }}A few things to notice:
- The method body doesn’t compute an answer. It describes constraints.
ZkField,ZkUIntandZkBoolare symbolic values: wires in a circuit, not numbers. - Returning a
ZkBoolmakes the generated code assert that it is true. A witness that makes it false can’t be proven. ZkContextis optional. Declare it when you call gadgets that need the circuit context.- For Cardano, pass
PoseidonParamsBLS12_381T3.INSTANCEto every Poseidon call. See Gadget library.
Annotations
Section titled “Annotations”| Annotation | Where | What it does |
|---|---|---|
@ZKCircuit(name, nameTemplate, version) | class | Marks the circuit. name defaults to the class name, version defaults to 1 and must be positive, nameTemplate is for parameterized circuits. |
@Prove | method | The single method that defines the constraints. Exactly one per class, not private. Returns ZkBool or void. |
@Public(name = "...") / @Secret(name = "...") | field or parameter | Visibility. Exactly one is required on every input. name overrides the generated input name. |
@UInt(bits = N) | ZkUInt input (or arrays of it) | Declares the width, 1 to 253. Required on every ZkUInt input. |
@FixedSize(value) / @FixedSize(param = "...") | ZkArray, ZkBits, ZkBytes | Fixed length, as a literal or a @CircuitParam name. Add inner / innerParam for ZkArray<ZkArray<T>>. |
@CircuitParam("name") | constructor parameter | A build-time value that changes the circuit’s shape (depth, size, mode). |
@Order(n) | field | Overrides declaration order in field style. Values must be unique per visibility. |
@FieldElement | ZkField input | Optional marker that documents “raw field element”. The processor only checks it sits on a ZkField. |
Symbolic types
Section titled “Symbolic types”| Type | Input constraints added | Key operations |
|---|---|---|
ZkField | none | add, sub, mul, div, isEqual, assertEqual |
ZkBool | value is 0 or 1 | and, or, xor, not, select(a, b), isEqual, assertTrue, assertFalse, asField |
ZkUInt | value < 2^bits (range check) | add, sub, mul, lt, lte, gt, gte, inRange, isEqual, assertEqual, asField, bits |
ZkArray<T> | per element, by element type | get(i), size(), values() |
ZkBits | each element boolean | get(i), size(), isEqual, assertEqual |
ZkBytes | each element 8 bits | get(i) (a ZkUInt), size(), isEqual, assertEqual |
Semantics worth knowing:
ZkFieldarithmetic wraps modulo the circuit’s field prime (the BLS12-381 scalar field for Cardano). UseZkUIntfor anything that behaves like an amount, a count, or an age.ZkUIntkeeps you honest about widths.addandmulwiden the result (addof two 64-bit values is 65 bits,mulsums the widths), and fail at circuit-build time if the result would exceed 253 bits.subrange-constrains its result, so a negative difference makes the witness unsatisfiable instead of wrapping. Comparisons need widths below 253.ZkContextgives you constants:zk.constant(18)orzk.constant(BigInteger)return aZkField.
Field style and parameter style
Section titled “Field style and parameter style”You can declare inputs as fields or as @Prove parameters. Pick one per class; mixing them is a
compile error.
@ZKCircuit(name = "range-proof")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 = "age-verification")public class AgeVerification { @Prove ZkBool prove(@Secret @UInt(bits = 8) ZkUInt age, @Public @UInt(bits = 8) ZkUInt threshold) { return age.gte(threshold); }}Field style is concise. Parameter style keeps every dependency in the method signature and is
required for static @Prove methods. Symbolic input fields must be neither private nor
final, and a non-static circuit needs a visible no-argument constructor unless it has a
@CircuitParam constructor.
When there is no natural boolean result, declare void and assert explicitly:
@Provevoid prove(ZkContext zk, @Secret @UInt(bits = 16) ZkUInt value, @Secret @UInt(bits = 16) ZkUInt blinding, @Public ZkField expectedU, @Public ZkField expectedV) { ZkPedersen.commit(zk, value, blinding, 16) .assertAffineEquals(zk, expectedU, expectedV);}Input order and names
Section titled “Input order and names”The order of public inputs is part of your verification key’s contract, so know how it’s decided:
- All public inputs come before all secret inputs.
- Within each group, inputs keep declaration order. In field style,
@Ordervalues come first (ascending), then unannotated fields in declaration order. - Array inputs are flattened with a singular base name:
siblingsbecomessibling_0,sibling_1, and so on. A matrixmeasurementsbecomesmeasurement_0_0,measurement_0_1, row-major.
The generated schema().publicInputs().names() is the source of truth. Assert it in a test (see
Test your circuits) so a refactor can’t silently reorder
your public inputs.
Parameterized circuits
Section titled “Parameterized circuits”Constructor parameters annotated @CircuitParam make the circuit a template. Supported types are
primitive or boxed integral, boolean and char types, String, BigInteger, and enums. Integer
parameters can size arrays through @FixedSize(param = ...):
@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); }}Every generated method then takes the parameters:
var circuit = MerkleMembershipCircuit.build(20);var inputs = MerkleMembershipCircuit.inputs(20);var schema = MerkleMembershipCircuit.schema(20);The generated circuit name is the rendered template plus a canonical parameter suffix, for
example merkle-bls-d2--depth-1:2 for depth 2. The suffix prevents two parameter sets from ever
sharing a name.
Rectangular matrices
Section titled “Rectangular matrices”Two-dimensional inputs are supported when both dimensions are fixed:
@Secret @UInt(bits = 16)@FixedSize(param = "rows", innerParam = "cols")ZkArray<ZkArray<ZkUInt>> measurementsThe input builder takes a List<List<BigInteger>> and rejects ragged rows. Deeper nesting isn’t
supported; flatten it to parallel arrays.
The generated companion
Section titled “The generated companion”For a class X, the processor generates XCircuit with these static members. For
parameterized circuits, the methods marked with ✱ take the @CircuitParam values as arguments.
| Member | Returns | Use it to |
|---|---|---|
CIRCUIT_NAME, CIRCUIT_VERSION, CIRCUIT_NAME_TEMPLATE | constants | Identify the circuit (the template constant exists only with nameTemplate) |
One String constant per input, such as BID_AMOUNT | constants | Refer to input names without string literals |
build(...) ✱ | CircuitBuilder | Compile (compileR1CS) and compute witnesses |
schema(...) ✱ | ZkCircuitSchema | Inspect names, order, widths, and dimensions |
inputs(...) ✱ | XCircuit.Inputs | Fill input values fluently |
circuitId(...) ✱ | CircuitId | Label envelopes and key registries |
metadata(...) ✱ | ZkCircuitMetadata | Name, version, and parameters for envelopes |
calculateWitness(circuit, inputs, curve) | BigInteger[] | Compute the full witness |
publicInputs(inputs) | List<BigInteger> | Public values in schema order |
publicInputValues(inputs) | PublicInputs | The same, as the envelope type |
proofEnvelopeBuilder(circuit, proofSystem, curve, proofBytes, inputs, vkRef) | ZkProofEnvelope.Builder | Wrap proof bytes with the right circuit ID, public inputs, and metadata |
Inputs has one setter per input. Scalars accept BigInteger or long. Arrays accept
(index, value) or a whole List<BigInteger>, and matrices accept (row, col, value) or a
list of rows. It also offers toWitnessMap(), publicValues(), toPublicInputs(),
calculateWitness(circuit, curve), and schema().
Putting it together:
var circuit = SealedBidCircuit.build();
BigInteger bid = BigInteger.valueOf(100);BigInteger salt = new BigInteger("88001");BigInteger commitment = PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, bid, salt);
var inputs = SealedBidCircuit.inputs() .bidCommitment(commitment) .reservePrice(75) .bidAmount(bid) .salt(salt);
var r1cs = circuit.compileR1CS(CurveId.BLS12_381);BigInteger[] witness = inputs.calculateWitness(circuit, CurveId.BLS12_381); // witness[0] == 1List<BigInteger> publicValues = inputs.publicValues(); // [commitment, 75]PoseidonHash (in org.zeroj.circuit.lib.poseidon) computes the same hash outside the circuit,
which is how you produce public values such as commitments. Use a large random salt in real code.
From here, r1cs and witness go straight into Groth16 proving.
After you have a proof, proofEnvelopeBuilder(...) checks that the circuit you pass has the name
the inputs were generated for, then builds an envelope with the generated CircuitId, the public
inputs in schema order, and the circuit metadata.
Authoring rules
Section titled “Authoring rules”These rules are what keep an annotated circuit sound:
- No Java control flow on circuit values.
ZkBoolis not a Javaboolean, soif,&&and||over secrets don’t compile. That’s deliberate: a circuit can’t branch. Useand,or,not, andselect(ifTrue, ifFalse), which evaluate both sides and pick one with a constraint. - Java loops over shape values are fine. Loops over
@CircuitParamsizes or constants just unroll into more constraints. - A
ZkBoolyou compute and then drop constrains nothing. In avoidmethod, callassertTrue(),assertEqual(...), or an asserting gadget, or return theZkBool. - Range-check everything that is really a number. Use
ZkUIntwith the tightest honest@UIntwidth, and never compare rawZkFieldvalues as if they were integers. - Use explicit BLS12-381 Poseidon parameters. The no-parameter overloads are BN254 oriented, and the circuit refuses to compile for BLS12-381 if the fields don’t match.
- Test with invalid witnesses. An honest witness that passes proves very little. See Test your circuits for soundness.
When the symbolic types can’t express something, drop to CircuitSpec and the Signal API. See
CircuitSpec & the Signal DSL.
Current limits
Section titled “Current limits”- Nested (inner)
@ZKCircuitclasses,private@Provemethods, andprivateorfinalinput fields aren’t supported. static@Provemethods must use parameter style.@CircuitParambelongs on constructor parameters, not on@Proveparameters, and a class can have only one@CircuitParamconstructor.ZkArrayelements must beZkField,ZkBool,ZkUInt, or one nestedZkArrayof those.ZkBitsandZkBytesstore one constrained field element per bit or byte. Packed encodings aren’t available yet, and symbolic bitwise operations onZkBitsare limited.ZkMiMCandZkMerkle.HashType.MIMCare BN254-only, so they aren’t usable for Cardano circuits.
Next steps
Section titled “Next steps”- Gadget library: Poseidon, Merkle, comparators, Jubjub, and more
- Test your circuits for soundness
- Prove with Groth16