DEV Community

Tosh
Tosh

Posted on

Thinking in Compact: A Guide for Circom Developers

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

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

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

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

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

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

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

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

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

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)