DEV Community

Ademola Thompson
Ademola Thompson

Posted on

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

ZK circuits require every computation inside them to be expressible as arithmetic over a finite field. This means that ZK circuits can only prove computations that reduce to addition and multiplication.
Division, conditionals on private values, and anything involving external data cannot be expressed that way. You can't read from local storage or fetch a user's private key from their device by using a circuit.

But any useful DApp needs to do these things. For example, a game needs to divide a player's score by the number of rounds, and an access-controlled contract needs to check the caller's identity against a stored secret.

Midnight handles this by using witnesses. Witnesses let you run arbitrary TypeScript off-chain, feed the result into a circuit as an input, and then have the circuit verify that result using ZK constraints, without the off-chain computation itself being part of the proof. So you can perform your divisions off-chain and verify their correctness on-chain

Prerequisites

Before continuing, you should have:

  • The Compact toolchain installed and working
  • A basic understanding of Compact syntax — circuits, ledger declarations, and types
  • Familiarity with TypeScript, since witness implementations live there

What is a witness?

A witness is a function you declare in Compact but implement in TypeScript. Your Compact circuit calls it, but it does not see inside of it. It only gets to see the output. Therefore, what gets proven is not what the witness computed, but that its output (return value) satisfies the constraints the circuit put on it.

This means:

  • You declare a witness in Compact with a name and type signature, and no body
  • You implement it in TypeScript in the witness object you pass to the contract
  • At runtime, when the circuit calls the witness, the TypeScript implementation executes off-chain, and the return value flows back into the circuit

Here is what the most basic witness declaration in Compact will look like:

witness secretKey(): Bytes<32>;
Enter fullscreen mode Exit fullscreen mode

With that simple declaration, the compiler knows the function exists and knows its return type. Any circuit in the contract can call it. What it actually does at runtime is determined entirely by the TypeScript implementation.

This is what a TypeScript implementation of the secretKey() witness might look like:

// witnesses.ts file

import { WitnessContext } from "@midnight-ntwrk/compact-runtime";
import { Ledger } from "./managed/<myApp>/contract/index.js"; // update your path

export const witnesses = {
  secretKey: ({
    privateState,
  }: WitnessContext<Ledger, PrivateState>): [PrivateState, Uint8Array] => [
    privateState,
    privateState.secretKey,
  ],
};
Enter fullscreen mode Exit fullscreen mode

In the snippet above, the function receives a WitnessContext object typed over two generic parameters:

  • Ledger: the shape of the projected on-chain state
  • PrivateState: the shape of the user's local private data for this contract

It destructures privateState from it and returns a tuple containing the updated private state first, then the actual value the circuit receives.

NOTE: WitnessContext exposes three fields — ledger, privateState, and contractAddress. This witness only destructures privateState because it does not need to read the on-chain state or the contract address to do its job.

The private state threading matters. Witnesses are the only mechanism for reading and mutating the private state during circuit execution. The private state itself never touches the ledger.

NOTE: Your witnesses.ts file should live in the same directory as your .compact file. The managed/ folder you see in the import path is generated by the compiler into that same directory. Do not edit it manually. Your witnesses.ts sits alongside your .compact file, and imports compiled types from the managed/ subfolder next to it.

How witnesses are different from circuit logic in Compact

When you write a circuit, every operation in it becomes part of the ZK proof. The prover has to demonstrate that each step happened correctly, without revealing private inputs. That is what makes circuits trustworthy. It is also what makes them expensive. You cannot do arbitrary computation inside a circuit. You cannot perform operations like reading files, calling APIs, or division.

Witnesses, on the other hand, have no such constraint. They run before the proof is generated, outside it entirely. Their output enters the circuit as an input, and the circuit's job is to verify that input, not to reproduce the computation that generated it. If a computation is cheap and arbitrary, it belongs in a witness. If it needs to be proven correct, it belongs in a circuit.

Here is what this looks like in practice:

Compact code:

witness getAge(): Uint<8>;

