DEV Community

Cover image for Thinking in Compact: A guide for Circom developers
Harrie
Harrie

Posted on • Edited on

Thinking in Compact: A guide for Circom developers

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]);
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode
// TypeScript side: implement the witness in your DApp
export const witnesses = {
  localSecretKey: ({
    privateState,
  }: WitnessContext<Ledger, PrivateState>): [PrivateState, Uint8Array] => {
    return [privateState, privateState.secretKey];
  },
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
// Compact
export pure circuit multiply(a: Field, b: Field): Field {
  const c = a * b;
  assert(c != 0, "Product must be non-zero");
  return c;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>>;
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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,
      },
    ];
  },
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

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.

Top comments (0)