Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Testing and Debugging

This note covers how to test Compact contracts at every layer, read and fix compiler errors, and keep the toolchain in sync.

Docs: Test and Debug · Static and Dynamic Errors · FAQ Examples: 14.01 Static Errors


Intuition First

Compact contracts execute across three distinct contexts, the public ledger, ZK circuits, and local witness functions. Each context has different failure modes, and each requires a different testing strategy.

This chapter covers both halves:

  • Testing, how to verify circuit logic, state transitions, privacy properties, and authorization before deployment.
  • Debugging, how to read compiler errors, trace runtime failures, and manage versions across the toolchain.

Debugging alone is not enough. By the time you’re debugging, something has already gone wrong. Testing is how you catch it first.


Why Test Compact Contracts?

Most languages can get away with testing happy paths. Compact contracts cannot. There are three reasons:

1. Errors on-chain are expensive. A deployed contract with a flawed authorization check or a privacy leak cannot be patched. You redeploy.

2. ZK circuits are not functions. A circuit declares constraints. The proof proves those constraints held. Testing that the constraint logic is correct requires deliberate effort, it won’t surface from running the app normally.

3. Witness data is untrusted. The proof guarantees the circuit ran correctly given whatever witnesses returned. It says nothing about whether witnesses returned sensible values. Testing needs to cover adversarial witness inputs.


The Testing Stack

LayerWhat to testHow
Circuit logicCorrect outputs and state transitionscontract.impureCircuits in vitest
PrivacyPrivate data doesn’t appear in public outputsInspect newLedgerState vs private state
AuthorizationInvalid callers are rejectedNegative tests with wrong keys
IntegrationFull transaction lifecycle on-chainSubmit + tx.wait() on Preprod
PerformanceProof generation completes in timeMeasure under realistic circuit complexity

Unit Testing: Circuit Logic

The compiled contract exposes impureCircuits, a set of functions you can call directly in tests without deploying to a network or generating proofs. This is your fastest feedback loop.

import { describe, it, expect } from 'vitest';
import { Contract } from '../managed/counter/contract/index.js';
import { witnesses, type CounterPrivateState } from '../witnesses.js';

describe('Counter circuit', () => {
  it('should increment counter value', async () => {
    const contract = new Contract(witnesses);
    const privateState: CounterPrivateState = { privateCounter: 0 };

    const context = {
      privateState,
      ledgerState: { round: 0n }
    };

    const result = contract.impureCircuits.increment(context);

    expect(result.newLedgerState.round).toBe(1n);
  });
});

impureCircuits returns a result object with:

  • newLedgerState, the updated ledger after the circuit ran
  • newContext, the full updated context, pass this into the next call for chained tests

Chaining circuit calls

it('should track multiple increments correctly', async () => {
  const contract = new Contract(witnesses);
  let context = {
    privateState: { privateCounter: 0 },
    ledgerState: { round: 0n }
  };

  for (let i = 0; i < 5; i++) {
    const result = contract.impureCircuits.increment(context);
    context = result.newContext;  // carry state forward
  }

  expect(context.ledgerState.round).toBe(5n);
});

Unit Testing: State Transitions

Contracts that model state machines need explicit transition tests, both valid paths and invalid ones.

import { Contract } from '../managed/bboard/contract/index.js';
import { witnesses, type BBoardPrivateState } from '../witnesses.js';
import { State } from '../managed/bboard/contract/index.js';

describe('Bulletin board state transitions', () => {
  it('should move VACANT → OCCUPIED on post', async () => {
    const contract = new Contract(witnesses);
    const privateState: BBoardPrivateState = { secretKey: new Uint8Array(32) };

    const context = {
      privateState,
      ledgerState: {
        state: State.VACANT,
        message: { is_some: false, value: '' },
        sequence: 0n,
        owner: new Uint8Array(32)
      }
    };

    const result = contract.impureCircuits.post(context, 'Hello');

    expect(result.newLedgerState.state).toBe(State.OCCUPIED);
    expect(result.newLedgerState.message.value).toBe('Hello');
  });

  it('should reject posting to an OCCUPIED board', async () => {
    // ...post first to get occupied state...
    expect(() => {
      contract.impureCircuits.post(occupiedContext, 'World');
    }).toThrow('Board is occupied');
  });
});

Rule: For every valid transition, write a test. For every invalid transition, write a test that expects a throw.


Privacy Verification Testing

Privacy tests verify that private data does not appear in public outputs. These are easy to skip and expensive to miss.

it('should not expose secret key in ledger state', async () => {
  const contract = new Contract(witnesses);
  const secretKey = new Uint8Array(32);
  crypto.getRandomValues(secretKey);
  const privateState: BBoardPrivateState = { secretKey };

  const context = {
    privateState,
    ledgerState: {
      state: State.VACANT,
      message: { is_some: false, value: '' },
      sequence: 0n,
      owner: new Uint8Array(32)
    }
  };

  const result = contract.impureCircuits.post(context, 'Message');

  // The owner field contains a derived public key hash, not the raw secret key
  expect(result.newLedgerState.owner).toBeDefined();
  expect(result.newLedgerState.owner).not.toEqual(secretKey);

  // The secret key remains in private state only
  expect(result.newContext.privateState.secretKey).toEqual(secretKey);

  // The message is public (explicitly disclosed)
  expect(result.newLedgerState.message.value).toBe('Message');
});

What to check in every privacy test:

  • Sensitive inputs (secret keys, private balances) should not appear verbatim in newLedgerState
  • The ledger should contain derived values (hashes, commitments), not raw secrets
  • Private state stays in newContext.privateState, not the ledger

Negative Testing

Negative tests verify that your authorization checks and input validation actually work. These are the tests that catch security vulnerabilities.

Testing assertion failures

describe('Authorization', () => {
  it('should reject takeDown from a different key', async () => {
    const ownerKey = new Uint8Array(32);
    const attackerKey = new Uint8Array(32);
    crypto.getRandomValues(ownerKey);
    crypto.getRandomValues(attackerKey);

    // Post with owner key
    const postResult = contract.impureCircuits.post(
      { privateState: { secretKey: ownerKey }, ledgerState: vacantState },
      'Message'
    );

    // Attempt takeDown with a different key
    expect(() => {
      contract.impureCircuits.takeDown({
        privateState: { secretKey: attackerKey },
        ledgerState: postResult.newLedgerState
      });
    }).toThrow('Not authorized');
  });

  it('should reject operating on a vacant board', async () => {
    expect(() => {
      contract.impureCircuits.takeDown({
        privateState: { secretKey: someKey },
        ledgerState: vacantState
      });
    }).toThrow('Board is vacant');
  });
});

Testing double-spend prevention

If your contract uses sequences or nullifiers to prevent replay, test that they work:

it('should increment sequence on each post-takedown cycle', async () => {
  const secretKey = new Uint8Array(32);
  crypto.getRandomValues(secretKey);

  const post1 = contract.impureCircuits.post(
    { privateState: { secretKey }, ledgerState: vacantState },
    'First message'
  );
  expect(post1.newLedgerState.sequence).toBe(1n);

  const takeDown = contract.impureCircuits.takeDown({
    privateState: { secretKey },
    ledgerState: post1.newLedgerState
  });

  const post2 = contract.impureCircuits.post(
    { privateState: { secretKey }, ledgerState: takeDown.newLedgerState },
    'Second message'
  );

  // Sequence increments, the same key produces a different public key each round
  expect(post2.newLedgerState.sequence).toBe(2n);
});

The sequence increment is what prevents replay attacks. Test that it advances on every cycle.


Integration Testing

Integration tests run the full transaction lifecycle against a test network (Preprod). These are slower but verify the parts that unit tests can’t: proof generation, network submission, and on-chain state confirmation.

