DEV Community

Tosh
Tosh

Posted on

Witnesses in Depth: Patterns, Types, and Real Use Cases in Compact

Witnesses in Depth: Patterns, Types, and Real Use Cases in Compact

Every Compact circuit has two kinds of inputs: things the verifier can see, and things only the prover knows. The second category — private inputs the prover supplies at proof generation time — are called witnesses.

Witnesses are where most of the interesting (and dangerous) logic in Midnight contracts lives. They're also where most of the confusion comes from, especially for developers coming from Solidity or other ZK frameworks. This article walks through what witnesses actually are, how they differ from circuit logic, and the patterns that come up repeatedly in real Midnight dApps.


What a Witness Actually Is

In a ZK proof system, the prover wants to convince a verifier that they know some secret without revealing the secret. The witness is that secret — or more precisely, any value the prover supplies as a private input to the circuit.

In Compact, you declare witnesses in your circuit signature with the witness keyword:

circuit verifyOwnership(
  witness secretKey: Uint<256>,
  publicKeyHash: Bytes<32>
) -> Boolean {
  const derivedHash: Bytes<32> = hash(secretKey);
  return derivedHash == publicKeyHash;
}
Enter fullscreen mode Exit fullscreen mode

Here, secretKey is a witness. The prover supplies it when generating the proof, but it never appears in the proof itself — only the assertion "I know a secretKey such that hash(secretKey) == publicKeyHash" is visible to the verifier.

publicKeyHash is a public input — it's passed in from the transaction and visible to anyone who inspects the proof.

The distinction matters immediately when you think about what to put where. Anything that should stay private goes in as a witness. Anything the contract logic needs to inspect openly — amounts, deadlines, recipient addresses — is a regular parameter.


Witnesses vs Circuit Logic

The cleaner way to think about this: circuit logic is what you're proving, and witnesses are the private inputs you need to prove it.

Consider a simple spending circuit. You want to prove you own a coin without revealing your private key:

circuit spendCoin(
  witness ownerSecretKey: Uint<256>,
  coinCommitment: Bytes<32>,
  recipient: ZswapCoinPublicKey,
  amount: Uint<64>
) -> Boolean {
  // Witness-derived: prove key matches the coin's owner
  const keyHash: Bytes<32> = hash(ownerSecretKey);
  assert keyHash == ledger.ownerKeyHash : "not the coin owner";

  // Circuit logic: enforce spending rules
  assert amount <= ledger.balance : "insufficient balance";
  assert recipient != ZswapCoinPublicKey.zero() : "invalid recipient";

  return true;
}
Enter fullscreen mode Exit fullscreen mode

ownerSecretKey is a witness — it's private, supplied by the prover, and used only to derive a hash for comparison. The hash comparison and the spending rules (amount <= balance, valid recipient) are circuit logic — they're constraints that the ZK proof system enforces over all inputs, public and private.

The key insight: witnesses can affect circuit constraints, but they can't bypass them. A witness value that doesn't satisfy the circuit's assertions will cause proof generation to fail. The circuit is the law; witnesses are just private inputs to it.


Pattern 1: Secret Key Verification

This is the most common witness pattern in Midnight — proving you know a secret key that corresponds to some public commitment.

contract SecretVault {
  ledger ownerKeyCommitment: Bytes<32>;
  ledger balance: Uint<64>;

  circuit initialize(
    ownerPublicKeyHash: Bytes<32>,
    initialBalance: Uint<64>
  ): [] {
    ledger.ownerKeyCommitment = ownerPublicKeyHash;
    ledger.balance = initialBalance;
  }

  circuit withdraw(
    witness ownerSecretKey: Uint<256>,
    withdrawAmount: Uint<64>,
    recipient: ZswapCoinPublicKey
  ): [] {
    // Verify the witness matches the stored commitment
    const derivedCommitment: Bytes<32> = hash(ownerSecretKey);
    assert derivedCommitment == ledger.ownerKeyCommitment : "invalid owner key";

    // Enforce withdrawal rules
    assert withdrawAmount <= ledger.balance : "insufficient funds";
    assert withdrawAmount > 0 : "zero withdrawal";

    ledger.balance = ledger.balance - withdrawAmount;
    sendShielded(recipient, ledger.heldCoin, withdrawAmount);
  }
}
Enter fullscreen mode Exit fullscreen mode

A few things to notice:

The key itself never touches the ledger or the proof. Only ownerKeyCommitment — the hash — is stored. The witness ownerSecretKey is used in proof generation, proves the assertion is satisfiable, and then disappears. A verifier sees "the prover knows a key whose hash is 0xabc123" — they never see the key.

The commitment is set at initialization. This is important: you can't change it without knowing the current secret key. An attacker who only knows the hash can't change the stored commitment to their own key.

This is not the same as ownPublicKey(). Compact's built-in ownPublicKey() is an unconstrained witness — the prover can supply any value for it. The pattern above creates an explicitly constrained witness by tying it to a stored hash. This is the secure pattern. The ownPublicKey() shortcut is not.


Pattern 2: Division with Remainder

Division in ZK circuits is more subtle than in regular code. You can't just divide — you have to prove the division is correct.

The naive approach:

circuit distribute(
  witness totalTokens: Uint<64>,
  witness numRecipients: Uint<32>
) -> Uint<64> {
  assert numRecipients > 0 : "no recipients";
  return totalTokens / numRecipients;
}
Enter fullscreen mode Exit fullscreen mode

This works, but drops the remainder silently. If 100 tokens split among 3 recipients, one token disappears. For anything financial, that's a bug — or a way to drain value from a contract.

The correct witness-based pattern makes the remainder explicit:

circuit distributeWithRemainder(
  witness totalTokens: Uint<64>,
  witness numRecipients: Uint<32>,
  witness quotient: Uint<64>,
  witness remainder: Uint<32>
) -> (Uint<64>, Uint<32>) {
  assert numRecipients > 0 : "no recipients";

  // Prove the division is correct by verifying multiplication
  // This is the ZK-idiomatic way: prove X/Y=Q with remainder R
  // by asserting Q*Y + R = X and 0 <= R < Y
  assert quotient * numRecipients + remainder == totalTokens : "division incorrect";
  assert remainder < numRecipients : "remainder must be less than divisor";

  return (quotient, remainder);
}
Enter fullscreen mode Exit fullscreen mode

