DEV Community

Tosh
Tosh

Posted on

Security Checklist for Midnight dApps Before Deployment

Security Checklist for Midnight dApps Before Deployment

I've been going through Midnight's Compact contracts over the past few weeks and kept hitting the same class of bugs — not the kind that throw errors during compilation, but the kind that silently misbehave at runtime. ZK architecture introduces failure modes that EVM developers don't have muscle memory for: constraint violations that only surface during proof generation, disclosure leaks that pass all your unit tests, witness bugs that produce valid-looking but incorrect proofs.

This is the checklist I put together after working through these issues. It's focused specifically on Midnight — not generic smart contract security.


1. disclose() Audit: No Unintended Secret Leaks

The disclose() operation in Compact moves data from private state to public state. It's necessary for transparency (e.g., emitting events, publishing commitments) but dangerous when applied carelessly.

What to look for:

// DANGEROUS: Accidentally disclosing private user data
circuit completeOrder(
  userBalance: Uint64,    // private
  orderId: Field,         // public
): void {
  // This discloses the private balance — DON'T DO THIS
  disclose(userBalance);
  disclose(orderId);  // OK: orderId was already public
}
Enter fullscreen mode Exit fullscreen mode

Correct pattern:

circuit completeOrder(
  userBalance: Uint64,    // private
  orderId: Field,         // public
  orderAmount: Uint64,    // private
): void {
  // Only disclose what needs to be public
  assert(userBalance >= orderAmount);
  disclose(orderId);
  disclose(orderAmount);  // OK: amount is disclosed by design
  // userBalance stays private
}
Enter fullscreen mode Exit fullscreen mode

Checklist items:

  • [ ] Enumerate every disclose() call in your codebase
  • [ ] Confirm each disclosure is intentional and documented
  • [ ] Ask: "If an adversary sees this disclosed value, can they infer private state?"
  • [ ] Check for indirect disclosure (disclosing a value derived from private data can leak the private data)

2. ownPublicKey() Usage Review

ownPublicKey() retrieves the caller's public key within a circuit. There's a known vulnerability pattern: if you use the result of ownPublicKey() in a way that allows an adversary to choose a specific key for them, you may enable impersonation.

The vulnerability pattern:

// VULNERABLE: attacker can craft inputs to pass as someone else
circuit withdrawFunds(
  claimedKey: PublicKey,
  signature: Signature,
  amount: Uint64,
): void {
  // If signature verification is flawed, anyone can withdraw
  verifySignature(claimedKey, signature, amount);
  transfer(claimedKey, amount);
}
Enter fullscreen mode Exit fullscreen mode

Safer pattern — use ownPublicKey() directly:

circuit withdrawFunds(
  amount: Uint64,
): void {
  // The key comes from the ZK proof itself, not from caller input
  const caller: PublicKey = ownPublicKey();
  assert(balances[caller] >= amount);
  balances[caller] -= amount;
}
Enter fullscreen mode Exit fullscreen mode

Checklist items:

  • [ ] If you accept PublicKey as a parameter, verify you have a cryptographic reason for doing so
  • [ ] Prefer ownPublicKey() over accepting keys as inputs wherever possible
  • [ ] Review every function that accepts a PublicKey parameter for authorization logic
  • [ ] Ensure key derivation functions aren't exploitable with crafted inputs

3. Replay Protection: Nonces and Nullifiers

Without replay protection, an attacker can resubmit a valid ZK proof to execute the same operation multiple times. This is the ZK equivalent of a reentrancy attack.

Two standard mechanisms:

Nonce-based protection (for sequential operations):

export ledger {
  nonces: Map<PublicKey, Uint64>;
}

circuit executeAction(
  nonce: Uint64,
  // ... other params
): void {
  const caller = ownPublicKey();
  const expectedNonce = nonces[caller];
  assert(nonce === expectedNonce, "Invalid nonce");
  nonces[caller] = expectedNonce + 1;
  // ... rest of logic
}
Enter fullscreen mode Exit fullscreen mode

Nullifier-based protection (for one-time use):

export ledger {
  spentNullifiers: Set<Field>;
}

circuit spendNote(
  noteCommitment: Field,
  nullifier: Field,     // derived from the note's secret
  // ... other params
): void {
  assert(!spentNullifiers.has(nullifier), "Note already spent");
  spentNullifiers.add(nullifier);
  // ... process note
}
Enter fullscreen mode Exit fullscreen mode

Checklist items:

  • [ ] Every state-modifying operation has replay protection
  • [ ] Nonces are user-specific, not global (global nonces serialize all transactions)
  • [ ] Nullifiers are cryptographically bound to the specific note/credential being consumed
  • [ ] Nullifier derivation doesn't leak private data about the note

4. Exported Ledger Field Review

Midnight's dual ledger model lets you mark fields as exported to the public ledger. Exported fields are visible to all observers. Review every exported field as if it will be on a public blockchain forever — because it will be.

What to audit:

export ledger {
  // These are ALL visible publicly:
  totalSupply: Uint64;           // OK: intentionally public
  merkleRoot: Field;             // OK: commitment, no private info
  @private userBalances: Map<...>; // OK: private, only commitments exported

  // POTENTIAL ISSUE: Is this field revealing too much?
  lastTransactionTime: Timestamp;  // Leaks activity patterns
  participantCount: Uint64;        // May correlate with private activity
}
Enter fullscreen mode Exit fullscreen mode

