DEV Community

Cover image for Advanced Compact Patterns for Web3 Developers

Advanced Compact Patterns for Web3 Developers

Introduction

If you've spent years building on EVM chains, Midnight's architecture might feel like a paradigm shift. On Ethereum, you push computation onto the blockchain itself. On Midnight, you do the opposite you move computation off-chain and prove it correctly using zero-knowledge proofs.

This isn't just a different implementation detail. It fundamentally changes how you think about state management, data disclosure, and circuit design.

Samantha's foundational guide introduced the three-part structure of Midnight contracts: the public ledger, zero-knowledge circuits, and local computation. But understanding the basics and architecting production systems are two different challenges.

This guide dives into the patterns that separate working prototypes from robust systems. We'll explore how witnesses enable privacy boundaries, why commitments matter more than direct state, how to optimize circuits for real-world constraints, and how to compose multiple private contracts without leaking metadata.

By the end, you'll have concrete strategies for building systems that maintain privacy guarantees while managing the practical tradeoffs of Web3 applications.


Witnesses & Selective Disclosure

Understanding Witnesses Beyond Function Calls

In EVM contracts, all data available to a function is deterministic. The blockchain is your single source of truth. In Compact, witnesses invert this model: witnesses are the only source of truth the contract doesn't control.

// A witness declares a contract's dependency on external data
witness getUserSecret(): Bytes<32>;
witness getProofOfAssets(userId: Uint<64>): AssetsProof;
Enter fullscreen mode Exit fullscreen mode

When you declare a witness, you're saying: "This contract's logic depends on data I cannot verify on-chain. It's the application's responsibility to provide this correctly."

This creates a critical security boundary. The contract trusts the application to supply honest witnesses, but the proof system validates that the application used those witnesses correctly.

The Witness-Disclosure Loop

Real-world contracts don't just consume witnesses they combine witness data with disclosed state to create privacy preserving outcomes.

Consider an age verification system:

pragma language_version 0.22;
import CompactStandardLibrary;

// Public ledger: only record that someone proved they're eligible
export ledger ageVerified: Map<Bytes<32>, Boolean>;
export ledger verificationRound: Counter;

// Private witness: the user's actual birthdate (never on-chain)
witness getUserBirthDate(): Uint<32>; // Unix timestamp

// Derived public key with round counter to prevent replay
circuit derivePublicIdentity(round: Field, secret: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>(
    [round as Bytes<32>, secret]
  );
}

// Main circuit: prove age without revealing birthdate
export circuit verifyAge(secret: Bytes<32>, minAge: Uint<32>): [] {
  // Get private birthdate (witness - not on-chain)
  const birthDate = getUserBirthDate();

  // Compute age
  const currentTimestamp: Uint<32> = 1704067200; // Updated by app
  const age = currentTimestamp - birthDate;

  // Private check: verify age requirement
  assert(age >= minAge, "Age requirement not met");

  // Public disclosure: only record that verification happened
  const identity = derivePublicIdentity(verificationRound.roundNumber, secret);
  ageVerified = disclose(identity, true);
  verificationRound.increment(1);
}
Enter fullscreen mode Exit fullscreen mode

Key pattern: The witness data (getUserBirthDate) is never directly disclosed. Instead, you compute a predicate over it (age >= minAge), and then disclose only the outcome the user consents to.

The tradeoff: The application code that supplies the witness must be trusted. If a malicious DApp sends a false birthdate, the proof system can't detect it—but it will prove the user accepted false data. This is why witness sourcing matters as much as circuit logic.

Practical Consideration: Witness Sourcing

Where do witnesses come from in real applications?

  • User-held secrets: API keys, private keys, personal data
  • External APIs: Proof-of-reserve attestations, oracle feeds, credential issuers
  • Zero-knowledge proofs themselves: A sub-proof generated off-chain that proves something about external data
  • Trusted hardware: TEE attestations or trusted execution environment outputs

Each source has different security properties. A witness from a user's secret key is as strong as that key's protection. A witness from an untrusted API might need cryptographic verification itself.