export circuit verifyAdult(): [] {
  const age = getAge();
  assert(disclose(age >= 18), "Must be 18 or older");
}
Enter fullscreen mode Exit fullscreen mode

TypeScript implementation:

export const witnesses = {
  getAge: ({
    privateState,
  }: WitnessContext<Ledger, PrivateState>): [PrivateState, bigint] => [
    privateState,
    privateState.age,
  ],
};
Enter fullscreen mode Exit fullscreen mode

getAge() runs off-chain and returns the user's age from private state. The circuit never proves how that value was obtained. It only proves that the value satisfies the condition:

assert(disclose(age >= 18), "Must be 18 or older");
Enter fullscreen mode Exit fullscreen mode

In the snippet above, disclose(age >= 18) wraps the comparison rather than the age because
disclose() controls what leaves the private domain. Wrapping age directly would put the raw number on-chain. However, wrapping the comparison puts only the boolean result on-chain. The circuit can prove the condition is true without the actual age ever leaving the private state.

One consequence of using witnesses is that the compiler treats every value that comes from a witness as potentially private. Any value derived from a witness return — through arithmetic, type conversion, or logic — inherits the private status. If such data reaches a public ledger field or an exported circuit's return value without an explicit disclose() call, the compiler will refuse to build.

Common ways to use witnesses in Compact

You will come across multiple scenarios where you will need to use witnesses in Compact. This section lists some of the popular ways to use witnesses when writing code for the Midnight chain.

1. Private identities such as secret keys

The most common witness in Midnight contracts retrieves a user's private key from local storage. The secret key never touches the public ledger. Only its hash is public.

Here's an example Compact contract:

pragma language_version >= 0.22;
import CompactStandardLibrary;

witness secretKey(): Bytes<32>; //witness declaration

ledger owner: Bytes<32>;

circuit publicKey(_sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([
    pad(32, "myapp:auth:pk:"),
    _sk
  ]);
}

constructor() {
  const _sk = secretKey();
  owner = disclose(publicKey(_sk));
}
Enter fullscreen mode Exit fullscreen mode

In the snippet above, the constructor runs once at deployment. It calls the secretKey() witness to retrieve the deployer's private key off-chain, derives a public key from it using persistentHash, and stores only the hash in the owner ledger field.

After deployment, the secret key is gone from the execution context. What remains on-chain is only its cryptographic commitment.

In TypeScript, you will implement your witness like this:

import type { WitnessContext } from "@midnight-ntwrk/compact-runtime";
import { Ledger } from "./managed/<myApp>/contract/index.js"; // update your path

type PrivateState = {
  secretKey: Uint8Array;
};

export const witnesses = {
  secretKey: ({
    privateState,
  }: WitnessContext<Ledger, PrivateState>): [PrivateState, Uint8Array] => [
    privateState, // private state is unchanged
    privateState.secretKey, // this is what the circuit receives
  ],
};
Enter fullscreen mode Exit fullscreen mode

The TypeScript implementation simply pulls the secret key out of private state and returns it. What the circuit does with it — hashing, storing — is none of the witness's concern.

2. Witness-verified division

Circuits have no native division operator. The field arithmetic that underpins ZK proofs does not support it directly. If you need to divide two numbers inside a circuit, you must use a witness to compute the result off-chain and then prove that the result is correct inside the circuit.

This is an example from a real Midnight game contract:

pragma language_version >= 0.22;

witness _divMod(x: Uint<32>, y: Uint<32>): [Uint<32>, Uint<32>];

export circuit div(x: Uint<32>, y: Uint<32>): Uint<32> {
  const res = disclose(_divMod(x, y));
  const quotient = res[0];
  const remainder = res[1];
  assert(remainder < y && x == y * quotient + remainder, "Invalid divMod witness impl");
  return quotient;
}

export circuit mod(x: Uint<32>, y: Uint<32>): Uint<32> {
  const res = disclose(_divMod(x, y));
  const quotient = res[0];
  const remainder = res[1];
  assert(remainder < y && x == y * quotient + remainder, "Invalid divMod witness impl");
  return remainder;
}
Enter fullscreen mode Exit fullscreen mode

