Skip to content

CircuitSpec & the Signal DSL

View Markdown

Annotated circuits are built on a lower layer that you can use directly: CircuitBuilder, the object-oriented Signal API, and the functional CircuitAPI. This page covers when to drop down to that layer, how to use it, and how prover hints work without making your circuit forgeable.

Everything here lives in zeroj-circuit-dsl (package org.zeroj.circuit). Gadgets such as Poseidon and comparators come from zeroj-circuit-lib.

StyleEntry pointUse it when
Annotations@ZKCircuit + generated *CircuitNew application circuits. See Write circuits with annotations.
CircuitSpec + SignalCircuitBuilder.defineSignals(spec)You want a reusable circuit class without the annotation processor, need Signal-level gadgets that have no Zk* adapter, or are porting a circom-style circuit.
Inline CircuitAPICircuitBuilder.define(api -> ...)Small tests, quick experiments, and building gadgets on raw Variables.

All three produce the same kind of CircuitBuilder, so compiling, witness calculation, and proving are identical from there on.

A CircuitSpec is a class with one method, define(SignalBuilder c). You declare the input layout on the CircuitBuilder, then fetch the same names inside define:

HashCommitmentCircuit.java
import org.zeroj.circuit.CircuitBuilder;
import org.zeroj.circuit.CircuitSpec;
import org.zeroj.circuit.Signal;
import org.zeroj.circuit.SignalBuilder;
import org.zeroj.circuit.lib.SignalPoseidon;
import org.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3;
public class HashCommitmentCircuit implements CircuitSpec {
@Override
public void define(SignalBuilder c) {
Signal secret = c.privateInput("secret");
Signal salt = c.privateInput("salt");
Signal commitment = c.publicOutput("commitment");
Signal hash = SignalPoseidon.hash(c, PoseidonParamsBLS12_381T3.INSTANCE, secret, salt);
c.assertEqual(hash, commitment);
}
public static CircuitBuilder build() {
return CircuitBuilder.create("hash-commitment")
.publicVar("commitment")
.secretVar("secret")
.secretVar("salt")
.defineSignals(new HashCommitmentCircuit());
}
}

The rules that trip people up:

  • Names must match. c.publicInput("x") / c.publicOutput("x") require an earlier .publicVar("x"), and c.privateInput("y") requires .secretVar("y"). An unknown name, or a name requested with the wrong visibility, throws IllegalArgumentException while the circuit is being defined.
  • publicOutput is just a public input. The name documents intent. The prover still supplies the value, and only your constraints make it correct.
  • Declaration order is public-input order. Wire 0 is the constant 1, then every publicVar in declaration order, then every secretVar. Verifiers receive public inputs in exactly this order.
  • Define once. define / defineSignals build the constraint graph and freeze that definition. A symbolic value that escapes the block and later tries to add constraints fails loudly instead of silently constraining nothing.

Constructor parameters replace circom template parameters: a MerkleCircuit(int depth) can loop depth times in define and declare sibling_0 ... sibling_{depth-1} in its build(depth).

For tests and experiments, define the constraints in place with the functional CircuitAPI, where values are Variables and operations are methods on api:

var circuit = CircuitBuilder.create("multiplier")
.publicVar("c").secretVar("a").secretVar("b")
.define(api -> api.assertEqual(api.mul(api.var("a"), api.var("b")), api.var("c")));

defineSignals also takes a lambda, so you can write Signal-style code inline, and even mix in the symbolic annotation types:

var circuit = CircuitBuilder.create("range")
.publicVar("threshold")
.secretVar("age")
.defineSignals(c -> {
var age = ZkUInt.secret(c, "age", 8); // adds the 8-bit range check
var threshold = ZkUInt.publicInput(c, "threshold", 8);
age.gte(threshold).assertTrue();
});
OperationMeaningR1CS cost
a.add(b), a.sub(b), a.neg(), a.add(5)Field addition and subtractionFree (linear combination)
a.mul(b)Field multiplication1 constraint
a.mul(5)Multiply by a constantFree (the compiler folds constant factors into a linear combination)
a.inv(), a.div(b)Inverse, divisionAdds a·a⁻¹ = 1, so zero has no inverse and makes the witness unsatisfiable
a.toBinary(n)Decompose into n bits, LSB firstAbout n booleanity checks plus one recomposition
a.and(b), a.or(b), a.xor(b), a.not()Boolean logicInputs must already be boolean
a.isZero(), a.isEqual(b)Returns 1 or 0A few constraints (uses advice, see below)
a.lessThan(b, n)1 if a < b as n-bit integersO(n); range-checks both operands
cond.select(x, y)cond ? x : y, with cond booleanSmall constant
a.assertBoolean(), a.assertInRange(n)Constrain to {0,1} or to [0, 2ⁿ)1, about n + 1
c.assertEqual(a, b), c.assertNotEqual(a, b)Equality constraintsSmall constant
c.constant(v), c.fromBinary(bits), c.arrayAccess(arr, idx)Constants, recomposition, MUX lookupFree, free, O(length)

The golden rule of R1CS cost: additions are free, multiplications are not. Reuse intermediate results instead of recomputing them, and prefer select over any “branching”.