For example, if your witness comes from an API like "get current asset price," the application must either:

  1. Trust the API (weak)
  2. Verify the API response against multiple oracles (medium)
  3. Require the API to provide a signature from a trusted source (better)
  4. Use sub-proofs to prove the API data meets certain criteria without revealing it (best)

Commitments & Zero-Knowledge Proof Architecture

From State Mutation to Commitment Schemes

EVM developers are accustomed to direct state mutations:

// Ethereum: modify state directly
mapping(address => uint256) balance;
balance[user] += amount;
Enter fullscreen mode Exit fullscreen mode

In Midnight, public state mutations must be proven by a zero-knowledge circuit. This means your public state must be designed around commitment schemes cryptographic structures that let you prove you know a value without revealing it.

Here's the conceptual bridge:

  • EVM thinking: State is a mutable cell. Update it directly.
  • Midnight thinking: State is a commitment to a value. Prove you know the value, then update the commitment.

Building a Private Ledger with Commitments

Let's walk through a private token transfer system:

pragma language_version 0.22;
import CompactStandardLibrary;

// Public state: only commitments to balances, never actual amounts
export ledger balanceCommitment: Map<Bytes<32>, Field>;
export ledger totalSupply: Uint<128>;
export ledger transferRound: Counter;

// Private data structure (never on-chain, only proven)
struct Account {
  owner: Bytes<32>,
  balance: Uint<128>,
  nonce: Uint<64>
}

// Witness: the actual account data, held privately by user
witness getAccount(): Account;
witness getNullifierSecret(): Bytes<32>;

// Helper: derive a nullifier to prevent double-spending
circuit deriveNullifier(nonce: Uint<64>, secret: Bytes<32>): Field {
  return persistentHash<Vector<2, Field>>(
    [persistentHash<Bytes<32>>(nonce as Bytes<32>), 
     persistentHash<Bytes<32>>(secret)]
  ) as Field;
}

// Helper: commitment to an account
circuit commitToAccount(account: Account, salt: Bytes<32>): Field {
  return persistentHash<Vector<2, Field>>(
    [persistentHash<Account>(account),
     persistentHash<Bytes<32>>(salt)]
  ) as Field;
}

// Main circuit: prove a valid token transfer
export circuit transfer(
  recipient: Bytes<32>,
  amount: Uint<128>,
  salt: Bytes<32>,
  newSalt: Bytes<32>
): [] {
  // Load private account data
  const account = getAccount();
  const nullifierSecret = getNullifierSecret();

  // Verify the account commitment exists
  const oldCommitment = commitToAccount(account, salt);
  assert(
    balanceCommitment[account.owner] == oldCommitment,
    "Account commitment mismatch"
  );

  // Private verification: user has sufficient balance
  assert(account.balance >= amount, "Insufficient balance");

  // Compute new account state (private)
  const newAccount: Account = [
    owner: account.owner,
    balance: account.balance - amount,
    nonce: account.nonce + 1
  ];

  // Create nullifier to prevent replay
  const nullifier = deriveNullifier(account.nonce, nullifierSecret);

  // Update public state with new commitment
  const newCommitment = commitToAccount(newAccount, newSalt);
  balanceCommitment = disclose(account.owner, newCommitment);

  // Record nullifier to prevent double-spend
  // In a real system, this would be a set of spent nullifiers
  // For now, we disclose it as proof of spending
  disclose(nullifier);

  // Recipient balance update (simplified: assume recipient pre-existed)
  // In production, you'd handle account creation
  transferRound.increment(1);
}
Enter fullscreen mode Exit fullscreen mode

The Commitment Tradeoff

This approach provides strong privacy but requires careful design:

Advantages:

  • Balances are never visible on-chain (only commitments)
  • Transfers reveal no information except that a transfer occurred
  • The system is composable with other private circuits

Costs:

  • Every balance update requires a full commitment recomputation
  • Clients must store balance commitments locally (or query from a private oracle)
  • Replay protection requires tracking spent nullifiers
  • The circuit is more complex, leading to larger proofs and longer proving times

