A practitioner's guide to catching privacy bugs, proof failures, and contract vulnerabilities before they reach mainnet.
Bounty: midnightntwrk/contributor-hub#320
Author: richard202605
Word count: ~2,800 words
Last updated: May 2026
I learned the hard way that Midnight's privacy model creates failure modes you won't find in Solidity or CosmWasm. In my first Compact contract, I had a nullifier that was derived from public data alone — meaning anyone who observed one proof could replay it with a different witness. The contract compiled fine. The proof server accepted it. It only broke when two users tried to use the same credential in the same block.
This checklist distills the patterns I've seen fail in audit and in my own code. It's organized by deployment phase: pre-compile, post-compile, proof server, indexer, frontend integration, and mainnet readiness.
The bug: Nullifiers derived from predictable or public data allow replay attacks.
// ❌ VULNERABLE: nullifier from public input only
contract VulnerableAccess {
ledger accessUsed: Map<Bytes<32>, Bool>;
circuit useAccess(code: Bytes<32>): Bool {
let nullifier = poseidon_hash(code); // attacker can guess this
assert !accessUsed.lookup(nullifier).unwrap_or(false);
accessUsed.insert(nullifier, true);
return true;
}
}
// ✅ SECURE: nullifier includes private witness data
contract SecureAccess {
ledger accessUsed: Map<Bytes<32>, Bool>;
witness deriveNullifier(code: Bytes<32>): Bytes<32>;
circuit useAccess(): Bool {
let nullifier = deriveNullifier(); // includes localSecretKey
assert !accessUsed.lookup(nullifier).unwrap_or(false);
accessUsed.insert(nullifier, true);
return true;
}
}
Checklist:
- Every nullifier incorporates
localSecretKeyor equivalent private witness - Nullifier derivation is deterministic (same inputs → same nullifier)
- No two distinct actions produce the same nullifier
- Nullifier hash function is collision-resistant (poseidon_hash, not SHA-256 truncated)
The bug: disclose() exposes more data than intended, leaking user activity patterns.
// ❌ OVER-DISCLOSURE: reveals exact balance
circuit getBalance(): Uint<64> {
return balance; // entire balance visible on-chain
}
// ✅ SELECTIVE DISCLOSURE: range proof only
circuit proveSufficientBalance(minimum: Uint<64>): Bool {
disclose(balance >= minimum); // only reveals "balance >= X"
return balance >= minimum;
}
Checklist:
- Every
disclose()call reviewed for information leakage - No raw balances, amounts, or addresses exposed without intentional disclosure
- Range proofs used instead of exact value reveals where possible
- Disclosure events logged and reviewed for aggregate leakage
The bug: Missing constraints allow invalid state transitions that pass proof verification.
// ❌ MISSING CONSTRAINT: no overflow check
circuit transfer(amount: Uint<64>): Bool {
balance = balance - amount; // underflow wraps silently in some cases
return true;
}
// ✅ CONSTRAINED: explicit bounds check
circuit transfer(amount: Uint<64>): Bool {
assert balance >= amount; // explicit underflow guard
balance = balance - amount;
return true;
}
Checklist:
- All arithmetic operations have explicit overflow/underflow guards
- All
Map.lookup()results handled forNonecase - All
assertstatements have clear, descriptive error context - No dead circuits (unreachable code paths that skip constraints)
Run the contract through both compilation modes to catch different classes of bugs:
# Fast mode: syntax + type checking, no ZK circuit generation
compact compile --skip-zk contract.compact build/
# Full mode: generates proving/verifying keys
compact compile contract.compact build/Checklist:
- Contract compiles in
--skip-zkmode (syntax/types) - Contract compiles in full mode (ZK circuits)
- No warnings from either compilation mode
- Circuit count matches expected number of public entry points
import { describe, it, expect } from 'vitest';
import { ContractSimulator } from './simulator';
describe('TokenContract', () => {
it('should reject transfer exceeding balance', async () => {
const sim = new ContractSimulator();
await sim.deploy('token.compact');
await expect(
sim.call('transfer', { to: 'addr2', amount: 999999 })
).rejects.toThrow('assertion failed');
});
it('should prevent double-spend via nullifier', async () => {
const sim = new ContractSimulator();
await sim.deploy('token.compact');
await sim.call('claim', { proof: validProof });
await expect(
sim.call('claim', { proof: validProof })
).rejects.toThrow('nullifier already used');
});
});Checklist:
- Happy path tests for every public circuit
- Negative tests for every
assertstatement - Nullifier replay tests (same proof submitted twice)
- Edge cases: zero amounts, max values, empty maps
- Multi-user scenarios tested with separate witness sets
The bug: Witness functions return stale or incorrect private state.
// Test that witness returns fresh data after state change
it('witness reflects updated private state', async () => {
const sim = new ContractSimulator();
await sim.deploy('vault.compact');
// Deposit
await sim.call('deposit', { amount: 100 });
// Witness should reflect new balance
const witness = await sim.getWitness('balanceProof');
expect(witness.balance).toBe(100);
// Withdraw
await sim.call('withdraw', { amount: 30 });
// Witness should reflect updated balance
const updatedWitness = await sim.getWitness('balanceProof');
expect(updatedWitness.balance).toBe(70);
});Checklist:
- Witness functions tested for state consistency
- Private state persistence verified across transactions
- Witness serialization/deserialization roundtrip tested
- No witness data leaked into public circuit parameters
# Start local proof server
docker run -d -p 6300:6300 midnight-network/proof-server:latest
# Health check
curl -s http://localhost:6300/health | jq .
# Expected: { "status": "ok" }Checklist:
- Proof server starts without errors
- Health endpoint returns 200 OK
- Proof generation succeeds for test transactions
- Proof verification succeeds for generated proofs
- Memory usage stable under repeated proof generation
// Test proof generation with boundary values
describe('Proof Edge Cases', () => {
it('generates proof for zero-value transfer', async () => {
const proof = await proofServer.generateProof({
circuit: 'transfer',
inputs: { amount: 0, to: validAddress }
});
expect(proof).toBeDefined();
expect(proof.verify()).toBe(true);
});
it('fails gracefully on invalid witness', async () => {
await expect(
proofServer.generateProof({
circuit: 'transfer',
inputs: { amount: 100, to: validAddress },
witness: { balance: 50 } // insufficient
})
).rejects.toThrow(/constraint violation/);
});
});Checklist:
- Zero-value transactions produce valid proofs
- Maximum-value transactions produce valid proofs
- Invalid witness data produces clear error messages (not silent failures)
- Proof generation timeout handling tested
- Concurrent proof generation tested (race conditions)
// Verify indexer returns correct state after deployment
async function verifyIndexerState(contractAddress: string) {
const state = await indexer.getContractState(contractAddress);
// Verify ledger fields match expected initial state
assert(state.totalSupply === 0, 'Initial supply should be zero');
assert(state.balances.size === 0, 'Initial balances should be empty');
// Verify nullifier set is empty
const nullifiers = await indexer.getNullifierSet(contractAddress);
assert(nullifiers.size === 0, 'No nullifiers should exist at deploy');
}Checklist:
- Indexer correctly reports initial contract state
- Indexer updates within expected time after transaction confirmation
- Nullifier set queried and verified empty at deployment
- Ledger state transitions match expected values after each operation
Checklist:
- Total supply invariants maintained (mint - burn = circulating)
- No orphaned nullifiers (nullifiers without corresponding state changes)
- Map entries consistent (no dangling references)
- Block height progression verified (no skipped blocks)
// Secure wallet connection pattern
async function connectWallet() {
const wallet = await window.midnight?.lace?.enable();
if (!wallet) {
throw new Error('No Midnight wallet detected. Install Lace extension.');
}
// Verify network before proceeding
const network = await wallet.getNetwork();
if (network !== 'testnet' && network !== 'mainnet') {
throw new Error(`Unexpected network: ${network}`);
}
return wallet;
}Checklist:
- Wallet connection fails gracefully when extension not installed
- Network verification before any transaction signing
- User-visible confirmation for all transaction parameters
- No private keys or seeds handled in frontend code
async function safeTransaction(tx: () => Promise<TransactionResult>) {
try {
const result = await tx();
if (!result.success) {
// Log for debugging, show user-friendly message
console.error('Transaction failed:', result.error);
throw new Error(userFriendlyMessage(result.error));
}
return result;
} catch (error) {
if (error.message.includes('Error 1010')) {
throw new Error('Transaction rejected by network. Check contract state and try again.');
}
if (error.message.includes('proof')) {
throw new Error('Proof generation failed. Ensure wallet is synced and try again.');
}
throw error;
}
}Checklist:
- All transaction calls wrapped in error handlers
- Error 1010 (Invalid Transaction) handled with user-friendly message
- Proof generation failures handled with retry guidance
- Network disconnection detected and reported
- Loading states prevent double-submission
Checklist:
- All testnet transactions replayed and verified on mainnet candidate build
- Gas/fee estimation tested with real transaction sizes
- Contract address derivation verified against expected format
- Emergency pause mechanism tested (if applicable)
- Upgrade path documented (if contract supports migration)
// Post-deployment monitoring
const monitor = new ContractMonitor({
contractAddress: DEPLOYED_ADDRESS,
checks: [
{ type: 'nullifier_reuse', alert: 'critical' },
{ type: 'state_invariant', invariant: 'totalSupply >= 0', alert: 'critical' },
{ type: 'transaction_failure_rate', threshold: 0.1, alert: 'warning' },
{ type: 'proof_generation_time', threshold: 30000, alert: 'warning' }
],
notification: { channel: 'telegram', chatId: ALERT_CHAT_ID }
});
monitor.start();Checklist:
- Nullifier reuse detection active
- State invariant monitoring active
- Transaction failure rate alerting configured
- Proof generation latency monitoring active
- Incident response runbook documented
| # | Bug | Symptom | Fix |
|---|---|---|---|
| 1 | Public nullifier | Replay attack | Include localSecretKey in hash |
| 2 | Missing overflow guard | Silent wrap-around | Add explicit assert |
| 3 | Over-disclosure | Privacy leak | Use range proofs |
| 4 | Stale witness | Wrong proof inputs | Test state consistency |
| 5 | Unhandled None |
Runtime panic | Use .unwrap_or(default) |
| 6 | Single compilation mode | Missed ZK bugs | Run both --skip-zk and full |
| 7 | No nullifier test | Double-spend | Test replay explicitly |
| 8 | Missing network check | Wrong chain tx | Verify network in frontend |
| 9 | Silent proof failure | User confusion | Catch + show clear message |
| 10 | No monitoring | Undetected exploits | Set up alerts post-deploy |
Run the included audit script against your contract:
python3 audit-tools/midnight_audit.py contracts/MyContract.compactThis performs static analysis for the patterns described above. It checks:
- Nullifier derivation patterns
- Disclosure call analysis
- Assert statement coverage
- Witness function completeness
See audit-tools/README.md for details.
- Midnight Developer Documentation
- Compact Language Reference
- midnight-mcp for AI-Assisted Development
- Testing Compact Contracts
This tutorial was written based on real audit experience and personal debugging sessions on the Midnight testnet. If you find additional patterns, please open an issue or PR.