Skip to content

richard202605/midnight-security-checklist

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 

Repository files navigation

Security Checklist for Midnight dApps Before Deployment

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


Why This Checklist Exists

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.


Phase 1: Pre-Compile — Contract Design Review

1.1 Nullifier Uniqueness

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 localSecretKey or 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)

1.2 State Disclosure Boundaries

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

1.3 Circuit Constraint Completeness

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 for None case
  • All assert statements have clear, descriptive error context
  • No dead circuits (unreachable code paths that skip constraints)

Phase 2: Post-Compile — Verification Suite

2.1 Compilation Modes

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-zk mode (syntax/types)
  • Contract compiles in full mode (ZK circuits)
  • No warnings from either compilation mode
  • Circuit count matches expected number of public entry points

2.2 Unit Testing with Contract Simulator

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 assert statement
  • Nullifier replay tests (same proof submitted twice)
  • Edge cases: zero amounts, max values, empty maps
  • Multi-user scenarios tested with separate witness sets

2.3 Witness Integrity

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

Phase 3: Proof Server Validation

3.1 Local Proof Server Health Check

# 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

3.2 Proof Generation Edge Cases

// 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)

Phase 4: Indexer and State Verification

4.1 Indexer Sync Verification

// 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

4.2 State Consistency Checks

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)

Phase 5: Frontend Integration Security

5.1 Wallet Connection

// 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

5.2 Transaction Error Handling

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

Phase 6: Mainnet Readiness

6.1 Final Security Review

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)

6.2 Monitoring Setup

// 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

Quick Reference: The 10 Most Common Midnight Bugs

# 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

Audit Script

Run the included audit script against your contract:

python3 audit-tools/midnight_audit.py contracts/MyContract.compact

This 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.


Related Resources


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.

About

Security Checklist for Midnight dApps Before Deployment — Tutorial and audit tools

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors