Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Witnesses

This note explains witnesses, the mechanism that brings private data into circuits without ever touching the chain.

Docs: Declaring Witnesses Examples: 06.01 Witnesses


Intuition First

A witness is a callback function. You declare its type in Compact, but the body is provided by your TypeScript DApp at runtime. When a circuit calls a witness, it runs locally on the user’s device, the value it returns never goes on-chain. Instead, a ZK proof proves the circuit executed correctly given that value.

The name “witness” comes from ZK literature. In a proof, the witness is the secret data that proves a statement is true, without revealing what that data is. In Compact, witnesses are exactly that: secret inputs that prove the circuit ran correctly.


Mental Model

Witnesses are private inputs, not parameters.

Parameters (to circuits)Witnesses
Passed explicitly when callingProvided by DApp at runtime
Visible in the proof inputsStay local, never on chain
Public (anyone can see them)Private (only the caller knows)
Compiler enforces typeDApp provides implementation

When you call a circuit, you pass public parameters. The circuit can also call witnesses internally. The witness returns a value, and the circuit uses it, but only the proof goes on-chain.


The Flow

Witness + Circuit Flow


Declaring a Witness

witness secretKey(): Bytes<32>;
witness getBalance(addr: Bytes<32>): Uint<64>;
witness userNonce(): Field;
witness getItem<T>(index: Uint<32>): T;  // generic

Witness declarations have no body. The body is provided by your TypeScript DApp.


Calling a Witness

export circuit clear(): [] {
  const sk = secretKey();           // call witness: returns private data
  const pk = publicKey(round, sk); // compute with it: still private
  assert(authority == pk, "Not authorized");
  state = State.UNSET;
  round.increment(1);
}

The witness call happens locally. The ZK proof proves the computation was correct, without revealing sk.


The Compiler Tracks Witness Data

The compiler tracks witness data through every operation, arithmetic, type conversions, struct construction, function calls. Once data comes from a witness, it’s “tainted”, the compiler knows it’s private.

export circuit example(): [] {
  const s = getSecret();
  const doubled = s + s;              // still witness data
  const converted = s as Uint<64>;    // still witness data
  ledger = doubled;                  // compiler error: undeclared disclosure
}

This is the witness protection program, the compiler prevents accidental disclosure of private data.


When Disclosure Is Required

When witness data needs to flow into the public ledger, wrap it in disclose():

// Without: compiler error
export circuit record(): [] {
  balance = getBalance();  // error: witness data going to ledger
}

// With: compiles
export circuit record(): [] {
  balance = disclose(getBalance());  // ok: declared
}

disclose() does not encrypt. It’s a compile-time annotation that says “I’m intentionally making this public.”


The Compiler Error

When you forget disclose(), the compiler tells you exactly where the witness data came from:

Exception: line 6 char 11:
  potential witness-value disclosure must be declared but is not:
    witness value potentially disclosed:
      the return value of witness getBalance at line 2 char 1
    nature of the disclosure:
      ledger operation might disclose the witness value

This trace tells you:

  1. Where the witness data originated (getBalance)
  2. Where it tried to flow (the ledger assignment)

Indirect Disclosure

The compiler catches disclosure even when witness data travels through helper circuits:

circuit obfuscate(x: Field): Field {
  return x + 73;  // output is still witness data
}

export circuit record(): [] {
  const s = getBalance() as Field;
  const x = obfuscate(s);
  balance = x as Bytes<32>;  // compiler catches this
}

The compiler’s abstract interpreter follows witness taint through every operation. You cannot hide witness data by passing it through arithmetic, structs, or helper functions.


Place Disclosure As Close As Possible

// Bad: declares broader scope
export circuit process(data: PrivateData): [] {
  const result = compute(disclose(data));  // discloses too much
  ledger = result;
}

// Good: discloses at the boundary
export circuit process(data: PrivateData): [] {
  const result = compute(data);
  ledger = disclose(result);  // discloses only what's needed
}

Place disclose() as close to the disclosure point as possible. This minimizes what you’re declaring as public.


Standard Library Exceptions

Some functions can handle witness data without explicit disclosure:

FunctionWitness-tainted?Why
transientCommit(e)NoRandom nonce provides sufficient hiding
transientHash(e)YesBare hash may not hide input
// no disclose() needed
ledger commitment: Field;
export circuit commit(v: Field): [] {
  const nonce = freshNonce();
  commitment = transientCommit(v, nonce);  // nonce hides the value
}

The nonce provides enough randomness that even knowing the value doesn’t help. This is why transientCommit doesn’t require disclose(), but transientHash does.


Critical: Witness Results Are Untrusted

Do not assume in your contract that the code of any witness function is the code that you wrote. Any DApp may provide any implementation it wants. Results should be treated as untrusted input.

The ZK proof guarantees the circuit’s logic ran correctly, given whatever inputs witnesses returned. It does not guarantee witnesses returned sensible values.

Your contract must validate witness outputs:

// WRONG: trust the witness
export circuit transfer(to: Bytes<32>, amount: Uint<64>): [] {
  const balance = getBalance();  // untrusted!
  balances[to] += amount;
}

// RIGHT: validate first
export circuit transfer(to: Bytes<32>, amount: Uint<64>): [] {
  const balance = getBalance();
  assert(balance >= amount, "Insufficient balance");  // validate!
  balances[to] += amount;
}

Comparison Layer

ConceptSolidityTypeScriptCompact
Private inputprivate variablesclass fieldswitness
Secret dataon-chain (encrypted)in memorystays local
Proving computationN/AN/Avia circuit
Trust modelcontract codeapp logicwitness is untrusted

Quick Recap

  • Witnesses are callback functions, declared in Compact, implemented in TypeScript.
  • They run locally on the user’s device. The value never goes on-chain.
  • Only the ZK proof goes on-chain, it proves the circuit ran correctly given the witness inputs.
  • The compiler tracks witness data through every operation.
  • If witness data reaches the ledger without disclose(), the compiler errors.
  • transientCommit is an exception, the random nonce provides hiding without disclose().
  • Always validate witness outputs. The proof proves correct logic, not sensible inputs.