In the code above, the circuits do not blindly trust the witness. They verify the fundamental identity of division: x == y * quotient + remainder, and check that the remainder is strictly less than the divisor. If an attacker provides a malicious witness implementation that returns incorrect values, the assert fails, and the transaction reverts.

In TypeScript, this is what the implementation of the _divMod witness looks like:

export const witnesses = {
  _divMod: (
    context: WitnessContext<Ledger, Game2PrivateState>,
    x: bigint,
    y: bigint,
  ): [Game2PrivateState, [bigint, bigint]] => {
    const xn = Number(x);
    const yn = Number(y);
    const remainder = xn % yn;
    const quotient = Math.floor(xn / yn);
    console.log(
      `dyn witness _divMod(${x}, ${y}) = [${quotient}, ${remainder}]`,
    );
    return [context.privateState, [BigInt(quotient), BigInt(remainder)]];
  },
};
Enter fullscreen mode Exit fullscreen mode

Overall, TypeScript performs the cheap arithmetic, and the circuit proves that the result is correct.

3. Managing private state with witnesses

Some witnesses do not return a single computed value. Instead, they manage a local state machine that mirrors the contract's on-chain state. Here is an example:

pragma language_version >= 0.22;
import CompactStandardLibrary;

witness localSecretKey(): Bytes<32>;
witness localState(): LocalState;
witness localAdvanceState(): [];
witness localRecordVote(vote: Boolean): [];
witness localVoteCast(): Maybe<Boolean>;

enum LocalState { initial, committed, revealed }

export ledger committedVotes: MerkleTree<10, Bytes<32>>;
export ledger committed: Set<Bytes<32>>;

circuit commitmentNullifier(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "nullifier:"), sk]);
}

circuit commitWithSk(ballot: Bytes<32>, sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([ballot, sk]);
}

export circuit voteCommit(ballot: Boolean): [] {
  assert(localState() == LocalState.initial, "Already committed");
  localRecordVote(ballot);
  const sk = disclose(localSecretKey());
  const nullifier = commitmentNullifier(sk);
  assert(!committed.member(nullifier), "Already used this identity");
  const cm = commitWithSk(ballot ? pad(32, "yes") : pad(32, "no"), sk);
  committedVotes.insert(disclose(cm));
  committed.insert(disclose(nullifier));
  localAdvanceState();
}
Enter fullscreen mode Exit fullscreen mode

Notice that localAdvanceState() and localRecordVote() return []. They have no return value to the circuit. Their entire job is to update private state as a side effect. The Compact side declares that these functions exist; the TypeScript side is where the actual state transition happens.

The TypeScript implementation will look like this:

type PrivateState = {
  secretKey: Uint8Array;
  localState: "initial" | "committed" | "revealed";
  localVote: boolean | null;
};

export const witnesses = {
  localSecretKey: ({
    privateState,
  }: WitnessContext<Ledger, PrivateState>): [PrivateState, Uint8Array] => [
    privateState,
    privateState.secretKey,
  ],

  localState: ({
    privateState,
  }: WitnessContext<Ledger, PrivateState>): [PrivateState, string] => [
    privateState,
    privateState.localState,
  ],

  localAdvanceState: ({
    privateState,
  }: WitnessContext<Ledger, PrivateState>): [PrivateState, []] => {
    const next =
      privateState.localState === "initial"
        ? "committed"
        : privateState.localState === "committed"
          ? "revealed"
          : privateState.localState;
    return [{ ...privateState, localState: next }, []];
  },

  localRecordVote: (
    { privateState }: WitnessContext<Ledger, PrivateState>,
    vote: boolean,
  ): [PrivateState, []] => [{ ...privateState, localVote: vote }, []],

  localVoteCast: ({
    privateState,
  }: WitnessContext<Ledger, PrivateState>): [
    PrivateState,
    { is_some: boolean; value: boolean },
  ] => [
    privateState,
    privateState.localVote !== null
      ? { is_some: true, value: privateState.localVote }
      : { is_some: false, value: false },
  ],
};
Enter fullscreen mode Exit fullscreen mode

