Midnight gives DApp developers a different security model from public-by-default chains. A Midnight smart contract can keep sensitive data local, prove facts about that data with ZK proofs, and write only selected outputs to public ledger state. That power creates a clear deployment responsibility: every public write, every witness value, every authorization check, and every proof-generation path must be reviewed before launch.
Use this tutorial as a durable pre-deployment checklist. It is written for developers who already have a Compact smart contract and want a repeatable audit process before deploying to Preview, Preprod, or Mainnet.
This guide is for Midnight DApp developers who write Compact smart contracts, implement TypeScript witnesses, and configure Midnight.js or related tooling for proof generation and transaction submission.
Prerequisites
Before you begin, make sure you have:
- Node.js 22 or later.
- Docker Desktop or another Docker-compatible runtime.
- The Compact devtools installed.
- A compiled Midnight smart contract in a project with TypeScript tests.
- Access to a local proof server or the correct Preview, Preprod, or Mainnet proof server endpoint.
- Lace configured for the target environment when wallet interaction is part of your test flow.
Required reading
Review these pages before running the checklist:
- Midnight developer documentation
- Compact language
- Smart contract security
- Explicit disclosure
- Test and debug
- Compatibility matrix
- Environments and endpoints
- Midnight MCP
- Bulletin board DApp
Start with a clean verification gate
Run the same gate before every security review and keep it in CI.
compact --version
compact compile --version
compact compile --language-version
compact compile --runtime-version
compact compile --ledger-version
compact format --check src/
compact fixup --check src/
npm run compact
npm run typecheck
npm test
For the official bulletin board example, the Compact script compiles the source smart contract into generated TypeScript, JavaScript, ZKIR, proving keys, verifier keys, and compiler metadata:
{
"scripts": {
"compact": "compact compile src/bboard.compact ./src/managed/bboard",
"ci": "npm run compact && npm run typecheck && npm run lint && npm run build && npm run test"
}
}
Do not review stale generated files. Delete generated output, recompile, run tests, and review generated metadata from the deployment commit.
Understand the security boundary
A Compact smart contract spans three contexts:
- Public ledger state: On-chain state that observers, indexers, and DApps can read.
- ZK circuits: Compact logic that proves valid execution without revealing private inputs.
- Local witnesses: TypeScript or JavaScript callbacks that run on the user's machine and supply private values.
Most Midnight bugs appear at the boundary between those contexts. The compiler helps prevent accidental disclosure, but it cannot decide whether a disclosure is safe for your logic or make a witness implementation trustworthy.
1. Audit every disclose() call
The disclose() wrapper is an explicit statement that a value derived from witness data, exported circuit arguments, or constructor arguments is safe to place in a public context. Public contexts include public ledger assignments, values returned from exported circuits, and values passed to another smart contract.
Start with a simple inventory:
grep -R "disclose(" -n src contract contracts
For each result, write down:
- The exact value being disclosed.
- The private inputs or witness values that can influence it.
- The public destination, such as
export ledger owner, an exported circuit return value, or a cross-smart-contract argument. - The reason that disclosure is necessary.
- The privacy impact if a user links this value across transactions.
A safe disclose() sits close to the public write. Avoid disclosing a private value early and passing it through multiple branches. That makes later review harder.
The bulletin board ownership pattern shows a narrow disclosure. The smart contract does not disclose the local secret key. It discloses a hash commitment derived from a domain separator, the current sequence number, and the local secret key.
pragma language_version >= 0.22;
import CompactStandardLibrary;
export enum State {
VACANT,
OCCUPIED
}
export ledger state: State;
export ledger message: Maybe<Opaque<"string">>;
export ledger sequence: Counter;
export ledger owner: Bytes<32>;
constructor() {
state = State.VACANT;
message = none<Opaque<"string">>();
sequence.increment(1);
}
witness localSecretKey(): Bytes<32>;
export circuit post(newMessage: Opaque<"string">): [] {
assert(state == State.VACANT, "Attempted to post to an occupied board");
owner = disclose(publicKey(localSecretKey(), sequence as Field as Bytes<32>));
message = disclose(some<Opaque<"string">>(newMessage));
state = State.OCCUPIED;
}
export circuit takeDown(): Opaque<"string"> {
assert(state == State.OCCUPIED, "Attempted to take down post from an empty board");
assert(owner == publicKey(localSecretKey(), sequence as Field as Bytes<32>), "Attempted to take down post, but not the current owner");
const formerMsg = message.value;
state = State.VACANT;
sequence.increment(1);
message = none<Opaque<"string">>();
return formerMsg;
}
export circuit publicKey(sk: Bytes<32>, sequence: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>([pad(32, "bboard:pk:"), sequence, sk]);
}
Review owner and message differently. owner is safe only because it is a one-way, domain-separated commitment. message is intentionally public. The compiler enforces explicitness. You enforce policy.
Pass this section only when every disclosure has a documented purpose and a negative privacy test.
2. Review ownPublicKey() usage
Search for ownPublicKey() before any deployment:
grep -R "ownPublicKey" -n src contract contracts
A match in authorization code is a release blocker. Midnight's security guidance treats ownPublicKey() as a witness function. A DApp frontend can provide witness results, so a circuit must not use ownPublicKey() as proof that the caller owns the matching secret key.
Prefer a witness-based commitment pattern:
- Keep the secret key in local private state.
- Return it through a witness such as
localSecretKey(). - Derive a DApp-specific commitment with
persistentHashand a clear domain separator. - Store only the commitment in public ledger state.
- Recompute the commitment inside protected circuits and assert equality against public state.
- Include a sequence, round, or nonce when linkability matters.
In the bulletin board pattern, post() stores owner as a commitment. Later, takeDown() recomputes the same commitment with localSecretKey() and the current sequence. The caller proves knowledge of the private input that opens the commitment without revealing the secret key.
Your review should classify each authorization path:
-
Allowed:
assert(publicKey(localSecretKey(), round) == owner, "Operation not permitted")whenowneris a stored commitment for that round. -
Allowed after another check:
ownPublicKey()used for display, routing, or wallet-related logic after the caller has passed an independent proof. -
Blocked:
assert(ownPublicKey() == owner, "Only owner")or any equivalent caller-verification check.
Pass this section only when no exported circuit depends on ownPublicKey() for caller verification.
3. Verify replay protection with nonces or nullifiers
A valid proof can still be dangerous if it can be reused in the wrong context. Replay protection binds an action to a unique state, round, nonce, or resource.
Search for replay primitives and state-versioning fields:
grep -R "Counter\|nonce\|nullifier\|claimZswapNullifier\|sequence\|round" -n src contract contracts
Use a Counter, round, or explicit nonce for state-machine actions. The bulletin board example uses sequence for this. The current poster's commitment includes the current sequence. When the message is removed, sequence.increment(1) invalidates the old commitment for the next post. This prevents old ownership proofs from applying to a later board cycle.
Use nullifiers for one-time resources, shielded notes, private claims, coupons, votes, withdrawals, or any operation where a private resource must be consumed exactly once. The ledger kernel exposes claimZswapNullifier(nul: Bytes<32>) to require a nullifier in the containing transaction and prevent another call from claiming the same nullifier in that transaction.
Your replay review should answer these questions:
- What makes this call unique?
- Does the uniqueness value appear inside the commitment or nullifier derivation?
- Does the smart contract update the uniqueness value after a successful call?
- Can a stale proof apply after state changes?
- Can two calls in the same transaction consume the same resource?
Add negative tests for replay attempts. A good test calls the same action twice with the same private input and the same public state snapshot, then expects the second call to fail or produce a different required state boundary. For nullifier flows, add a test that attempts to consume the same private resource twice and expects rejection.
Pass this section only when every state-changing circuit has an explicit anti-replay story and a negative test.
4. Review exported ledger fields
Anything declared with export ledger is part of the public interface. Treat exported ledger fields as API surface. They are useful for DApps, indexers, and tests, but they also reveal information.
Search all ledger declarations:
grep -R "export[[:space:]]\+ledger\|sealed[[:space:]]\+ledger\|^[[:space:]]*ledger" -n src contract contracts
For each ledger field, classify the field as:
- Public by design: state enums, counters, public messages, public commitments, public configuration, and public totals.
- Public but sensitive: hashes, commitments, status flags, timestamps, or counters that could link users or reveal behavior.
- Not acceptable: raw secrets, raw private identifiers, raw credentials, hidden vote choices, private notes, unblinded amounts, or values that reconstruct private state.
Use sealed ledger for values that must be initialized once and stay immutable, such as domain separators, policy IDs, configured limits, or deployment parameters. A sealed field can be set during initialization, but exported circuits cannot modify it later.
Compact toolchain 0.31.0 adds public ledger state layout to contract-info.json. After compilation, inspect that generated file and compare it with your manual ledger inventory:
compact compile src/bboard.compact src/managed/bboard
cat src/managed/bboard/compiler/contract-info.json
Do not rely on source review alone. Generated metadata catches fields that appear through modules, imports, and generated layouts. Keep a ledger review file with this shape:
| Field | Exported | Sealed | Type | Public reason | Privacy risk | Approved by |
| --- | --- | --- | --- | --- | --- | --- |
| state | yes | no | State | DApp renders board status | Low | Security reviewer |
| message | yes | no | Maybe<Opaque<"string">> | Message is intentionally public | User content is public | Product owner |
| sequence | yes | no | Counter | Replay boundary | May reveal activity count | Security reviewer |
| owner | yes | no | Bytes<32> | Ownership commitment | Linkable within one sequence | Security reviewer |
Pass this section only when every exported field has a documented public reason.
5. Verify witness implementation correctness
Witness declarations in Compact have no body. The DApp implements them in TypeScript or JavaScript. That means witness logic belongs in the security review.
A witness implementation returns a tuple: the new private state and the value passed back into the circuit. The bulletin board witness returns the same private state and the user's local secret key:
import { Ledger } from "./managed/bboard/contract/index.js";
import { WitnessContext } from "@midnight-ntwrk/compact-runtime";
export type BBoardPrivateState = {
readonly secretKey: Uint8Array;
};
export const createBBoardPrivateState = (secretKey: Uint8Array) => ({
secretKey,
});
export const witnesses = {
localSecretKey: ({
privateState,
}: WitnessContext<Ledger, BBoardPrivateState>): [
BBoardPrivateState,
Uint8Array,
] => [privateState, privateState.secretKey],
};
Review every witness for these requirements:
- The return type matches the Compact declaration exactly.
-
Bytes<32>maps to aUint8Arraywith length 32. -
Uintvalues usebigintwhere generated types require it. - The witness does not read from remote services during proof generation unless that dependency is authenticated, stable, and tested.
- The witness does not log secrets, notes, nullifiers, credentials, raw votes, or private amounts.
- The witness updates private state deterministically when a later circuit call depends on that update.
- The circuit validates the returned value before relying on it.
Treat witness values as untrusted inputs. If a witness returns a balance, membership flag, score, credential, or secret, the circuit must verify it through commitments, Merkle paths, signatures, nullifiers, public state, or another cryptographic check. Do not accept a witness result because your own frontend returns the expected value.
Add tests for malicious witness values. Replace a valid secret key with another 32-byte value and expect authorization to fail. Return an out-of-range amount and expect an assertion. Return a stale Merkle path and expect rejection.
Pass this section only when every witness has type tests, negative tests, and a circuit-level validation path.
6. Confirm version compatibility
Version mismatches create confusing failures. A proof can fail when generated files, runtime packages, ledger version, and proof server version do not match.
Use the compatibility matrix as the source of truth for the release you target. At the time of this tutorial, the latest tested stack lists Compact devtools 0.5.1, Compact toolchain 0.31.0, Compact runtime 0.16.0, Ledger 8.0.3, Midnight.js 4.0.4, testkit-js 4.0.4, and proof server 8.0.3.
Run these checks in CI:
compact list --installed
compact compile --version
compact compile --language-version
compact compile --runtime-version
compact compile --ledger-version
npm ls --depth=0 | grep '@midnight-ntwrk'
Pin versions in package.json. Do not mix Ledger 8 generated files with a Ledger 7 proof server or older Midnight.js packages. Recompile after every dependency update, even when TypeScript still passes.
Upgrade flow:
compact update 0.31
rm -rf node_modules package-lock.json
npm install
npm run compact
npm run typecheck
npm test
After upgrading, inspect generated files and contract-info.json again. Toolchain 0.31.0 includes public ledger layout in that file.
Pass this section only when versions match the target environment and generated files come from the pinned compiler.
7. Test proof generation on Testnet or a local network
Unit tests are necessary, but they do not replace proof generation. Before Mainnet deployment, run a full flow that generates ZK proofs, submits transactions, waits for finalization, and reads public state.
Start a local proof server with the version from the compatibility matrix:
docker run -p 6300:6300 midnightntwrk/proof-server:8.0.3 midnight-proof-server -v
The proof server listens on port 6300. Keep it running while deploying or interacting with your DApp. Use a local proof server or a controlled endpoint for sensitive test data.
For final pre-production testing, use Preprod. Preview is suitable for early development and experimentation. Preprod is designed for final testing before Mainnet deployment. Mainnet is the production network.
Your proof-generation test should cover:
- Deploy the smart contract.
- Submit a successful transaction for each exported state-changing circuit.
- Wait for the transaction receipt and assert the applied status.
- Query the public ledger state and confirm only approved fields changed.
- Submit a negative transaction for each authorization and replay boundary.
- Restart the DApp process and confirm private-state persistence works.
- Restart the proof server and rerun one representative transaction.
- Record proof-generation duration for the largest circuit.
Pass this section only when a real proof-generation flow succeeds against the environment you plan to use for deployment testing.
CI checklist
Add this checklist to pull requests that modify Compact source, TypeScript witnesses, generated smart contract code, package versions, Docker images, provider configuration, or deployment scripts.
## Midnight deployment security checklist
- [ ] `compact format --check src/` passes.
- [ ] `compact fixup --check src/` passes.
- [ ] Compact source recompiles from a clean generated directory.
- [ ] TypeScript type checking passes.
- [ ] Unit tests cover success paths and negative paths.
- [ ] Every `disclose()` call has a documented public reason.
- [ ] No raw private data appears in exported ledger fields.
- [ ] `ownPublicKey()` is absent from caller verification.
- [ ] Replay protection uses a nonce, nullifier, counter, or round.
- [ ] Witness return types match generated TypeScript types.
- [ ] Malicious witness tests fail safely.
- [ ] Versions match the compatibility matrix.
- [ ] Proof generation succeeds on local network, Preview, or Preprod.
- [ ] Public state after proof submission matches the exported ledger review.
Troubleshooting
The compiler reports a missing disclosure
Do not wrap the earliest private value in disclose() just to silence the compiler. Move disclose() to the public assignment or exported return. Then document the disclosure.
ownPublicKey() appears in authorization
Treat it as a blocker. Replace it with a commitment derived from localSecretKey() or another verified private input. Bind that commitment to a domain separator and replay boundary.
A replay test succeeds twice
Find the missing uniqueness boundary. Add a counter, round, nonce, or nullifier to the commitment derivation. Update or claim it after a successful call.
A sealed field fails compilation
Check whether an exported circuit modifies a sealed ledger field. Sealed fields belong to initialization logic. Use unsealed fields only when post-deployment mutation is required.
Proof generation fails but unit tests pass
Check version compatibility first. Confirm the Compact compiler, generated files, Compact runtime, Ledger version, Midnight.js packages, and proof server image target the same release. Then inspect circuit complexity and witness return types.
Port 6300 is unavailable
Stop the existing process or container using the port. If you remap the host port, update the DApp and Lace proof server configuration to match.
Deployment decision
Deploy only when the checklist produces evidence: command output, test results, source links, generated metadata, transaction hashes, and public ledger snapshots. A passing DApp has a clear disclosure policy, safe authorization, replay boundaries, reviewed public state, correct witnesses, compatible versions, and proof-generation coverage.
Next steps
After this checklist passes, publish the review notes with the release candidate. Include the Compact compiler version, Ledger version, proof server version, target environment, transaction hashes from testing, and the exported ledger review. Keep the checklist in the repository so future feature work cannot bypass it.
Top comments (0)