Checklist items:

  • [ ] List every exported ledger field
  • [ ] For each field: "What does an adversary learn from watching this field change?"
  • [ ] Check for timing-channel leaks (when something changes can be as revealing as what changes)
  • [ ] Verify that commitment schemes don't leak set membership information
  • [ ] Consider correlation attacks: can exported fields be combined with other public data to infer private state?

5. Witness Implementation Correctness

Witnesses are the off-chain computations that generate inputs to ZK circuits. A bug in a witness doesn't fail with an error — it generates incorrect proofs that may still verify. This is the sneakiest class of Midnight bug.

Common witness bugs:

// TypeScript witness implementation
async function generateTransferWitness(
  fromSecret: SecretKey,
  toAddress: PublicKey,
  amount: bigint,
  currentBalance: bigint,
): Promise<TransferWitness> {
  // BUG: Off-by-one in balance check
  // The circuit checks balance >= amount
  // But the witness passes balance - 1, which will fail constraint
  return {
    balance: currentBalance - 1n,  // WRONG
    amount: amount,
    // ...
  };

  // CORRECT:
  // return {
  //   balance: currentBalance,
  //   amount: amount,
  // };
}
Enter fullscreen mode Exit fullscreen mode

Checklist items:

  • [ ] Every circuit constraint has a corresponding witness computation test
  • [ ] Witness inputs match circuit input types precisely (bit widths, field sizes)
  • [ ] Test witnesses with edge cases: zero values, maximum values, equal values (e.g., amount == balance)
  • [ ] Verify witness generation doesn't fail silently — errors should propagate, not return invalid witnesses
  • [ ] Test that invalid witnesses correctly fail proof generation

6. Version Compatibility Confirmation

Midnight's SDK is evolving. API changes between versions can break proof generation in non-obvious ways.

What can break between versions:

  • Constraint system changes invalidate old proofs
  • API renames cause silent failures if TypeScript types are too permissive
  • Proving key format changes require key regeneration

Checklist items:

  • [ ] Pin exact SDK versions in package.json (no ^ or ~ prefixes for Midnight packages)
  • [ ] Check Midnight's changelog for breaking changes before upgrading
  • [ ] Regenerate proving keys after any SDK upgrade — don't reuse keys from a previous version
  • [ ] Test the full proof generation pipeline after version updates, not just compilation
  • [ ] Document the exact SDK version in your deployment runbook

Pin versions explicitly:

{
  "dependencies": {
    "@midnight-ntwrk/compact-runtime": "0.14.0",
    "@midnight-ntwrk/midnight-js-types": "0.14.0",
    "@midnight-ntwrk/midnight-js-contracts": "0.14.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

7. Proof Generation Testing on Testnet

Testnet is where you find proof generation failures, gas estimation issues, and latency problems. Deploy early, test thoroughly.

Proof generation failure modes:

  • Constraint violations: Circuit assertions fail during witness generation
  • Out-of-memory: Complex circuits require significant RAM for proof generation
  • Timeout: Proof generation takes longer than client timeout limits
  • Stale proving keys: Circuit changed without regenerating keys

Testnet testing checklist:

  • [ ] Complete the full transaction lifecycle: generate witness → generate proof → submit → verify
  • [ ] Test with realistic data sizes (if your circuit handles 1000 items, test with 1000 items)
  • [ ] Measure proof generation time on a standard consumer machine, not a dev workstation
  • [ ] Test error handling: what happens when proof generation fails?
  • [ ] Verify all circuit variants are tested (don't only test the happy path)
  • [ ] Confirm wallet integration works with the proof server
  • [ ] Test behavior when the proof server is slow or unreachable

Latency benchmarking:

# Time a full proof generation cycle
time npx compact-cli prove \
  --circuit transfer \
  --inputs ./test-inputs.json \
  --proving-key ./proving-key.bin \
  --output ./proof.bin
Enter fullscreen mode Exit fullscreen mode

The Pre-Deployment Checklist Summary

Before mainnet deployment, confirm:

Disclosure

  • [ ] All disclose() calls are intentional and documented
  • [ ] No indirect private data leaks through disclosed derived values

Authorization

  • [ ] ownPublicKey() used for caller identification
  • [ ] No PublicKey parameters accepted without cryptographic justification

Replay Protection

  • [ ] All state-modifying circuits have nonce or nullifier protection
  • [ ] Nullifiers are cryptographically bound to specific consumed resources

Ledger Design

  • [ ] Every public ledger field is intentionally public
  • [ ] Timing channels and correlation attacks are considered

Witness Correctness

  • [ ] All witnesses have unit tests
  • [ ] Edge cases are covered (zero, max, equal values)

Version Management

  • [ ] Exact SDK versions pinned
  • [ ] Proving keys match current circuit version

Testnet Validation

  • [ ] Full lifecycle tested end-to-end
  • [ ] Proof generation benchmarked on standard hardware
  • [ ] Error handling verified

A Note on What This Checklist Doesn't Cover

Security doesn't stop here. This checklist covers Midnight-specific issues. You also need to audit:

  • Off-chain witness code for standard application security vulnerabilities (input validation, authentication, etc.)
  • Frontend code for wallet interaction security
  • Key management — how are secret keys stored and used in production?
  • The deployment process itself — who controls the upgrade keys?

ZK cryptography is only as secure as the code around it. A mathematically perfect ZK circuit can be defeated by a compromised witness generator.

Privacy bugs don't announce themselves. There's no revert, no thrown exception — just data that was supposed to stay hidden showing up somewhere it shouldn't. Testnet is cheap, mainnet isn't. Worth the time to go through this before you ship.

Top comments (0)