Each witness returns the full updated private state as its first element. This is how Midnight threads private state through the circuit execution functionally. If the circuit fails at any point, none of the private state mutations are committed either.

4. Merkle path witness

When a contract needs to verify that a value belongs to a set without revealing which member, it uses a Merkle tree. The path computation is too expensive and arbitrary to happen inside a circuit. A witness does the traversal off-chain and hands the path to the circuit, which verifies the root.

Here's an example Compact contract:

pragma language_version >= 0.22;
import CompactStandardLibrary;

witness localSecretKey(): Bytes<32>;
witness localPathOfPk(pk: Bytes<32>): Maybe<MerkleTreePath<10, Bytes<32>>>;

export ledger eligibleVoters: MerkleTree<10, Bytes<32>>;

circuit voterPublicKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "election:voter:"), sk]);
}

export circuit proveEligibility(): [] {
  const sk = localSecretKey();
  const pk = voterPublicKey(sk);
  const path = localPathOfPk(pk);

  assert(
    disclose(path.is_some) &&
    eligibleVoters.checkRoot(disclose(merkleTreePathRoot<10, Bytes<32>>(path.value))) &&
    pk == path.value.leaf,
    "Not an eligible voter"
  );
}
Enter fullscreen mode Exit fullscreen mode

The circuit receives a path from the witness and checks for three things:

  • that a path exists
  • that the path root matches the on-chain tree root
  • that the leaf is the caller's public key.

If any check fails, the assert reverts everything. The voter's identity is never disclosed. Instead, the proof only establishes that a valid path exists.

Here is the TypeScript implementation of the witnesses:

type PrivateState = {
  secretKey: Uint8Array;
  localVoterTree: Map<string, unknown>; // local Merkle tree structure
};

export const witnesses = {
  localSecretKey: ({
    privateState,
  }: WitnessContext<Ledger, PrivateState>): [PrivateState, Uint8Array] => [
    privateState,
    privateState.secretKey,
  ],

  localPathOfPk: (
    { privateState }: WitnessContext<Ledger, PrivateState>,
    pk: Uint8Array,
  ): [PrivateState, { is_some: boolean; value: unknown }] => {
    // Traverse the local Merkle tree to find the path for this public key. Implement your version computeMerklePath
    const path = computeMerklePath(privateState.localVoterTree, pk);
    return [
      privateState,
      path ? { is_some: true, value: path } : { is_some: false, value: null },
    ];
  },
};
Enter fullscreen mode Exit fullscreen mode

TypeScript holds a local copy of the Merkle tree and computes the path. The circuit validates it. The tree traversal never needs to be expressed in field arithmetic.

5. Feeding external data into circuits

Witnesses can pull in any data that exists outside the contract (e.g., game state, board positions, API responses). The key requirement is that the circuit must constrain and validate whatever the witness provides. A witness that returns unvalidated external data and a circuit that trusts it blindly is a security hole.

The naval battle game by ErickRomeroDev demonstrates this pattern cleanly. Before the game starts, each player commits their board setup privately. The witness receives the full board, stores it locally, and returns only a hash to the circuit. The circuit writes that hash to the ledger, and the actual board positions never go on-chain.

export ledger playerOnePk: Cell<Bytes<32>>;
export ledger playerOneCommit: Cell<Bytes<32>>;
export ledger playerOneHasCommitted: Cell<Boolean>;

witness local_sk(): Bytes<32>;

// Receives the full board setup, stores it in private state,
// and returns only a hash to the circuit
witness set_local_gameplay(playerSetup: Vector<100, Uint<1>>): Bytes<32>;

export circuit commitGrid(player: Bytes<32>, playerSetup: Vector<100, Uint<1>>): Void {
  // Verify the caller is who they claim to be
  assert(playerOnePk == public_key(local_sk())) "PlayerOne confirmation failed";

  // The witness stores the board locally and hands back only the hash
  const commit = set_local_gameplay(playerSetup);

  // Only the hash goes on-chain — the board positions stay private
  playerOneCommit.write(commit);
  playerOneHasCommitted.write(true);
}