Here, quotient and remainder are also witnesses. The prover computes the division off-chain (where it's cheap and fast) and provides the result as witnesses. The circuit then verifies the result is correct using multiplication — which is cheap in ZK — rather than computing division inside the circuit.

This is the core witness pattern in ZK programming: do expensive computation off-chain as a witness, verify the result cheaply in the circuit.

The caller on the TypeScript side computes the division:

const witnesses = {
  distributeWithRemainder: async (
    totalTokens: bigint,
    numRecipients: bigint
  ) => {
    const quotient = totalTokens / numRecipients;
    const remainder = totalTokens % numRecipients;
    return { quotient, remainder };
  }
};
Enter fullscreen mode Exit fullscreen mode

The circuit doesn't trust these values — it verifies them. If the prover provides wrong values, the assertion fails and proof generation fails. The witness is a hint, not a trusted input.


Pattern 3: External Data Ingestion

Witnesses don't have to be derived from other ledger state. They can bring external data into a proof — price feeds, off-chain signatures, values from outside the blockchain.

contract ConditionalRelease {
  ledger escrowAmount: Uint<64>;
  ledger releaseThreshold: Uint<64>;
  ledger priceOracleKey: Bytes<32>;

  circuit releaseIfPriceAbove(
    witness currentPrice: Uint<64>,
    witness oracleSignature: Bytes<64>,
    recipient: ZswapCoinPublicKey
  ): [] {
    // Verify the oracle signed this price
    const signatureValid: Boolean = verifySignature(
      priceOracleKey,
      encodeUint64(currentPrice),
      oracleSignature
    );
    assert signatureValid : "invalid oracle signature";

    // Use the verified external data in circuit logic
    assert currentPrice >= ledger.releaseThreshold : "price below threshold";
    assert ledger.escrowAmount > 0 : "nothing to release";

    sendShielded(recipient, ledger.heldCoin, ledger.escrowAmount);
    ledger.escrowAmount = 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

The currentPrice and oracleSignature are witnesses — they come from outside the contract and are provided at proof generation time. The circuit doesn't trust them directly; it verifies the oracle's signature before using the price value in any constraint.

This pattern is how you bring off-chain state into Midnight contracts without giving up the ZK security model. The data is private (only the prover supplies it), but the proof guarantees it was signed by the authorized oracle.

One important note: the priceOracleKey is stored in the ledger, which means it's publicly visible. Anyone can see which oracle is authorized. Only the price data itself — supplied as a witness each time — is private.


Pattern 4: Witness-Based Access Control

Access control in Midnight works differently from Solidity. You can't check msg.sender == owner because Midnight doesn't have the same notion of a transaction sender — identities are cryptographic, and the proof system doesn't directly expose them.

The witness-based approach:

contract AdminPanel {
  ledger adminCommitments: Set<Bytes<32>>;
  ledger config: Bytes<256>;

  circuit updateConfig(
    witness adminSecretKey: Uint<256>,
    newConfig: Bytes<256>
  ): [] {
    // Derive the admin's identity commitment from their secret key
    const adminIdentity: Bytes<32> = hash(adminSecretKey);

    // Check that this identity is in the authorized set
    assert ledger.adminCommitments.contains(adminIdentity) : "not an admin";

    ledger.config = newConfig;
  }

  circuit addAdmin(
    witness callerSecretKey: Uint<256>,
    newAdminCommitment: Bytes<32>
  ): [] {
    // Only existing admins can add new admins
    const callerIdentity: Bytes<32> = hash(callerSecretKey);
    assert ledger.adminCommitments.contains(callerIdentity) : "not an admin";

    ledger.adminCommitments = ledger.adminCommitments.insert(newAdminCommitment);
  }
}
Enter fullscreen mode Exit fullscreen mode

The adminSecretKey witness proves admin status without revealing the actual key. The adminCommitments set on the ledger is public — everyone can see which commitment hashes are authorized — but the keys themselves never appear on-chain.

This gives you a useful property: you can rotate admin keys by adding a new commitment (with the old key) and removing the old one, without ever exposing the keys.

What to watch for: The adminCommitments set is publicly enumerable. Anyone can see how many admins exist. If that's a privacy concern, you can use a Merkle root instead — store a root hash that commits to the full admin set, and prove membership with a Merkle proof supplied as a witness.


Real Use Cases in Midnight dApps

These patterns combine in production contracts. A few examples from real Midnight dApps:

Private voting: Each voter supplies their secret voting key as a witness, proving they're eligible to vote (via a commitment stored in a voter registry) without revealing their identity. Their vote choice is also a witness — the circuit accumulates vote tallies without storing individual choices.

Token vesting: The beneficiary proves they know the vesting schedule's secret parameters (locked at contract creation, stored as a hash) and supplies the current timestamp as an externally verified witness to unlock tokens on schedule.

Private loan repayment: The borrower proves they control a coin with sufficient value (secret key witness) and that the loan terms are satisfied (amount, interest computed off-chain as witnesses, verified by the circuit).

In each case, the pattern is the same: sensitive data arrives as witnesses, circuit constraints verify the witnesses are valid, and the proof carries the guarantee forward without revealing the inputs.


Common Mistakes

Witness values aren't constrained by default. If you declare witness someValue: Uint<64> and never use it in an assertion, the prover can supply anything. Always assert the properties you care about.

Don't confuse witness size with circuit constraints. A Uint<256> witness takes 256 bits of private input. That's fine. What matters for proof complexity is how many constraints use that value — each operation (hash, comparison, arithmetic) adds constraints.

Off-chain computation must match circuit expectations. The TypeScript witness function computes a value; the Compact circuit verifies it. If your TypeScript uses JavaScript's floating-point division and your Compact circuit expects integer division, you'll get a proof failure with no obvious error message. Keep the arithmetic consistent between both sides.

Witness computation failures surface as generic errors. ProofGenerationError: witness computation failed usually means the TypeScript witness function threw an exception or returned an unexpected type. Add logging to your witness functions during development — they run in a context where console output is visible.


Summary

Witnesses are the mechanism that makes ZK privacy possible in Midnight. They let you provide secret inputs to a circuit — keys, values, external data — without those inputs appearing in the proof or on-chain. The circuit enforces that the witnesses satisfy the rules; it doesn't trust them.

The patterns that come up most often:

  • Secret key verification: prove knowledge of a key that hashes to a stored commitment
  • Division with remainder: compute off-chain, verify the result in-circuit using multiplication
  • External data ingestion: bring in oracle data with a signature the circuit can verify
  • Access control: tie authorization to a set of identity commitments, not to a public address

The rule of thumb for placing logic: if the data should be private, it's a witness. If the rule must be enforced publicly, it's a circuit constraint. Witnesses and constraints work together — one provides the private inputs, the other verifies they're valid.


Want to go deeper? The Midnight docs cover the Compact reference and SDK. The contributor hub has more bounty issues if you want to contribute and get paid in NIGHT tokens.

Top comments (0)