Real world consideration: For most applications, you won't implement full commitment schemes from scratch. You'll use Midnight's standard library, which provides optimized versions. But understanding the underlying structure helps you choose the right patterns for your use case.


Circuit Optimization

Why Circuit Optimization Matters

Compact circuits must be bounded at compile time. You can't have unbounded loops or recursive calls. This constraint exists because every circuit must compile to a fixed-size zero-knowledge proof.

For EVM developers, this is a significant mindset shift. On Ethereum, you pay gas for computation. On Midnight, you accept predetermined computation bounds.

// This won't compile unbounded recursion
circuit traverse(node: TreeNode): Uint<64> {
  if (node.left == null) {
    return node.value;
  } else {
    return traverse(node.left);
  }
}

// This works—bounded by tree depth
export circuit traverseFixed<#DEPTH>(
  node: TreeNode, 
  path: Vector<#DEPTH, Boolean>
): Uint<64> {
  let current = node;
  for (let i = 0; i < #DEPTH; i++) {
    if (path[i]) {
      current = current.right; // Assumes node structure allows this
    } else {
      current = current.left;
    }
  }
  return current.value;
}
Enter fullscreen mode Exit fullscreen mode

Optimization Strategies

1: Vectorization and Batching

For operations on multiple items, vectorize instead of looping:

// Less efficient: separate proofs for each item
export circuit verifyAge(secret: Bytes<32>, minAge: Uint<32>): [] {
  const birthDate = getUserBirthDate();
  assert(currentTime - birthDate >= minAge, "Too young");
}

// More efficient: batch verification
export circuit verifyAgesInBatch<#N>(
  secrets: Vector<#N, Bytes<32>>,
  minAges: Vector<#N, Uint<32>>
): [] {
  for (let i = 0; i < #N; i++) {
    // Witness supplies ages for all users
    const birthDates = getUserBirthDates(i);
    assert(
      currentTime - birthDates >= minAges[i],
      "Age check failed"
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

2: Lazy Evaluation with Merkle Trees

Instead of processing all data inline, use Merkle trees to prove membership:

// Direct approach: verify all items (O(n) circuit size)
export circuit verifyAllBalances<#N>(
  balances: Vector<#N, Uint<128>>,
  totalRequired: Uint<128>
): [] {
  let sum: Uint<128> = 0;
  for (let i = 0; i < #N; i++) {
    sum = sum + balances[i];
  }
  assert(sum >= totalRequired, "Insufficient total");
}

// Optimized: verify membership in Merkle tree (O(log n) circuit size)
export circuit verifyBalanceProof(
  balance: Uint<128>,
  merkleProof: Vector<32, Field>, // Log2(2^32) = 32 levels
  merkleRoot: Field,
  leaf_index: Uint<32>
): [] {
  // Recompute leaf and verify path
  const leaf = persistentHash<Uint<128>>(balance) as Field;
  let current = leaf;

  for (let i = 0; i < 32; i++) {
    const proofElement = merkleProof[i];
    // Combine in canonical order to prevent tree structure attacks
    if (leaf_index & (1 << i) == 0) {
      current = persistentHash<Vector<2, Field>>([current, proofElement]) as Field;
    } else {
      current = persistentHash<Vector<2, Field>>([proofElement, current]) as Field;
    }
  }

  assert(current == merkleRoot, "Merkle proof failed");
}
Enter fullscreen mode Exit fullscreen mode

3: Proof Aggregation

When you have multiple privacy preserving properties to prove, you have two choices: prove them all in one circuit (larger proof), or split into separate circuits (multiple proofs, sequential verification).

// Single circuit: proves age AND asset ownership
// Proof size: large, proving time: high
export circuit verifyAgeAndAssets(
  secret: Bytes<32>,
  minAge: Uint<32>,
  assetsProof: AssetsProof
): [] {
  const birthDate = getUserBirthDate();
  assert(currentTime - birthDate >= minAge, "Too young");

  const assets = getAssets(assetsProof);
  assert(assets.value >= 100000, "Insufficient assets");
}

// Split circuits: separate concerns, compose on app level
// Proof size: smaller per circuit, proving time: faster
export circuit verifyAge(secret: Bytes<32>, minAge: Uint<32>): Bytes<32> {
  const birthDate = getUserBirthDate();
  assert(currentTime - birthDate >= minAge, "Too young");
  return disclose(persistentHash<Bytes<32>>(secret));
}

export circuit verifyAssets(assetsProof: AssetsProof): Bytes<32> {
  const assets = getAssets(assetsProof);
  assert(assets.value >= 100000, "Insufficient assets");
  return disclose(persistentHash<Bytes<32>>(assetsProof));
}

// Application composes both proofs
// Tradeoff: two proofs to verify, but faster to prove each one
Enter fullscreen mode Exit fullscreen mode

Benchmarking Your Circuits

Different circuit structures have dramatic performance differences. Use this framework to evaluate:

Structure Pros Cons Use When
Direct computation Simple, straightforward Large proof, slow Small bounded operations
Merkle proof verification Logarithmic size, scales well Higher cryptographic complexity Membership checks in large sets
Vectorized batching Efficient for repeated ops Requires uniform structure Batch processing many similar items
Split circuits Faster per-circuit proving Coordination overhead When proofs are logically independent

Multi-Contract Privacy Architecture

Composing Private Contracts

Midnight's biggest strength is that multiple private contracts can interact while maintaining privacy boundaries. However, composing contracts introduces new considerations:

Problem: The Metadata Leak

Even if all data is encrypted, metadata can leak information:

// Privacy leak: contract call pattern reveals intent
export circuit buyAsset(assetId: Uint<64>): Bytes<32> {
  // If specific assetIds always correlate with specific users,
  // blockchain analysis can link buyers to assets even without seeing amounts
  const proof = getOwnershipProof(assetId);
  verify(proof);
  return disclose(persistentHash<Uint<64>>(assetId));
}

// Mitigated: hide specific asset, batch with dummy calls
export circuit batchBuyAssets<#N>(
  assetIds: Vector<#N, Uint<64>>,
  proofs: Vector<#N, Bytes<32>>,
  isReal: Vector<#N, Boolean>
): Vector<#N, Bytes<32>> {
  let results: Vector<#N, Bytes<32>> = [];
  for (let i = 0; i < #N; i++) {
    // Verify proof only if real purchase (circuits execute either way)
    if (isReal[i]) {
      verify(proofs[i]);
    }
    results[i] = disclose(persistentHash<Uint<64>>(assetIds[i]));
  }
  return results;
}
Enter fullscreen mode Exit fullscreen mode