export circuit vectorHash(sk: Vector<100, Uint<1>>): Bytes<32> {
  return persistent_hash<Vector<100, Uint<1>>>(sk);
}
Enter fullscreen mode Exit fullscreen mode

Later, when a player makes a move, the circuit uses the vectorHash circuit to verify that the locally stored board matches the committed hash. This will spot any attempt to tamper with the board after the game has started.

// Verify the player has not tampered with their board since committing
assert(vectorHash(local_gameplay()) == playerOneCommit) "Player one has tampered with the grid";
Enter fullscreen mode Exit fullscreen mode

Here is the TypeScript implementation of the two witnesses:

export const witnesses = {
  local_sk: ({
    privateState,
  }: WitnessContext<Ledger, NavalBattlePrivateState>): [
    NavalBattlePrivateState,
    Uint8Array,
  ] => [privateState, privateState.secretKey],

  set_local_gameplay: (
    {
      privateState,
      contractAddress,
    }: WitnessContext<Ledger, NavalBattlePrivateState>,
    playerSetup: bigint[],
  ): [NavalBattlePrivateState, Uint8Array] => {
    const updatedGameplay =
      privateState.localGameplay ?? new Map<string, bigint[]>();

    // Store the full board setup locally, keyed by contract address
    updatedGameplay.set(contractAddress, playerSetup);

    return [
      { ...privateState, localGameplay: updatedGameplay },

      // Return only the hash to the circuit — the board itself stays private
      pureCircuits.vectorHash(playerSetup),
    ];
  },
};
Enter fullscreen mode Exit fullscreen mode

local_sk returns the player's secret key from the private state. This pattern was explained in an earlier section.

set_local_gameplay is where the external data ingestion happens. It receives the full board setup as a bigint[], stores it in the local gameplay map keyed by contractAddress, and returns only its hash via pureCircuits.vectorHash. Using contractAddress as the key means a player can
participate in multiple game instances simultaneously without their board from one game bleeding into another.

NOTE: pureCircuits is a generated object from your compiled contract that exposes circuits with no ledger side effects so they can be called directly from TypeScript without submitting a transaction. You can learn more about how this works on Midnight's Documentation.

Witness-based access control

Access control in Midnight does not work the way it does in Ethereum. There is no native version of msg.sender, and circuits cannot observe who submitted a transaction.

In Compact, circuits can enforce access control by asking the caller to prove knowledge of a secret, and verify that proof against a commitment stored on-chain. It follows this pattern:

  • Store a hash of the secret at setup time
  • Re-derive the same hash at call time
  • Assert that the above two match each other. If they do not, the circuit reverts.

Here's an example of what it looks like to enforce access control in Compact:

pragma language_version >= 0.22;
import CompactStandardLibrary;

witness secretKey(): Bytes<32>;

ledger authority: Bytes<32>;
export ledger round: Counter;
export ledger sensitiveValue: Bytes<32>;

circuit publicKey(_sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<3, Bytes<32>>>([
    pad(32, "myapp:auth:pk:"),
    round as Field as Bytes<32>,
    _sk
  ]);
}

// Runs once at deployment. Stores a hash of the deployer's secret key as the authority.
constructor(value: Bytes<32>) {
  const _sk = secretKey();
  authority = disclose(publicKey(_sk));
  sensitiveValue = disclose(value);
}

// Only the holder of the original secret key can call this.
export circuit update(newValue: Bytes<32>): [] {
  const _sk = secretKey();
  assert(publicKey(_sk) == authority, "Not authorized");
  sensitiveValue = disclose(newValue);
  round.increment(1);
  authority = disclose(publicKey(_sk));
}

// Anyone can read. Only the authority can write.
export circuit read(): Bytes<32> {
  return sensitiveValue;
}
Enter fullscreen mode Exit fullscreen mode