isEqual, lessThan and friends return a 0/1 signal; they don’t assert anything. Feed the result into c.assertEqual(result, c.constant(1)) or another constraint, or it constrains nothing.

var circuit = HashCommitmentCircuit.build();
R1CSConstraintSystem r1cs = circuit.compileR1CS(CurveId.BLS12_381); // Groth16
r1cs.numConstraints();
r1cs.numWires();
r1cs.numPublicInputs();
r1cs.constraints(); // List<R1CSConstraint>
r1cs.flat(); // packed CSR form (R1CSFlat) for large circuits
byte[] iden3 = R1CSSerializer.serialize(r1cs); // .r1cs file for snarkjs tooling
MethodProducesNotes
compileR1CS(curve)R1CSConstraintSystemThe Groth16 input. Use CurveId.BLS12_381 for Cardano.
compileR1CSWithDiagnostics(curve)R1CSCompiler.CompilationResultSame compilation plus diagnostics. Use it in build tooling to catch changes in the R1CS shape before you generate keys.
compilePlonK(curve)PlonKConstraintSystemExperimental. See Prove with PlonK.

Gadgets that depend on field constants (Poseidon, MiMC) record the field they need. If you compile or compute a witness for a different curve, CircuitBuilder throws IllegalStateException instead of producing a circuit with mismatched constants.

BigInteger[] witness = circuit.calculateWitness(Map.of(
"commitment", List.of(commitment),
"secret", List.of(secret),
"salt", List.of(salt)), CurveId.BLS12_381);
// witness[0] = 1, witness[1..numPublic] = public inputs, then secrets, then intermediate wires

The witness calculator evaluates every gate in order and checks every equality assertion. Expect these failures:

ExceptionCause
ArithmeticExceptionA constraint is violated (Constraint violation: ...)
IllegalArgumentExceptionA declared input is missing from the map
IllegalStateExceptionThe curve doesn’t match the gadgets’ field

Input values are reduced modulo the field prime before use, so -1 becomes p - 1. Range-check anything that must be a small integer. For very large circuits, calculateWitnessFlat and calculateWitnessFlatChunked return packed limbs instead of millions of BigIntegers; see Performance & large circuits.

Hints: prover advice, and how to keep it sound

Section titled “Hints: prover advice, and how to keep it sound”

Some values are expensive to compute with constraints but cheap to check. An inverse is the classic example: computing a⁻¹ in-circuit is costly, but checking a · x = 1 costs one multiplication. A hint (also called advice) is a value the witness calculator computes outside the constraint system and hands to the circuit as a new wire.

Here’s the catch: a hinted wire is unconstrained. A malicious prover doesn’t run your witness calculator. They pick every wire value themselves. The only thing that stops them putting any number in a hinted wire is the constraints you add around it. Soundness lives entirely in those constraints.

You already use hints through built-ins that pin their own advice:

OperationAdviceConstraint that pins it
inv(a)x = a⁻¹a · x = 1
isZero(a)r (the result) and x (an inverse)a · x = 1 − r and a · r = 0
toBinary(a, n)the bitseach bit is boolean, and the bits recompose to a

For advanced gadgets, CircuitAPI.hintN(kind, params, numOutputs, inputs) requests multi-output advice from a fixed, enumerated set of trusted-core kinds (Gate.HintKind: MUL_MOD_REDUCE and INV_MOD, used for non-native Ed25519 field arithmetic). There is deliberately no way to plug in arbitrary advice lambdas. hintN creates no constraints: the caller must add all of them.

The rules ZeroJ applies to its own hinted gadgets are the ones to follow in yours:

  1. Pin every hinted value. For each advice wire, write down which constraints force it to one correct value, or show that a different value can’t change any output.
  2. Range-check limbs and quotients. Advice split into limbs must have each limb range-checked, or a prover can overflow the field.
  3. Check integer identities over the integers. For non-native arithmetic, verify a·b − q·p − r = 0 limb-wise with range-bounded carries, not merely modulo the native field. Checking only modulo the native field lets a prover forge by adding a multiple of the field modulus.
  4. Test with mutated advice. Take an honest witness, change each hint output (+1, −1, plus the modulus), and assert the circuit rejects it. An accepted mutation is a forgery. The next page shows how: Test your circuits for soundness.

ZeroJ’s own hint-based Ed25519 multiplication (Fe25519.USE_HINT_MUL) ships off by default and gated on an external audit. The simpler hint-based inverse (Fe25519.USE_HINT_INVERSE) is on by default because its check reuses the deterministic multiplication.

  • Minimize multiplications; additions are free.
  • Decompose to bits once and reuse the result. ZkUInt.decomposition() and CircuitAPI.decompose(...) return an owned BitDecomposition that gadgets can reuse, instead of range-checking the same wire twice.
  • Use Poseidon with PoseidonParamsBLS12_381T3.INSTANCE as your hash. Bit-oriented hashes such as SHA-512 cost around a hundred thousand constraints per block.
  • Keep public inputs few and bound to something meaningful. Every public wire must appear in some constraint, or Groth16 setup refuses the relation (see Prove with Groth16).

Design notes: ADR-0010 (the Java circuit DSL), ADR-0028 (DSL optimization and hint soundness).