Manage private state with witnesses
For the complete documentation index, see llms.txt
In the Midnight Network, zero-knowledge (ZK) proofs let you keep data private while maintaining verifiable state. A core concept in building Midnight DApps is the witness: a function that supplies data known to the user (the prover) but kept hidden from the network (the verifiers).
This guide covers how to declare witnesses in Compact, implement them in TypeScript against the compiler's generated module, and troubleshoot common issues.
Prerequisites
- The Compact developer tools installed, with compiler 0.31.1 set as the default. Update the compiler version shows how to pin it.
- A Node.js project with
@midnight-ntwrk/compact-runtime@0.16.0installed. Compiler 0.31.1 and runtime 0.16.0 are the pair in the compatibility matrix, and the examples on this page target that pair. The generated code checks the runtime version when you import it, so a mismatched pair fails with aVersion mismatcherror. - Familiarity with compiling a contract and importing its generated module. Using Compact contracts from JavaScript covers this end to end.
Core concepts
Before diving into code, it helps to understand the role of a witness in a Midnight DApp:
- Public state (ledger): Data stored on the Midnight blockchain that anyone can read.
- Private state: Data kept locally by the user's DApp and never sent to the network. Your TypeScript code defines its shape and keeps it up to date.
- Witness: A function, declared in Compact and implemented in TypeScript, that supplies private data to a smart contract circuit during local execution. The circuit uses this data to generate a ZK proof, which the DApp submits to the network. The network verifies the proof without ever seeing the witness data.
Private state and witnesses are related but distinct: a witness usually reads from (and may update) your private state, and because a witness value arrives untrusted, the circuit must assert any property of it that the contract relies on.
Step-by-step walkthrough: a private access code
Build a circuit where a user must provide a secret access code to update a public counter. The network knows the counter increased, but never learns the access code used.
1. Define the circuit in Compact
In your Compact contract, you declare a witness function. This tells the compiler that the circuit expects external, private input during execution, supplied by your TypeScript code at call time.
// access_control.compact
pragma language_version >= 0.16;
import CompactStandardLibrary;
export ledger counter: Uint<32>;
// The network knows the hash of the secret code, but not the code itself.
// The expected hash is set once at deployment via the constructor.
export ledger secret_hash: Bytes<32>;
// Declare the witness function
witness access_witness(): Bytes<32>;
constructor(sk: Bytes<32>) {
secret_hash = disclose(persistentHash<Bytes<32>>(sk));
}
export circuit increment_counter(): [] {
// 1. Fetch the private data via the witness
const secret_code = access_witness();
// 2. Verify the secret code matches the expected hash
// The hash function is evaluated locally, and a proof of correct execution is generated.
const computed_hash = persistentHash<Bytes<32>>(secret_code);
assert(computed_hash == secret_hash, "Invalid access code");
// 3. Update the public ledger
counter = (counter + 1) as Uint<32>;
}
2. Provide the witness in TypeScript
When the Compact compiler processes your contract, it generates a JavaScript implementation in the managed/ directory. The generated Contract class requires a witnesses object whose keys match the witness names declared in your .compact file.
Each witness function receives a WitnessContext carrying the current ledger view, private state, and contract address, and returns a tuple of the updated private state and the witness value.
import * as RT from '@midnight-ntwrk/compact-runtime';
import { Contract, ledger } from './managed/access_control/contract/index.js';
import type { Ledger } from './managed/access_control/contract/index.js';
import type { WitnessContext } from '@midnight-ntwrk/compact-runtime';
// Define the shape of your private state
type AccessControlPrivateState = {
readonly secretCode: Uint8Array;
};
// Create a factory for initial private state
const createPrivateState = (secretCode: Uint8Array): AccessControlPrivateState => ({
secretCode,
});
// Implement witnesses: keys must match Compact witness names exactly
const witnesses = {
access_witness: ({
privateState,
}: WitnessContext<Ledger, AccessControlPrivateState>): [
AccessControlPrivateState,
Uint8Array,
] => [privateState, privateState.secretCode],
};
// Instantiate the contract with the witnesses object
const contract = new Contract(witnesses);
Once instantiated, build a circuit context with the runtime helpers and call the circuit through contract.impureCircuits. Use RT.createConstructorContext and RT.createCircuitContext rather than assembling the context by hand: a real CircuitContext carries query-context state that the wrappers validate.
// Local scaffolding for running the circuit off-chain: a zeroed coin public key
// and a sample contract address. A deployed DApp gets the real values from the
// wallet and the deployment instead.
const COIN = '0'.repeat(64);
const ADDR = RT.sampleContractAddress();
const secretCode = new Uint8Array(32).fill(7); // any 32-byte secret
// 1. Create the genesis state with the constructor
const ctor = contract.initialState(
RT.createConstructorContext(createPrivateState(secretCode), COIN),
secretCode, // constructor argument: the secret key to hash and store
);
// 2. Build a circuit context from the constructor result
const ctx = RT.createCircuitContext(
ADDR, COIN, ctor.currentContractState, createPrivateState(secretCode),
);
// 3. Call the impure circuit, which invokes access_witness() automatically
const call = contract.impureCircuits.increment_counter(ctx);
// 4. Read the resulting ledger state with the generated helper
const state = ledger(call.context.currentQueryContext.state);
console.log('New counter value:', state.counter);
For a complete walkthrough of importing the generated module, writing witnesses, calling circuits, and running tests, see Using Compact contracts from JavaScript.
Troubleshooting and best practices
When working with witnesses, you might encounter a few common pitfalls.
1. Missing or misnamed witness
Cause: The Contract constructor checks that every witness declared in Compact has a matching function in the witnesses object. If one is missing or misnamed, the constructor throws first (witnesses) argument to Contract constructor does not contain a function-valued field named access_witness.
Solution: Ensure every key in your witnesses object exactly matches the name of the witness declared in your .compact file. For the other errors the generated module throws, see Errors from the generated module.
2. Witnesses that return a new value on every call
Cause: Each witness call in a circuit runs your TypeScript function once, and the proof uses the value that call returned. A witness that generates a fresh random value on every call gives each call site, and each later transaction, a different value, and nothing saves it. If you commit to one of those values, you cannot open the commitment later.
Solution: If a value must stay the same across call sites or transactions, generate it once, store it in private state, and have the witness return the stored value.
type MyPrivateState = { readonly nonce?: Uint8Array };
// BAD: Generates a new random value every time it's called, and never saves it
const witnesses = {
get_random_nonce: ({
privateState,
}: WitnessContext<Ledger, MyPrivateState>): [MyPrivateState, Uint8Array] => [
privateState,
crypto.getRandomValues(new Uint8Array(32)),
],
};
// GOOD: Generates the value once, stores it in private state, and returns the stored value
const witnesses = {
get_random_nonce: ({
privateState,
}: WitnessContext<Ledger, MyPrivateState>): [MyPrivateState, Uint8Array] => {
const nonce = privateState.nonce ?? crypto.getRandomValues(new Uint8Array(32));
return [{ ...privateState, nonce }, nonce];
},
};
3. Failed asserts
Cause: The private data your witness supplied did not satisfy the circuit's assert statements, for example a secret whose hash does not match the stored secret_hash. The circuit runs locally before proof generation starts, so the call throws there with the message from your contract: failed assert: Invalid access code.
Solution: Catch the error where you call the circuit and show the reason in your UI. Because the call fails before proving, a wrong value costs no proving time. The same assert is part of the circuit that the proof covers, so a client that skips the local check still cannot produce a valid proof.
Next steps
- Learn more about Compact types and declaring witnesses in the Compact reference.
- Explore Using Compact contracts from JavaScript for the full generated-module API.
- Harden contracts that consume witness data with Security and best practices.
- When you are ready for a network, Deploy and operate a contract covers providers, deployment, and maintenance.
- To see why the constructor wraps the stored hash in
disclose(), How to build a private smart contract walks through the privacy boundary end to end.