In the snippet above, the constructor runs once at deployment. It calls the secretKey() witness
to retrieve the deployer's private key, derives a public key from it, and stores only the hash as authority. Every subsequent call to update() re-derives that same hash from the witness at call time and compares it against authority. If the caller does not know the original secret key, the assert fails and the transaction reverts.

read() has no access control. Any caller can execute it. The distinction between these two circuits is the access control model: one requires proof of identity, the other does not.

NOTE: You will notice the use of a round counter. After update() succeeds, the function increments round and re-derives authority with the new counter value. This means the stored authority hash changes after every authorized action, so an observer watching the chain cannot link multiple update() calls to the same key.

Role-based access control with witnesses

So far, you have seen how to handle access control with a single authority. But what happens when your contract requires multiple roles, such as minter or admin? The solution is simply to extend your contract to store a separate authority hash for each role:

pragma language_version >= 0.22;
import CompactStandardLibrary;

witness secretKey(): Bytes<32>;

ledger adminAuthority: Bytes<32>;
ledger minterAuthority: Bytes<32>;

circuit roleKey(_sk: Bytes<32>, role: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<3, Bytes<32>>>([
    pad(32, "myapp:role:"),
    role,
    _sk
  ]);
}

export circuit mint(amount: Uint<64>): [] {
  const _sk = secretKey();
  assert(roleKey(_sk, pad(32, "minter")) == minterAuthority, "Not a minter");
  // mint logic
}

export circuit pause(): [] {
  const _sk = secretKey();
  assert(roleKey(_sk, pad(32, "admin")) == adminAuthority, "Not an admin");
  // pause logic
}
Enter fullscreen mode Exit fullscreen mode

Each role uses a different domain string in the hash, so the same secret key produces a different commitment per role. Therefore, an observer cannot correlate admin actions with minter actions even if the same person holds both roles.

How official Midnight DApps use witnesses

1. Election Contract

The example election contract uses two witnesses to implement a private commit-reveal voting scheme:

witness localSk(): Bytes<32>;
witness localGetVote(): VoteChoice;

export circuit commitVote(): [] {
    assert(votingState == VotingState.OPEN, "Voting has not opened yet");
    const _sk = localSk();
    const pubKey = getDappPubKey(_sk);
    assert(registeredVoters.member(pubKey), "You are not registered to vote.");
    assert(!hashedVoteMap.member(pubKey), "Attempt to double vote");
    const _currentVote = localGetVote();
    assert(_currentVote == VoteChoice.BAD || _currentVote == VoteChoice.WORSE, "Please provide a valid vote");

    const hash = commitWithSk(_currentVote as Field as Bytes<32>, _sk);
    hashedVoteMap.insert(pubKey, hash);
    totalVoteCount.increment(1);
}


// check privateState for original vote, if it's changed, then error
export circuit revealVote(): [] {
    assert(votingState == VotingState.CLOSED, "Voting is still open");

    const _sk = localSk();
    const pubKey = getDappPubKey(_sk);
    // we want to check that they have already voted..
    assert(hashedVoteMap.member(pubKey), "You have not voted yet");
    assert(registeredVoters.member(pubKey), "You are not a registered voter");

    const vote = localGetVote();
    assert(vote == VoteChoice.BAD || vote == VoteChoice.WORSE, "Please supply a valid vote");

    // here is the money
    const hashedVote = commitWithSk(vote as Field as Bytes<32>, _sk);
    assert(hashedVoteMap.lookup(pubKey) == hashedVote, "Attempt to change the vote!");

    if(disclose(vote) == VoteChoice.BAD){
        candidate0VoteCounter.increment(1);
    } else if (disclose(vote) == VoteChoice.WORSE) {
        candidate1VoteCounter.increment(1);
    }
}

// hash a random "public key" that is only traceable in this dapp
circuit getDappPubKey(_sk: Bytes<32>): Bytes<32> {
  return disclose(persistentHash<Vector<2, Bytes<32>>>([pad(32, "election:pk:"), _sk]));
}
Enter fullscreen mode Exit fullscreen mode

