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 · Smart Contract Security 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 Three Execution Contexts

Compact contracts don’t execute in one place. There are three distinct contexts, and understanding their boundaries is what makes you write secure contracts.

ContextWhere it runsWho sees itWhat goes here
Public ledgerEvery network nodeAll observersexport ledger fields, proofs
ZK circuitsOn-chain validationNo one (only the proof result)Circuit logic, state transitions
Local computationUser’s machineOnly the userWitness implementations, private state

Witnesses live entirely in the third context. The ZK circuit in the second context calls into the third to get private values, then proves it computed correctly without revealing those values to the first context.


The Flow

User's machine (local)          On-chain
┌─────────────────────┐         ┌──────────────────────────┐
│  witness impl       │ ──────► │  ZK circuit              │
│  returns secret     │  feeds  │  computes with secret    │
│  stays here         │         │  generates proof         │
└─────────────────────┘         │  proof goes on-chain     │
                                │  secret does not         │
                                └──────────────────────────┘

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: discloses too broadly
export circuit process(data: PrivateData): [] {
  const result = compute(disclose(data));  // disclosed before compute
  ledger = result;
}

// ✅ Good: discloses only at the boundary
export circuit process(data: PrivateData): [] {
  const result = compute(data);            // still private through compute
  ledger = disclose(result);               // disclose at the exact point needed
}

Place disclose() as close to the disclosure point as possible. This minimizes what you’re declaring as public and keeps maximum computation inside the privacy boundary.


Standard Library Exceptions

Some functions can handle witness data without explicit disclosure:

FunctionWitness-tainted?Why
transientCommit(e)NoRandom nonce provides sufficient hiding
persistentCommit(e)NoSHA-256 + random nonce provides sufficient hiding
transientHash(e)YesBare hash may not hide input
persistentHash(e)YesBare hash may not hide input
// no disclose() needed, nonce hides the value
ledger commitment: Bytes<32>;
export circuit commit(v: Uint<64>): [] {
  const nonce = freshNonce();
  commitment = persistentCommit(v, nonce);
}

The nonce provides enough randomness that even knowing the value space doesn’t help. This is why commit functions don’t require disclose(), but hash functions still do.


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.insert(to, amount);
}

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

ownPublicKey() Is a Witness Function

ownPublicKey() returns the Zswap coin public key of the user executing the circuit. It looks like a built-in identity function, which makes it tempting to use for caller verification.

Do not use ownPublicKey() to verify the caller of a circuit.

ownPublicKey() is technically a witness function. Every user’s frontend is capable of providing a malicious return value for it. Using it as an authorization check means a malicious DApp can impersonate any address.

// ❌ Wrong: ownPublicKey() can be spoofed
export circuit adminAction(): [] {
  assert(ownPublicKey() == adminKey, "Not admin");  // unsafe
  // ...
}

// ✅ Right: verify through a cryptographic commitment you control
export circuit adminAction(): [] {
  const _sk = localSecretKey();
  const pk = persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:auth:pk"), _sk]);
  assert(disclose(pk) == authority, "Not authorized");
  // ...
}

The rule: use ownPublicKey() only after the caller has already been verified through another mechanism, a hash-based authentication pattern, a committed key, or a nullifier. Never as the first and only check.


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
Built-in identitymsg.senderN/AownPublicKey() (untrusted, do not use for auth)

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 it reaches the ledger without disclose(), you get a compile error.
  • persistentCommit and transientCommit are exceptions, the random nonce provides sufficient hiding without disclose().
  • Always validate witness outputs. The proof proves correct logic, not sensible inputs.
  • Never use ownPublicKey() for caller authorization. It’s a witness function and can return anything a malicious DApp provides.