If you've built ZK circuits with Circom before, you already have a head start with Midnight's Compact language. The core instinct is the same: express computation in a way a prover can prove and a verifier can verify, without leaking private data. What changes is the shape of that instinct — Compact is a full contract language, not a circuit DSL, and that difference runs deeper than syntax.
This isn't a beginner's intro to zero-knowledge proofs. It's a translation guide for someone who already thinks in signals and templates and wants to understand what those map to when writing Compact contracts on Midnight. The areas that trip people up most: concept mapping, the ledger (nothing like it exists in Circom), and a handful of pitfalls that catch almost everyone on the transition.
Different problem, similar intuition
Circom does one thing: describe a constraint system. You define signals, wire them together, and the Circom compiler turns your templates into an R1CS (Rank-1 Constraint System) that snarkjs or Groth16 uses to produce and verify proofs. Everything else — state management, contract logic, user interaction — lives in Solidity or some wrapper layer you build separately.
Compact is a full contract language. It's built for Midnight, a blockchain with privacy at the protocol level. A Compact contract contains your ZK circuit logic, your on-chain state machine, and the interface your DApp calls into. No Solidity contract needed on top.
The underlying proof system is also different. Circom compiles to R1CS. Compact compiles to ZKIR, Midnight's own intermediate representation. You can't port a Circom circuit by copy-pasting — the constraint models operate at different abstractions. But your thinking transfers well. The same habits that made you effective in Circom apply in Compact.
Your Circom vocabulary, translated
| Circom concept | Compact equivalent | Key difference |
|---|---|---|
signal |
Typed variable | Rich type system: Field, Uint<n>, Bytes<n>, Vector<n, T>
|
template |
circuit |
Can be pure (stateless) or impure (reads/writes ledger) |
component |
Circuit call + witness | Sub-circuits called directly; private data comes from witnesses |
=== R1CS constraint |
assert(cond, msg) |
Same constraint logic, more readable |
<== signal assignment |
Variable assignment + disclose()
|
Privacy is explicit — you declare when private data goes public |
component main |
export circuit |
Exported circuits are the contract's public entry points |
Signals → typed variables
In Circom, a signal is always a field element — full stop. If you want a boolean, you write constraints enforcing it's 0 or 1. Range checks require bit decomposition. You do that work manually.
Compact has a real type system, and it handles a lot of that automatically:
// Circom-style thinking: everything is a Field
signal input age; // could be anything
signal input nonce; // could be anything
signal output hash; // field element
// Compact-style: types carry meaning built into the language
circuit example(age: Uint<8>, nonce: Bytes<32>): Bytes<32> {
// age is already constrained to 0-255 by the type
// nonce is already exactly 32 bytes
return persistentHash<[Uint<8>, Bytes<32>]>([age, nonce]);
}
Compact's built-in types:
-
Boolean— true/false -
Field— unsigned integer up to the native prime (for raw field arithmetic) -
Uint<n>— n-bit unsigned integer (Uint<32>,Uint<64>, etc.) -
Uint<0..n>— bounded integer, guaranteed to be less than n -
Bytes<n>— fixed-length byte array of exactly n bytes -
Vector<n, T>— homogeneous fixed-length tuple
You can also define structs and enums, which Circom has no equivalent for:
struct MerkleEntry {
left: Bytes<32>;
right: Bytes<32>;
}
enum AccessLevel { NONE, READ, WRITE, ADMIN }
Templates → circuits
Circom templates are parametric blueprints you instantiate into components. Compact circuits work the same way conceptually, but with a distinction Circom doesn't have: they're either pure or impure.
A pure circuit takes inputs, runs computation, returns outputs — no side effects, no ledger access, no witness calls. It's the direct equivalent of a Circom template used as a stateless function:
export pure circuit hashPair(left: Bytes<32>, right: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([left, right]);
}
An impure circuit can read from and write to the on-chain ledger, and call witnesses:
export circuit castVote(choice: Bytes<32>): [] {
assert(state == VoteState.OPEN, "Voting is closed");
const voter = publicKey(localSecretKey(), epoch as Field as Bytes<32>);
votes.insert(disclose(voter), disclose(choice));
}
The export modifier makes a circuit callable from outside the contract. Your DApp TypeScript calls exported circuits to construct transactions. Unexported circuits are internal helpers — like non-main templates in Circom.
Generic circuits work too, with type and numeric parameters:
export pure circuit hashVector<T, #N>(values: Vector<N, T>): Bytes<32> {
return persistentHash<Vector<N, T>>(values);
}
Components → circuit calls and witnesses
In Circom, a component instantiates a sub-template and wires its signals into your circuit. That's how you compose logic — building a constraint graph one node at a time.
In Compact, calling another circuit is just a function call:
export pure circuit hashPair(a: Bytes<32>, b: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([a, b]);
}
export pure circuit hashTriple(a: Bytes<32>, b: Bytes<32>, c: Bytes<32>): Bytes<32> {
const ab = hashPair(a, b);
return hashPair(ab, c);
}
No wiring. No signal arrays. No component instantiation syntax.
But there's a second kind of external input in Compact that Circom has no parallel for: witnesses.
A witness is a TypeScript or JavaScript function that runs off-chain, inside the user's DApp, and supplies private data into the circuit during proof generation. It's how private state enters a Compact contract — the circuit declares what it needs, and the witness provides it without putting anything in any public record.
// Compact side: declare the witness signature
witness localSecretKey(): Bytes<32>;
export circuit authenticate(): [] {
const sk = localSecretKey(); // private, called during proof generation
const pk = publicKey(sk, nonce as Field as Bytes<32>);
assert(owner == pk, "Not the registered owner");
}
// TypeScript side: implement the witness in your DApp
export const witnesses = {
localSecretKey: ({
privateState,
}: WitnessContext<Ledger, PrivateState>): [PrivateState, Uint8Array] => {
return [privateState, privateState.secretKey];
},
};
Private keys, secret notes, and local state all live in witnesses. When you ask "where does private input come from in Compact?" — it comes from here.
R1CS constraints → assert()
Circom's === operator assigns a value and adds an R1CS constraint simultaneously. <== and ==> do the same in one direction. The circuit only produces a valid proof if all constraints are satisfied.
Compact uses assert() for the same job:
// Circom
signal input a;
signal input b;
signal output c;
c <== a * b;
a * b === c;
// Compact
export pure circuit multiply(a: Field, b: Field): Field {
const c = a * b;
assert(c != 0, "Product must be non-zero");
return c;
}
Same semantics — a failing assertion means no valid proof. The difference is that assert() reads like normal application code, which makes auditing circuit logic much less painful.
disclose() — the concept Circom doesn't have
This one has no Circom equivalent, and it will confuse you until it clicks.
In Compact, all data flowing into or from witnesses is treated as potentially private by default. The compiler tracks the "taint" of that data through your entire circuit. If any potentially-private value is about to be stored in the public ledger, returned from an exported circuit, or passed to another contract, you must explicitly wrap it in disclose(). This applies to witness-derived values and — perhaps surprisingly — to exported circuit parameters too, since the compiler can't statically guarantee their origin.
Think of it as a compiler-enforced consent form. Before private information goes public, you have to say so:
witness localSecretKey(): Bytes<32>;
export circuit register(): [] {
const sk = localSecretKey(); // private
const pk = publicKey(sk, nonce); // still private (derived from sk)
// This fails — storing private-derived data without disclosing:
// owner = pk;
// This is correct — explicitly declaring that pk goes on-chain:
owner = disclose(pk);
}
You'll see disclose() everywhere in Compact code. It's not boilerplate — it's a design choice that catches accidental data leaks at compile time, before a circuit ever runs.
The ledger: what Circom doesn't have
The biggest structural difference between Circom and Compact is that Compact has on-chain state.
In a Circom-based system, your ZK circuit is stateless. It takes inputs, produces a proof, and your Solidity contract handles the rest: storing commitments, enforcing access control, tracking balances. The circuit and the state machine are separate things you wire together yourself.
In Compact, they live in the same file.
The ledger is Compact's on-chain state machine. It holds values that persist across every transaction:
export ledger state: VoteState;
export ledger owner: Bytes<32>;
export ledger totalVotes: Counter;
export ledger balances: Map<Bytes<32>, Uint<64>>;
export ledger approvedSet: Set<Bytes<32>>;
Ledger values are public — everyone can read them. Privacy comes from keeping inputs private (in witnesses) and using ZK proofs to verify computations without revealing those inputs.
Ledger types go well beyond primitives:
| Ledger type | What it does |
|---|---|
Counter |
Increment/decrement with overflow protection |
Cell<T> |
Wraps any regular type with read/write/reset |
Map<K, V> |
Key-value store, useful for allowlists or balances |
Set<T> |
Unordered membership collection |
MerkleTree<n, T> |
On-chain Merkle tree with depth n |
HistoricMerkleTree<n, T> |
Versioned Merkle tree for past root verification |
The constructor initializes ledger state at deployment:
constructor() {
state = VoteState.PENDING;
totalVotes.increment(1);
}
ZK proof logic and state machine in one language. The mental model shifts from "write a circuit and bolt it onto a contract" to "write a contract where privacy is built in from the start."
Side-by-side: range proof
A range proof shows that a private value falls within a given range without revealing the value itself. Good place to start.
In Circom:
pragma circom 2.0.0;
include "circomlib/circuits/comparators.circom";
template AgeRangeProof(bits) {
signal input age; // private input
signal output valid;
component upperBound = LessThan(bits);
upperBound.in[0] <== age;
upperBound.in[1] <== 120;
component lowerBound = GreaterEqThan(bits);
lowerBound.in[0] <== age;
lowerBound.in[1] <== 18;
// Both conditions must hold
valid <== upperBound.out * lowerBound.out;
}
component main = AgeRangeProof(8);
In Compact:
pragma language_version >= 0.22;
witness privateAge(): Uint<8>;
export circuit proveAgeRange(): [] {
const age = privateAge();
assert(age >= 18, "Must be at least 18 years old");
assert(age < 120, "Age exceeds expected maximum");
}
Uint<8> already constrains age to 0-255 by the type, so no bit decomposition needed. The LessThan and GreaterEqThan component wiring becomes two assert() comparisons. The private input comes through a witness instead of a signal input. There's no output signal to wire up — a failing assert means no valid proof, which is the constraint.
Side-by-side: Merkle membership proof
Merkle membership proofs show up everywhere in ZK systems: prove a private leaf exists in a public tree without revealing which leaf.
In Circom:
pragma circom 2.0.0;
include "circomlib/circuits/poseidon.circom";
include "circomlib/circuits/mux1.circom";
template MerkleProof(depth) {
signal input leaf; // private
signal input root; // public
signal input pathElements[depth]; // private sibling hashes
signal input pathIndices[depth]; // 0=left, 1=right
component hashers[depth];
component selectors[depth];
signal levelHash[depth + 1];
levelHash[0] <== leaf;
for (var i = 0; i < depth; i++) {
selectors[i] = MultiMux1(2);
selectors[i].c[0][0] <== levelHash[i];
selectors[i].c[0][1] <== pathElements[i];
selectors[i].c[1][0] <== pathElements[i];
selectors[i].c[1][1] <== levelHash[i];
selectors[i].s <== pathIndices[i];
hashers[i] = Poseidon(2);
hashers[i].inputs[0] <== selectors[i].out[0];
hashers[i].inputs[1] <== selectors[i].out[1];
levelHash[i + 1] <== hashers[i].out;
}
root === levelHash[depth];
}
component main { public [root] } = MerkleProof(20);
In Compact:
pragma language_version >= 0.22;
import CompactStandardLibrary;
// The Merkle root lives on-chain, publicly visible
export ledger treeRoot: Field;
// The full membership path stays private — provided off-chain by the witness
witness getMembershipPath(): MerkleTreePath<20, Bytes<32>>;
// Prove a private leaf exists in the committed tree
export circuit proveMembership(): [] {
const path = getMembershipPath();
const computed = merkleTreePathRoot<20, Bytes<32>>(path);
assert(
computed.field == treeRoot,
"Proof failed: leaf is not in the committed Merkle tree"
);
}
// Update the committed root (admin operation)
// disclose() required: exported circuit params are treated as
// potentially witness-tainted when writing to ledger
export circuit updateRoot(newRoot: Field): [] {
treeRoot = disclose(newRoot);
}
Off-chain witness in TypeScript:
type PrivateState = {
leaf: Uint8Array;
path: Array<{
sibling: { field: bigint };
goesLeft: boolean;
}>;
};
export const witnesses = {
getMembershipPath: ({
privateState,
}: WitnessContext<Ledger, PrivateState>): [PrivateState, MerkleTreePath] => {
return [
privateState,
{
leaf: privateState.leaf,
path: privateState.path,
},
];
},
};
The Circom version is roughly 40 lines of wiring: manual left/right selection at each level, explicit loop over depth, individual component instantiation per level. The Compact version is 8 lines of contract logic. merkleTreePathRoot handles path traversal and hashing internally — it's a standard library circuit, not something you wire yourself.
In Circom, private inputs are individual signals: pathElements[depth], pathIndices[depth]. In Compact, they're a single typed struct (MerkleTreePath<20, Bytes<32>>) with sibling hashes and direction flags bundled together, supplied entirely by the TypeScript witness.
The MerkleTreePath never appears in any on-chain data. It exists only during proof generation. The only thing that touches the ledger is the root, explicitly stored via disclose(newRoot).
Common pitfalls for Circom developers
1. Forgetting disclose() when storing to ledger
The compiler error looks like:
potential witness-value disclosure must be declared but is not
The rule is wider than most people expect: any value stored in the ledger from an exported circuit requires disclose() — not just witness data. This includes plain circuit parameters. The compiler treats exported circuit arguments as potentially witness-tainted because, in the ZK proof model, their origin can't be statically guaranteed. The fix is straightforward: wrap the value at the point of ledger assignment.
// Fails — even a plain circuit parameter needs disclose() before hitting the ledger
export circuit updateRoot(newRoot: Field): [] {
treeRoot = newRoot; // Error: potential witness-value disclosure
}
// Correct
export circuit updateRoot(newRoot: Field): [] {
treeRoot = disclose(newRoot);
}
Put disclose() as close to the disclosure point as possible — not wrapped around entire expression chains.
2. Using transientHash for ledger-stored commitments
Compact has two hash functions: transientHash (optimized for circuit performance, outputs Field) and persistentHash (SHA-256 based, outputs Bytes<32>). For any value stored in the ledger, use persistentHash. If you use transientHash for a commitment and the contract gets upgraded, the hash output may change and invalidate all existing proofs against old commitments. persistentHash is stable across contract upgrades by design.
3. Expecting Circom's loop patterns to transfer directly
Compact loops follow the same bounded-compile-time rule Circom does, but the syntax is different and the type system enforces it more aggressively:
// Valid: bounded by a constant
for (const i of 0..10) { ... }
// Valid: bounded by vector size
for (const elem of myVector) { ... }
// Invalid: no dynamic upper bounds, no recursion
No recursion in Compact. Every circuit must have a provably finite execution path, and the compiler won't let you accidentally create one that doesn't.
4. Treating witnesses like Circom components
Witnesses are TypeScript. They run outside the circuit entirely. You can't add constraints inside a witness, and the Compact docs specifically note that you should not assume the witness executes your code exactly as written. Constraints belong in assert() inside circuits — not in witness logic.
5. Forgetting pure on utility circuits
If a circuit doesn't access the ledger or call witnesses, mark it pure. It's not just a style preference — pure circuits can be called in more contexts (including from other pure circuits) and make it immediately obvious when something unexpectedly touches state. An accidental ledger access in a utility circuit is much easier to catch as a compiler error than as a runtime bug.
6. Assuming R1CS tooling transfers
Groth16 ceremonies, snarkjs scripts, R1CS export workflows — none of it applies in Compact. Compact compiles to ZKIR. You'll use the Midnight SDK toolchain for compilation, proof generation, and DApp integration. Plan for that as a separate learning track, not an afternoon of config changes.
What carries over
A lot, actually.
The constraint-first mindset — "if this condition fails, there's no valid proof" — maps directly onto assert(). Private inputs never leaving the circuit becomes witness isolation. Value commitments become persistentHash plus persistentCommit. Your understanding of Merkle proofs, nullifiers, and commitment schemes applies directly — the standard library has all of those primitives.
The scope is what changes more than the syntax. Circom asked you to think about one circuit. Compact asks you to think about a full contract: state machine, multiple entry points, privacy logic woven through all of it. Once that framing settles in, the translation starts feeling less like learning a new language and more like writing familiar logic in a bigger room.
Quick reference
| You want to... | In Circom | In Compact |
|---|---|---|
| Define a reusable circuit | template Foo() { } |
circuit foo(): T { } |
| Expose a circuit to callers | component main = Foo() |
export circuit foo(): T { } |
| Add a constraint | a === b |
assert(a == b, "msg") |
| Hash two values |
Poseidon(2) component |
persistentHash<Vector<2, T>>([a, b]) |
| Supply private data | signal input x |
witness getX(): T |
| Store on-chain state | (Solidity contract) | ledger val: T |
| Declare privacy intent | (not required) | disclose(value) |
| Verify Merkle membership | manual component wiring | merkleTreePathRoot<n, T>(path) |
Getting started
- Compact Language Reference — full syntax spec
- Compact Deep Dive Part 1 — contract structure walkthrough
- Compact Deep Dive Part 2 — circuits and witnesses in depth
- Bulletin Board Tutorial — the reference contract that uses every concept above in one small file
- Standard Library Exports — all built-in types and circuit signatures
If you're coming from Circom, read the bulletin board contract first (bboard.compact). It's small, uses a witness, a persistent hash commitment, a ledger state machine, and disclose() all together. Everything in this guide shows up there.
All Compact examples in this article were compiled and verified against Compact compiler v0.31.0. Check the compiler release notes for the current version before you start.




![[Tutorial] Reading and Reacting to Contract State from a Frontend on Midnight network](https://media2.dev.to/dynamic/image/width=1200,height=627,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq3e9mrk8esgkgsuymvsq.png)