localSk handles identity. Every circuit that needs to verify the caller derives their public key from it. localGetVote retrieves the voter's choice from the private state.

In commitVote(), the circuit retrieves the vote from the witness, hashes it together with the caller's secret key, and stores only that hash on-chain without revealing the vote:

const _currentVote = localGetVote();
const hash = commitWithSk(_currentVote as Field as Bytes<32>, _sk);
hashedVoteMap.insert(pubKey, hash);
Enter fullscreen mode Exit fullscreen mode

In revealVote(), the circuit retrieves the vote again and re-derives the same hash. If the re-derived hash matches what was committed, the vote is counted. If the voter tries to change their vote between commit and reveal, the hashes will not match, and the assert fails:

const hashedVote = commitWithSk(vote as Field as Bytes<32>, _sk);
assert(
  hashedVoteMap.lookup(pubKey) == hashedVote,
  "Attempt to change the vote!",
);
Enter fullscreen mode Exit fullscreen mode

You can browse the full code based on Midnight's official documentation.

2. Naval battle game

The naval battle contract uses three witnesses:

witness local_sk(): Bytes<32>;
witness local_gameplay(): Vector<100, Uint<1>>;
witness set_local_gameplay(playerSetup: Vector<100, Uint<1>>): Bytes<32>;
Enter fullscreen mode Exit fullscreen mode

local_sk handles player identity. set_local_gameplay receives the full board setup, stores it locally, and returns only a hash to the circuit. local_gameplay retrieves the stored board during gameplay.

The three witnesses work together across two key moments in the game. At commit time, set_local_gameplay stores the board, and the circuit writes only the hash on-chain:

const commit = set_local_gameplay(playerSetup);
playerOneCommit.write(commit);
Enter fullscreen mode Exit fullscreen mode

At move time, local_gameplay retrieves the stored board, and the circuit verifies it has not been tampered with since the commit:

assert(vectorHash(local_gameplay()) == playerOneCommit) "Player one has tampered with the grid";
Enter fullscreen mode Exit fullscreen mode

The board positions never go on-chain. What the chain holds is a commitment.

Security Rules for Witnesses

When using witnesses in Compact (and you will use them), you should keep the following in mind so you don't compromise on your privacy:

1. Verify, never trust. Every value a witness returns should be treated as adversarial input. Write circuit assertions that prove the output is correct based on public inputs and mathematical identities. You should never assume that the witness is honest or correct.

2. Witnesses run outside the ZK proof. The user's own DApp provides the witness implementation. If a user modifies their frontend to return malicious values from a witness, the circuit assertions are the only thing standing between that and an invalid state transition. Make those assertions tight.

3. Never use ownPublicKey() for access control. It is a witness. It can return anything. Use the secret key → hash pattern instead.

4. Keep disclose() as late as possible. Once you wrap a value in disclose(), the compiler stops tracking it for accidental leakage. Disclose immediately before the value enters a ledger field or a circuit return, and not the moment it comes out of the witness.

5. Witnesses cannot modify the public state. They can only update private state and return values to circuits. If you need to change a ledger field, that has to happen in circuit logic, using what the witness returned.

Summary

Witnesses are where Midnight's privacy architecture does most of its practical work. They handle everything the ZK proof system cannot, such as arbitrary computation, private data access, external data ingestion, and hand the results to circuits that constrain and verify them.

The patterns in this article cover the cases you will encounter most often:

  • Secret key witnesses establish private identity without putting credentials on-chain
  • Witness-verified division handles arithmetic that the circuit cannot do natively, with circuit assertions proving correctness
  • Private state management witnesses implement local state machines that mirror the on-chain state
  • Merkle path witnesses compute membership proofs off-chain and hand them to circuits for verification
  • External data witnesses ingest arbitrary off-chain data that circuits then constrain

In every pattern, the structure is the same: the witness computes, the circuit verifies. That boundary is not a limitation. It is what makes the whole system trustworthy.

Top comments (0)