Security Checklist for Midnight dApps Before Deployment
Midnight Network brings privacy-preserving smart contracts to Web3 through zero-knowledge proofs and programmable confidentiality. But with great privacy power comes great security responsibility. This checklist will help you catch common vulnerabilities before your dApp goes live.
Why This Checklist Matters
Midnight's Compact language enforces privacy by default — all data stays private unless you explicitly disclose() it. This is powerful, but it also means:
- One misplaced
disclose()can leak sensitive data permanently - Witness functions run outside ZK circuits and can be manipulated
- The
ownPublicKey()function has a known vulnerability that many developers miss - Replay protection requires careful implementation of nonces and nullifiers
Let's walk through each security area systematically.
Pre-Deployment Checklist
✅ 1. disclose() Audit — No Secret Leaks
disclose() is the only mechanism for moving private data to the public ledger. Every disclose() call in your code is a conscious decision to make something public.
What to check:
// ❌ BAD: Early disclosure exposes the value for all subsequent operations
export circuit store(flag: Boolean): [] {
const secret = disclose(getSecret()); // Public too early!
const derived = computeValue(secret); // `secret` is now visible
result = disclose(flag) ? derived : 0;
}
// ✅ GOOD: Disclose only at the point of use
export circuit store(flag: Boolean): [] {
const secret = getSecret();
const derived = computeValue(secret); // Still private
result = disclose(flag) ? disclose(derived) : 0; // Specific, targeted disclosure
}
Checklist:
- [ ] Search your codebase for every
disclose()call - [ ] Verify each disclosure is intentional and minimal
- [ ] Ensure
disclose()is positioned as close to the usage point as possible - [ ] Check that no code path accidentally discloses values that should remain private
- [ ] Use underscore prefixes (
_sk,_secret) for values that must never be disclosed
Pro tip: The Compact compiler will catch attempts to assign private values to ledger fields without disclose(), but it won't catch premature disclosures. Manual review is essential.
✅ 2. ownPublicKey() Usage Review (Known Vulnerability)
This is one of the most common security mistakes in Midnight development.
⚠️ WARNING: Do NOT use ownPublicKey() for caller verification!
ownPublicKey() is technically a witness function. This means each user's frontend can produce a malicious return value. An attacker can modify their local witness implementation to return any public key they choose.
// ❌ CRITICAL VULNERABILITY: Using ownPublicKey() for authorization
export circuit transfer(to: Bytes<32>, amount: Uint<64>): [] {
const sender = ownPublicKey(); // Attacker can return ANY key!
assert(balances.member(sender), "No balance");
// ... transfer logic using spoofed sender
}
// ✅ CORRECT: Hash-based authentication
witness secretKey(): Bytes<32>;
circuit publicKey(_sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "midnight:auth:pk"),
_sk
]);
}
export circuit authorizedOperation(newValue: Bytes<32>): [] {
const _sk = secretKey();
const pk = publicKey(_sk);
assert(disclose(pk) == authority, "Authorization failed");
ledgerValue = disclose(newValue);
}
Checklist:
- [ ] Search for all
ownPublicKey()calls in your codebase - [ ] Verify none are used as the sole authorization mechanism
- [ ] Replace any authorization logic with hash-based patterns
- [ ] If you must use
ownPublicKey(), add additional verification layers
✅ 3. Replay Protection Verification (Nonces and Nullifiers)
Midnight uses the commitment/nullifier pattern (from Zerocash/Zswap) to prevent double-spending without revealing which resource was spent.
How nullifiers work:
export ledger usedNullifiers: Set<Bytes<32>>;
circuit nullifier(secretKey: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "nullifier-domain"), // Domain separator!
secretKey
]);
}
export circuit spend(secretKey: Bytes<32>): [] {
const nul = nullifier(secretKey);
assert(!usedNullifiers.member(nul), "Already spent");
usedNullifiers.insert(disclose(nul));
}
Critical requirements:
- [ ] Domain separators: Commitments and nullifiers MUST use different domain prefixes to prevent hash collision attacks
- Good:
"nullifier-my-dapp-v1"vs"commitment-my-dapp-v1" - Bad: Using the same prefix for both
- Good:
- [ ] No randomness reuse: Never reuse randomness across commitments — this enables linking and breaks privacy
- [ ] Use
persistentHash: For values stored in ledger state (guaranteed consistent across upgrades) - [ ] Round counters: Include time-bound counters for operations to prevent replay of old proofs
✅ 4. Exported Ledger Field Review
Every export ledger field is publicly visible on-chain. Review each one:
export ledger balance: Counter;
export ledger authority: Bytes<32>;
export ledger usedNullifiers: Set<Bytes<32>>;
Checklist:
- [ ] Verify each ledger field genuinely needs to be public
- [ ] Check that no private data is accidentally stored in ledger fields
- [ ] Ensure ledger field types match their intended use (Counter vs Uint, etc.)
- [ ] Review access patterns — who can read and modify each field?
✅ 5. Witness Implementation Correctness
Witnesses are off-chain TypeScript functions that run outside the ZK circuit. They are NOT cryptographically verified.
// Witness implementation in TypeScript
export const witnesses = {
secretKey: ({ privateState }: WitnessContext<Ledger, PrivateState>) =>
[privateState, privateState.secretKey],
getUserBalance: ({ privateState }: WitnessContext<Ledger, PrivateState>) =>
[privateState, privateState.balance],
};
Security implications:
- Each user provides their own witness implementation
- An attacker can return any value from a witness function
- Contract logic must never trust witness values without validation
Checklist:
- [ ] Every witness output is validated with
assertbefore use in circuits - [ ] No critical logic depends solely on witness return values
- [ ] Witness functions return consistent
[PrivateState, ReturnValue]tuples - [ ] No external dependencies in witnesses that might change between invocations
- [ ] Test witness implementations for consistency across multiple calls
✅ 6. Version Compatibility Confirmation
Midnight's toolchain evolves rapidly. Before deployment:
Checklist:
- [ ] Verify
pragma language_versionmatches your installed Compact compiler - [ ] Check that all dependencies (midnight-mcp, proof-server) are compatible versions
- [ ] Review the Midnight changelog for breaking changes
- [ ] Test compilation with the exact version specified in your contract
✅ 7. Proof Generation Testing on Testnet
Before mainnet, verify your proof generation pipeline works end-to-end.
Testnet tiers:
| Tier | Purpose | Tokens |
|------|---------|--------|
| Local Devnet | Development | Unlimited |
| Preview | Integration testing | Test NIGHT |
| Preprod | Final validation | Preprod faucet |
Checklist:
- [ ] Deploy contract to Local Devnet and verify all circuits
- [ ] Test proof generation for every circuit path
- [ ] Verify proof-server handles concurrent requests
- [ ] Test edge cases: zero values, maximum values, empty inputs
- [ ] Run the full transaction lifecycle: deploy → interact → verify state
- [ ] Test on Preprod before mainnet deployment
Proof server verification:
# Check proof server is running
curl http://localhost:6300/health
# Test proof generation
compact prove --contract ./managed/my-contract \
--circuit myCircuit \
--inputs ./test-inputs.json
Common Security Pitfalls
Pitfall 1: Trusting Frontend Input
Never trust data from the frontend without on-chain verification. The user controls their client.
Pitfall 2: Missing Domain Separation
Always use unique domain separators for different hash operations:
// Good: Different domains for different purposes
persistentHash<Vector<2, Bytes<32>>>([pad(32, "commitment-v1"), data])
persistentHash<Vector<2, Bytes<32>>>([pad(32, "nullifier-v1"), data])
Pitfall 3: Premature Disclosure
Position disclose() as late as possible in the computation chain.
Pitfall 4: Unvalidated Witness Data
Always assert witness outputs before using them in circuit logic.
Pitfall 5: Ignoring Linkability
Use round counters or other mechanisms to break transaction linkability when privacy matters.
Quick Reference: Security Patterns
| Pattern | Use Case | Key Point |
|---|---|---|
| Hash-based auth | Caller verification | Don't use ownPublicKey() alone |
| Commit-reveal | Sealed bids, auctions | Two-phase commitment |
| Domain separators | All hash operations | Unique prefixes per purpose |
| Round counters | Breaking linkability | Invalidate old keys |
| Late disclosure | All disclose() calls |
Minimize exposure window |
Resources
Summary
Before deploying your Midnight dApp, verify:
- ✅ disclose() audit — Every disclosure is intentional and minimal
- ✅ ownPublicKey() review — No reliance on this for authorization
- ✅ Replay protection — Proper nonces, nullifiers, and domain separators
- ✅ Ledger fields — No accidental public exposure of private data
- ✅ Witness validation — All witness outputs are asserted before use
- ✅ Version compatibility — Compiler and dependencies aligned
- ✅ Testnet verification — Full proof generation pipeline tested
Privacy-preserving dApps are powerful, but they require extra diligence. Run this checklist before every deployment, and your users' data will stay safe.
📌 Disclaimer: This guide is for educational purposes. Always stay updated with the latest Midnight Network documentation and security advisories. The security landscape evolves rapidly.
Found this helpful? Follow for more Web3 security content. Questions? Drop a comment below.
Top comments (0)