Thinking in Compact: A Guide for Circom Developers
If you've written Circom circuits, you already understand the hard parts: witness generation, constraint systems, the awkward gap between "compute a value" and "prove a constraint about that value." Compact solves several of these problems differently, and some of the solutions are genuinely better. But the mental model shift is real, and it trips people up.
This guide maps your Circom knowledge onto Compact concepts directly. No introductory fluff — I'm assuming you know what an R1CS is and why it matters.
The Fundamental Difference: Declarative vs Imperative
In Circom, you write constraints. The circuit is a set of polynomial equations that must be satisfied. Witness generation — computing the actual signal values that satisfy those constraints — is a separate step, often written in JavaScript.
In Compact, you write functions. The circuit is imperative code that executes like a normal program. The constraint system is derived automatically by the compiler from the execution trace. You don't write constraints; you write logic, and Compact figures out what constraints are implied.
This distinction has cascading effects on how you think about everything else.
Circom mindset: "What constraints must hold for this computation to be valid?"
Compact mindset: "What computation should happen, and what should be kept private?"
Neither is strictly better. Circom's constraint-first approach gives you fine-grained control over constraint count. Compact's execution-first approach is significantly easier to write and reason about.
Witness Generation: Two Very Different Models
In Circom, the witness is a vector — an assignment of values to all signals in the circuit. You compute this witness externally (via a JavaScript or C++ witness calculator), feed it into the prover, and the prover verifies that the constraint system is satisfied.
// Circom: split between constraint definition and witness computation
template Multiplier() {
signal input a;
signal input b;
signal output c;
c <== a * b; // this is a CONSTRAINT, not an assignment
// Witness (a=3, b=5) is computed separately and passed in
}
In Compact, witnesses are callbacks — TypeScript functions that your DApp provides at runtime. When the circuit executes locally (before proof generation), it calls these callbacks to get private data. The private values flow through the circuit like normal function arguments.
contract Multiplier {
witness getValues(): [Uint<64>, Uint<64>] {
// Implemented in TypeScript by the DApp developer
// Can call APIs, read local storage, prompt user, etc.
}
export circuit multiply(): [Uint<64>] {
const [a, b] = getValues();
return [a * b];
}
}
The TypeScript implementation of that witness:
const contract = new Contract(artifact);
const result = await contract.circuits.multiply({
getValues: async (context) => {
const a = await promptUser("Enter first value:");
const b = await promptUser("Enter second value:");
return [BigInt(a), BigInt(b)];
}
});
The key insight: witness values in Compact are runtime callbacks, not a pre-computed vector. You write normal TypeScript code that happens to run inside a ZK circuit context.
Public vs Private: Ledger State vs Witnesses
In Circom, you designate signals as either public inputs or private inputs. Public inputs appear in the verification key and are submitted with the proof. Private inputs are part of the witness and never leave the prover.
Compact replaces this with a two-layer model:
| Circom | Compact | Notes |
|---|---|---|
| Public input | Ledger state | On-chain, visible to all nodes |
| Private input | Witness | Off-chain, never touches the blockchain |
| Output | Ledger effect | Public result of circuit execution |
Ledger state is Compact's persistent, on-chain storage. Every field on the ledger is public — nodes can read it, explorers can index it, anyone watching the chain can see it. Updates to ledger state require a ZK proof that the update was computed correctly.
Witnesses are private data provided by the prover at transaction time. They're used inside the circuit but never committed to the chain. Only the ZK proof (which cryptographically commits to the witness without revealing it) goes on-chain.
contract VotingSystem {
// Public: on-chain, readable by anyone
ledger totalVotes: Uint<64>;
ledger committedVoters: Set<Bytes<32>>; // nullifier set
witness getVoterCredential(): [Bytes<32>, Bytes<32>] {
// Private: voter identity and secret never leave prover's machine
}
export circuit castVote(): [] {
const [voterId, secret] = getVoterCredential();
// Compute nullifier to prevent double-voting
const nullifier = hash(voterId, secret);
// Assert voter hasn't voted (uses public ledger state)
assert !ledger.committedVoters.member(nullifier) : "already voted";
// Update public state
ledger.committedVoters = ledger.committedVoters.insert(nullifier);
ledger.totalVotes = ledger.totalVotes + 1;
}
}
What's private: the voter's identity (voterId) and secret. What's public: that someone voted, the total count, and the nullifier (which can't be linked back to identity without knowing voterId and secret).
Explicit Disclosure: A Required Discipline
Compact enforces a rule that will feel strange coming from Circom: you cannot move witness data to public state without explicitly calling disclose().
In Circom, there's no such enforcement — you define which signals are public and which are private, but there's no compiler check preventing you from accidentally leaking information through constraint structure. In Compact, the compiler rejects any attempt to store witness data in ledger state without a disclose() wrapper.
witness getSecretAmount(): [Uint<64>] { ... }
export circuit badExample(): [] {
const [amount] = getSecretAmount();
ledger.publicAmount = amount; // COMPILE ERROR: cannot store witness in ledger
}
export circuit goodExample(): [] {
const [amount] = getSecretAmount();
const revealedAmount = disclose(amount); // explicit: I know this becomes public
ledger.publicAmount = revealedAmount; // OK
}
This is a better default than Circom's model. Privacy is explicit rather than accidental. You have to actively choose to reveal data.
Common Circom Patterns and Their Compact Equivalents
Range Checks
In Circom, range checks require bit decomposition — you manually decompose a number into bits, constrain each bit to 0 or 1, and reconstruct the value to verify the range. It's verbose and easy to get wrong.
// Circom: manual range check
template RangeCheck(n) {
signal input in;
signal output out;
signal bits[n];
var sum = 0;
for (var i = 0; i < n; i++) {
bits[i] <-- (in >> i) & 1;
bits[i] * (bits[i] - 1) === 0;
sum += bits[i] * 2**i;
}
sum === in;
out <== in;
}
In Compact, the type system handles this automatically. Uint<32> is constrained to the range [0, 2^32) by definition. You get range checks for free:
export circuit verifyAge(
witness birthYear: Uint<16> // already constrained to [0, 65535]
): [Boolean] {
const currentYear: Uint<16> = 2026;
assert birthYear <= currentYear : "birth year cannot be in the future";
assert birthYear >= 1900 : "implausible birth year";
const age: Uint<16> = currentYear - birthYear;
return [age >= 18];
}
Merkle Proof Verification
Circom's Merkle proof template requires ~50 lines and careful conditional logic for left/right hashing. Compact is closer to what you'd write in TypeScript:
export circuit verifyMembership(
witness leaf: Bytes<32>,
witness siblings: Bytes<32>[32],
witness pathBits: Boolean[32],
public root: Bytes<32>
): [Boolean] {
let hash = leaf;
for (let i = 0; i < 32; i++) {
const sibling = siblings[i];
hash = pathBits[i]
? poseidon(sibling, hash)
: poseidon(hash, sibling);
}
return [hash == root];
}
The loop is bounded at compile time (32 iterations), so the compiler can unroll it into the appropriate constraint structure. You get the same result as Circom's template with a fraction of the code.
Nullifier Patterns
Circom nullifiers are just hash(secret, ...) computed inside the circuit and constrained to equal a public input. Compact's witness model makes this more explicit:
contract Registry {
ledger usedNullifiers: Set<Bytes<32>>;
witness getSecret(): [Bytes<32>] { ... }
export circuit claimOnce(public domain: Bytes<32>): [] {
const [secret] = getSecret();
const nullifier = disclose(poseidon(secret, domain));
assert !ledger.usedNullifiers.member(nullifier) : "already claimed";
ledger.usedNullifiers = ledger.usedNullifiers.insert(nullifier);
}
}
disclose(poseidon(secret, domain)) computes the nullifier from private data and explicitly marks it as public — it'll appear in the ledger and on-chain.
ZK Proof Structure: What's Different
Circom/SnarkJS produces proofs using Groth16 or PLONK over a chosen elliptic curve. You run a circuit-specific setup ceremony to generate proving/verification keys, then generate proofs using those keys. The entire pipeline — circuit compilation, setup, witness generation, proof generation — runs in your toolchain.
Midnight's proof pipeline is structured differently. The Compact compiler produces two artifacts: a JavaScript implementation of the circuit (which runs locally on the prover's machine) and a circuit artifact for proof generation. The local execution collects a ProofData object — essentially a record of the execution trace, inputs, witness calls, and ledger snapshot. This ProofData goes to a Proof Server, which generates the ZK proof.
The practical implication: circuit execution and proof generation are decoupled. Your DApp runs the circuit locally to get the result immediately, then hands off proof generation to the proof server in the background. From a UX perspective, this is much better than blocking on proof generation for every user interaction.
There's no per-circuit trusted setup ceremony in Midnight. The network-level setup handles this. You ship circuits without coordinating ceremony participants.
What's Genuinely Easier in Compact
Syntax and readability. Circom's signal/constraint syntax is specialized and verbose. Compact reads like TypeScript with some new keywords. Onboarding is significantly faster.
Type-enforced bounds. Circom requires explicit bit-decomposition for range checks. Compact's type system handles this automatically. Uint<8> is always 0-255.
Witness injection. Separate witness calculators in Circom are a real source of bugs — you maintain two representations of the same computation (the constraints and the witness generator). Compact collapses these into one execution.
Privacy by default. Compact's disclose() requirement makes privacy violations explicit at compile time. You can't accidentally leak witness data.
Testing. Because circuits run as normal JavaScript before proof generation, you can test them with standard TypeScript testing tools. No special ZK test infrastructure required.
What's Better in Circom
Constraint optimization. Circom gives you direct control over the R1CS. You can hand-craft minimal constraint counts. Compact generates constraints from code, which is generally efficient but less tunable.
Ecosystem maturity. circomlib has a decade of battle-tested templates: Poseidon, Pedersen commitments, ECDSA, EdDSA, Merkle trees. Compact's standard library is newer and smaller.
Tooling. The Circom ecosystem — circom, snarkjs, hardhat-circom, circomspect — is mature and well-documented. Midnight's tooling is good but newer.
Summary Mapping
| Circom Concept | Compact Equivalent |
|---|---|
| Public input | Ledger state field |
| Private input / witness |
witness callback return value |
<== constraint |
Imperative code + compiler-derived constraints |
| Template | Contract function / circuit |
| Separate witness generator (JS) | Witness callback (TypeScript, same file) |
| Proving/verification keys | Network-level, no per-circuit ceremony |
signal output |
Circuit return value |
| circomlib template import | Standard library import |
=== hard constraint |
assert statement |
| Bit decomposition | Native Uint<N> type |
The mental shift from Circom to Compact is moving from constraint declaration to execution semantics. Once that clicks, the rest of the model follows naturally. Your ZK knowledge transfers — the new syntax and idioms take an afternoon.
Top comments (0)