it('should finalize a transaction and update on-chain state', async () => {
  // Submit transaction
  const tx = await deployedContract.callTx.increment();

  // Wait for confirmation
  const receipt = await tx.wait();

  expect(receipt.status).toBe('APPLIED_TO_CHAIN');
  expect(receipt.found).toBe(true);

  // Verify state updated on-chain
  const contractState = await providers.publicDataProvider
    .contractStateObservable(
      deployedContract.deployTxData.public.contractAddress,
      { type: 'latest' }
    )
    .toPromise();

  expect(contractState.data.round).toBeGreaterThan(0n);
});

Always await tx.wait() before querying state. Querying immediately after submission returns stale data, the transaction hasn’t finalized yet.


Two Error Types

Static Errors (Compile Time)

The compiler detects these before generating any output. It prints descriptive messages and terminates without producing target files.

Error typeWhat it isWhen caught
SyntaxMalformed codeParser
Type mismatchWrong type usedType checker
Undeclared disclosuredisclose() missingWitness protection
Undefined referenceUnknown identifierName resolver
Generic not specializedGeneric entity used at top levelScope checker
Recursive structStruct that refers to itselfDeclaration checker
Recursive circuitCircuit calls itselfDeclaration checker
return in forreturn inside loopStatement checker
Sealed ledger writeWrite to sealed field in exported circuitDeclaration checker

If the compiler produces no output, there’s at least one static error. Check the messages.

Dynamic Errors (Runtime)

These are detected by the generated JavaScript and runtime libraries when the circuit executes. They halt the current evaluation.

Error typeWhat it isExample
Type mismatchWrong argument type/numberCalling with wrong args
OverflowCast value too large for target1000 as Uint<8>
UnderflowCounter decremented below zerocounter -= 1 when at 0
Uninitialized nested valueNested ledger state not initializedmap.lookup(k).lookup(k2) before insert
Merkle tree fullInsert into full treetree.insert() when isFull()

Dynamic errors are harder to debug because they happen inside generated code. Read the error message for the line number in your source file.


Reading Compiler Error Messages

Type Error

/path/contract.compact line 12 char 5:
  type error: expected Uint<64>, got Field

Read: line:character, expected type, got type. The caret (^) points to the problem token.

Undeclared Disclosure

Exception: /path/contract.compact line 6 char 11:
  potential witness-value disclosure must be declared but is not:
    witness value potentially disclosed:
      the return value of witness getBalance at line 2 char 1
    nature of the disclosure:
      ledger operation might disclose the witness value
    via this path through the program:
      the right-hand side of = at line 6 char 11

Read this bottom to top. The path traces how witness data traveled:

  1. Origin: getBalance() at line 2
  2. Path: flows through the right-hand side of the assignment
  3. Destination: the ledger operation at line 6

Fix: Add disclose() somewhere along that path, as close to the disclosure point as possible.

Missing disclose() on Return

Exception: line 5 char 3:
  potential witness-value disclosure must be declared but is not:
    witness value potentially disclosed:
      the return value of witness getBalance at line 2 char 1
    nature of the disclosure:
      the value returned from exported circuit check might disclose
      the result of a comparison involving the witness value

Even a Boolean comparison result counts as disclosure. Wrap the witness call or the return value with disclose().

Version Mismatch

Error: runtime version mismatch: expected 0.15.0, got 0.14.2

The compiled contract expects a different runtime version. See version management below.


The --skip-zk Development Loop

Generating proving keys is slow. During iterative development, skip it:

compact compile --skip-zk contracts/contract.compact contracts/managed/contract

This produces contract/index.js and compiler/contract-info.json, enough to test logic. Re-enable for final builds and integration tests.


Common Mistakes and Fixes

Forgot disclose() on Ledger Write

// ❌ wrong: compiler error
balance = getBalance();

