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 calling | Provided by DApp at runtime |
| Visible in the proof inputs | Stay local, never on-chain |
| Public (anyone can see them) | Private (only the caller knows) |
| Compiler enforces type | DApp 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.
| Context | Where it runs | Who sees it | What goes here |
|---|---|---|---|
| Public ledger | Every network node | All observers | export ledger fields, proofs |
| ZK circuits | On-chain validation | No one (only the proof result) | Circuit logic, state transitions |
| Local computation | User’s machine | Only the user | Witness 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:
- Where the witness data originated (
getBalance) - 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:
| Function | Witness-tainted? | Why |
|---|---|---|
transientCommit(e) | No | Random nonce provides sufficient hiding |
persistentCommit(e) | No | SHA-256 + random nonce provides sufficient hiding |
transientHash(e) | Yes | Bare hash may not hide input |
persistentHash(e) | Yes | Bare 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
witnessfunction 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
| Concept | Solidity | TypeScript | Compact |
|---|---|---|---|
| Private input | private variables | class fields | witness |
| Secret data | on-chain (encrypted) | in memory | stays local |
| Proving computation | N/A | N/A | via circuit |
| Trust model | contract code | app logic | witness is untrusted |
| Built-in identity | msg.sender | N/A | ownPublicKey() (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. persistentCommitandtransientCommitare exceptions, the random nonce provides sufficient hiding withoutdisclose().- 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.
Cross-Links
- Previous: Ledger State Public vs private
- Next: Explicit Disclosure Deep dive on
disclose() - See also: Circuits Constraint declarations
- See also: Security and Best Practices Auth patterns and privacy
- Examples: 06.01 Witnesses