Pattern: Shielded Contract Composition

When one private contract depends on another, you need a protocol for safe interaction:

pragma language_version 0.22;
import CompactStandardLibrary;

// Contract A: Identity registry
export ledger identityCommitment: Map<Bytes<32>, Field>;

export circuit registerIdentity(publicKey: Bytes<32>): [] {
  const commitment = persistentHash<Bytes<32>>(publicKey) as Field;
  identityCommitment = disclose(publicKey, commitment);
}

// Contract B: Private voting (depends on Contract A)
export ledger voteCommitment: Map<Field, Field>; // (identityHash, voteHash)

export circuit castVote(
  publicKey: Bytes<32>,
  voteChoice: Uint<8>,
  salt: Bytes<32>
): [] {
  // Prove participation in Contract A without revealing identity
  const commitment = persistentHash<Bytes<32>>(publicKey) as Field;
  assert(identityCommitment[publicKey] == commitment, "Not registered");

  // Cast vote privately
  const voteHash = persistentHash<Vector<2, Field>>(
    [persistentHash<Uint<8>>(voteChoice) as Field, 
     persistentHash<Bytes<32>>(salt) as Field]
  ) as Field;

  voteCommitment = disclose(commitment, voteHash);
}
Enter fullscreen mode Exit fullscreen mode

Key insight: Contract B proves it respects Contract A's invariants (the user is registered) without revealing the user's identity. This is the foundation of composable privacy.

Considerations: State Consistency

When multiple contracts touch shared state, you must be careful about ordering:

// Race condition possible: State updated after verification
export circuit transferWithFeeShare(
  amount: Uint<128>,
  feeRecipient: Bytes<32>
): [] {
  const balance = getBalance(); // Witness
  assert(balance >= amount + fee, "Insufficient");

  // Race condition: if another proof updates fee rate before on-chain execution,
  // this assertion might have been based on stale assumptions
  const currentFee = feeContract.queryFee();
}

//  Fixed: Include fee data in the proof
export circuit transferWithFeeShare(
  amount: Uint<128>,
  feeRecipient: Bytes<32>,
  expectedFeeRate: Uint<16>, // App provides expected fee
  feeProof: Bytes<32> // Proof that fee rate matches
): [] {
  const balance = getBalance();

  // Verify fee was what we expected at proof time
  verify(feeProof);

  const fee = (amount * expectedFeeRate) / 100000;
  assert(balance >= amount + fee, "Insufficient");
}
Enter fullscreen mode Exit fullscreen mode

Real-World Tradeoffs

Decision Matrix

Use Case Pattern Witness Source Proof Strategy Notes
Privacy-first tokens Commitments + Nullifiers User secret key Split by operation type Requires nullifier tracking
KYC/AML compliance Age/identity verification Credential issuer Selective disclosure Issuer must be trusted
DAO voting Shielded voting + identity registry User secret, registry contract Batched dummy votes Metadata still visible (voting time)
Asset swaps DEX with private pricing** Oracle feeds, user orders Batch matching Requires MEV-resistant ordering

Debugging & Testing Advanced Patterns

Console Logging in Compact

While developing, use logging carefully (it's not available in production proofs):

export circuit debugTransfer(
  recipient: Bytes<32>,
  amount: Uint<128>
): [] {
  const balance = getBalance();

  // Debug logging helps during development
  assert(balance >= amount, "Insufficient");

  // The proof doesn't include debug output, but your app runner sees it
}
Enter fullscreen mode Exit fullscreen mode

Testing Strategy for Circuits

Since circuits must be proven, test thoroughly before deployment:

// TypeScript test harness
import { Contract } from '@midnight-protocol/sdk';

const contract = new Contract(compiledCircuit);

// Test 1: Valid transfer
const validTransfer = await contract.transfer({
  recipient: publicKey,
  amount: 1000n,
  salt: randomSalt,
  newSalt: newRandomSalt
});

assert(validTransfer.proof != null, 'Valid transfer should produce proof');

// Test 2: Insufficient balance should fail
const invalidTransfer = await contract.transfer({
  recipient: publicKey,
  amount: 999999999999n,
  salt: randomSalt,
  newSalt: newRandomSalt
});

assert(invalidTransfer.proof == null, 'Invalid transfer should not produce proof');
Enter fullscreen mode Exit fullscreen mode

Conclusion

Advanced Compact patterns aren't just optimizations they're architectural decisions that shape what's possible in your application.

As you move from learning Compact to building production systems, keep these principles in mind:

  1. Witnesses are your privacy boundary. Choose witness sources carefully; they determine your security model.

  2. Commitments enable privacy at scale. Direct state disclosure doesn't mix with zero-knowledge proofs; use commitments instead.

  3. Circuits must be bounded, but cleverly. Vectorization, Merkle trees, and proof aggregation let you handle complexity without exceeding bounds.

  4. Composition requires explicit coordination. Multiple private circuits can interact, but metadata and state consistency need careful handling.

  5. Privacy and usability are in tension. Batching dummy transactions protects metadata but increases proof sizes. Splitting circuits proves faster but requires coordination. Choose based on your threat model.

Midnight gives you powerful tools for building privacy-first applications. Mastering these patterns lets you use them effectively.


Further Reading

  • Midnight Language Reference: Full Compact syntax and semantics
  • Explicit Disclosure Deep Dive: Understanding the disclose() wrapper and threat models
  • Zero Knowledge Proof Fundamentals: If you want to understand the cryptography behind Compact circuits
  • Previous in this series: Learning Web3 from the Ground Up by Samantha Holstine

Top comments (0)