// ✅ correct
balance = disclose(getBalance());

Forgot disclose() on Return Value

// ❌ wrong: comparison of witness data still counts as disclosure
export circuit check(n: Uint<64>): Boolean {
  return getSecret() > n;
}

// ✅ correct
export circuit check(n: Uint<64>): Boolean {
  return disclose(getSecret()) > n;
}

return Inside for Loop

// ❌ wrong: static error
circuit findFirst(v: Vector<4, Field>, target: Field): Boolean {
  for (const x of v) {
    if (x == target) return true;
  }
  return false;
}

// ✅ correct: use fold
circuit findFirst(v: Vector<4, Field>, target: Field): Boolean {
  return fold((found, x) => found || x == target, false, v);
}

Recursive Circuit

// ❌ wrong: static error, recursion not allowed
circuit factorial(n: Uint<64>): Uint<64> {
  return n == 0 ? 1 : n * factorial(n - 1);
}

Rewrite using fold or explicit unrolling. Compact requires finite circuits.

Narrowing Cast Overflows at Runtime

const x: Uint<64> = 1000;
const y = x as Uint<8>;  // dynamic error: 1000 doesn't fit Uint<8>

Always verify the value fits before casting. Use assert or bounded types.

Uninitialized Nested Ledger State

ledger fld: Map<Boolean, Map<Field, Counter>>;

// ❌ wrong: dynamic error (inner map not initialized)
export circuit increment(b: Boolean, n: Field): [] {
  fld.lookup(b).lookup(n) += 1;
}

// ✅ correct: initialize first
export circuit init(b: Boolean): [] {
  fld.insert(disclose(b), default<Map<Field, Counter>>);
}

transientHash Result Used Without disclose()

// ❌ wrong: compiler error (witness-tainted)
ledger h: Field;
export circuit store(v: Field): [] {
  h = transientHash<Field>(v);
}

// ✅ option 1: declare disclosure
h = disclose(transientHash<Field>(v));

// ✅ option 2: use transientCommit (nonce provides hiding, no disclose needed)
h = transientCommit<Field>(v, nonce);

Debug Strategies

Enable Verbose Logging

Use pino to trace circuit execution and understand what’s happening across the lifecycle:

import pino from 'pino';

const logger = pino({ level: 'debug' });

logger.debug('Submitting increment transaction');
const tx = await deployedContract.callTx.increment();

const receipt = await tx.wait();
logger.debug({
  txId: receipt.public.txId,
  blockHeight: receipt.public.blockHeight,
  status: receipt.status
}, 'Transaction confirmed');

Inspect Circuit State Changes

For failing integration tests, query and log ledger state before and after:

it('should debug circuit state changes', async () => {
  const initialState = await providers.publicDataProvider
    .contractStateObservable(contractAddress, { type: 'latest' })
    .toPromise();

  console.log('Before:', initialState.data);

  const tx = await deployedContract.callTx.post('Debug message');
  const receipt = await tx.wait();

  console.log('Receipt:', {
    status: receipt.status,
    blockHeight: receipt.public.blockHeight,
    txId: receipt.public.txId
  });

  const finalState = await providers.publicDataProvider
    .contractStateObservable(contractAddress, { type: 'latest' })
    .toPromise();

  console.log('After:', finalState.data);

  expect(finalState.data.state).toBe(State.OCCUPIED);
});

Common Debug Scenarios

Circuit Execution Failures

When a circuit throws during unit testing, check these in order:

CheckWhat to look for
Witness return typesMust return tuples: [PrivateState, ReturnValue]
Assert conditionsLog the values you’re comparing, which assert is failing?
Variable initializationCompact does not allow reading uninitialized variables
Bounded integer typesDoes the value actually fit in Uint<8> / Uint<64>?

Proof Generation Failures

When proof generation fails or times out on Preprod:

CheckWhat to look for
Circuit complexityExtremely complex circuits may exceed proof server limits
Infinite loopsUse bounded loop counters to guarantee termination
Witness data typesUint8Array sizes and bigint ranges must match circuit expectations
Large data structuresConsider chunking for large collections

State Synchronization Issues

When ledger state doesn’t update as expected:

CheckWhat to look for
Missing await tx.wait()Querying before finalization returns stale data
Inconsistent witnessesWitness functions that depend on external state between calls
Ledger field operationsCounter.increment() vs direct assignment, are you using the right one?

Version Management

Midnight has six components that must stay in sync:

ComponentWhat it isHow to check
CLI toolcompact binarycompact --version
Compilercompactccompact compile --version
Runtime@midnight-ntwrk/compact-runtimenpm list
Ledger@midnight-ntwrk/ledger-v8npm list
JS libraries@midnight-ntwrk/midnight-js-*npm list
Proof serverDocker imageimage tag

Check Current Versions

compact --version
compact compile --version
npm list @midnight-ntwrk/compact-runtime
npm list @midnight-ntwrk/ledger-v8

Consult the Compatibility Matrix

The official release compatibility matrix is the source of truth. Never mix versions without checking it.

Lock Exact Versions in package.json

{
  "dependencies": {
    "@midnight-ntwrk/compact-runtime": "0.16.0",
    "@midnight-ntwrk/ledger-v8": "8.0.3"
  }
}

Do not use ^ or ~, these allow automatic updates that silently break compatibility.

Use npm ci for Reproducible Installs

rm -rf node_modules
npm ci

npm ci installs exactly what’s in package-lock.json. npm install fetches the latest matching version.

After Updating Any Component

  1. Update all related components together
  2. Recompile contracts
  3. Restart the proof server with the new Docker image
  4. Run your full test suite, unit tests first, then integration

Common Environment Issues

ErrorCauseFix
compact: command not foundBinary not on PATHexport PATH="$HOME/.compact/bin:$PATH"
ERR_UNSUPPORTED_DIR_IMPORTNode.js tried to import directoryOpen new terminal, clear caches
Docker connection errorsDocker Desktop not runningStart Docker Desktop
Port 6300 in useAnother container on same port-p 6301:6300
Version mismatch at runtimeOutdated runtime packageCheck compatibility matrix, update

Version Check Script

#!/bin/bash
echo "=== Midnight Version Check ==="
echo "CLI:"; compact --version || echo "not found"
echo "Compiler:"; compact compile --version || echo "not found"
echo "Runtime:"
npm list --depth=0 | grep @midnight-ntwrk || echo "none found"
echo "Node.js:"; node --version
echo "Compare with: docs.midnight.network/relnotes/support-matrix"

Run this before filing a bug report or asking for help.


Getting Help

If you’re stuck after working through this chapter:

  1. Discord #dev-chat, post your error message and version details
  2. FAQ, docs.midnight.network/troubleshoot/faq
  3. Forum, forum.midnight.network

When asking for help, always include:

  • Output of the version check script above
  • The full error message
  • The .compact file (or relevant excerpt)
  • What you expected vs. what happened

Quick Recap

  • Test at every layer: circuit logic, state transitions, privacy, authorization, integration.
  • contract.impureCircuits lets you call circuits in unit tests without a network or proofs.
  • Chain calls through result.newContext to test multi-step flows.
  • Privacy tests: check that secret keys don’t appear in newLedgerState.
  • Negative tests: test that invalid callers throw, not just that valid callers succeed.
  • Always await tx.wait() in integration tests before querying state.
  • Static errors: compiler catches them. Dynamic errors: happen at runtime, read the line numbers.
  • Undeclared disclosure trace: read bottom to top, it traces from origin to disclosure.
  • Use --skip-zk during development. Enable for final builds and integration tests.
  • Lock exact versions in package.json. Use npm ci.
  • Check the compatibility matrix before updating any component.