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
| Layer | What to test | How |
|---|---|---|
| Circuit logic | Correct outputs and state transitions | contract.impureCircuits in vitest |
| Privacy | Private data doesn’t appear in public outputs | Inspect newLedgerState vs private state |
| Authorization | Invalid callers are rejected | Negative tests with wrong keys |
| Integration | Full transaction lifecycle on-chain | Submit + tx.wait() on Preprod |
| Performance | Proof generation completes in time | Measure 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 rannewContext, 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 type | What it is | When caught |
|---|---|---|
| Syntax | Malformed code | Parser |
| Type mismatch | Wrong type used | Type checker |
| Undeclared disclosure | disclose() missing | Witness protection |
| Undefined reference | Unknown identifier | Name resolver |
| Generic not specialized | Generic entity used at top level | Scope checker |
| Recursive struct | Struct that refers to itself | Declaration checker |
| Recursive circuit | Circuit calls itself | Declaration checker |
return in for | return inside loop | Statement checker |
| Sealed ledger write | Write to sealed field in exported circuit | Declaration 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 type | What it is | Example |
|---|---|---|
| Type mismatch | Wrong argument type/number | Calling with wrong args |
| Overflow | Cast value too large for target | 1000 as Uint<8> |
| Underflow | Counter decremented below zero | counter -= 1 when at 0 |
| Uninitialized nested value | Nested ledger state not initialized | map.lookup(k).lookup(k2) before insert |
| Merkle tree full | Insert into full tree | tree.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:
- Origin:
getBalance()at line 2 - Path: flows through the right-hand side of the assignment
- 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:
| Check | What to look for |
|---|---|
| Witness return types | Must return tuples: [PrivateState, ReturnValue] |
| Assert conditions | Log the values you’re comparing, which assert is failing? |
| Variable initialization | Compact does not allow reading uninitialized variables |
| Bounded integer types | Does the value actually fit in Uint<8> / Uint<64>? |
Proof Generation Failures
When proof generation fails or times out on Preprod:
| Check | What to look for |
|---|---|
| Circuit complexity | Extremely complex circuits may exceed proof server limits |
| Infinite loops | Use bounded loop counters to guarantee termination |
| Witness data types | Uint8Array sizes and bigint ranges must match circuit expectations |
| Large data structures | Consider chunking for large collections |
State Synchronization Issues
When ledger state doesn’t update as expected:
| Check | What to look for |
|---|---|
Missing await tx.wait() | Querying before finalization returns stale data |
| Inconsistent witnesses | Witness functions that depend on external state between calls |
| Ledger field operations | Counter.increment() vs direct assignment, are you using the right one? |
Version Management
Midnight has six components that must stay in sync:
| Component | What it is | How to check |
|---|---|---|
| CLI tool | compact binary | compact --version |
| Compiler | compactc | compact compile --version |
| Runtime | @midnight-ntwrk/compact-runtime | npm list |
| Ledger | @midnight-ntwrk/ledger-v8 | npm list |
| JS libraries | @midnight-ntwrk/midnight-js-* | npm list |
| Proof server | Docker image | image 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
- Update all related components together
- Recompile contracts
- Restart the proof server with the new Docker image
- Run your full test suite, unit tests first, then integration
Common Environment Issues
| Error | Cause | Fix |
|---|---|---|
compact: command not found | Binary not on PATH | export PATH="$HOME/.compact/bin:$PATH" |
ERR_UNSUPPORTED_DIR_IMPORT | Node.js tried to import directory | Open new terminal, clear caches |
| Docker connection errors | Docker Desktop not running | Start Docker Desktop |
| Port 6300 in use | Another container on same port | -p 6301:6300 |
| Version mismatch at runtime | Outdated runtime package | Check 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:
- Discord
#dev-chat, post your error message and version details - FAQ, docs.midnight.network/troubleshoot/faq
- Forum, forum.midnight.network
When asking for help, always include:
- Output of the version check script above
- The full error message
- The
.compactfile (or relevant excerpt) - What you expected vs. what happened
Quick Recap
- Test at every layer: circuit logic, state transitions, privacy, authorization, integration.
contract.impureCircuitslets you call circuits in unit tests without a network or proofs.- Chain calls through
result.newContextto 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-zkduring development. Enable for final builds and integration tests. - Lock exact versions in
package.json. Usenpm ci. - Check the compatibility matrix before updating any component.
Cross-Links
- Previous: Keywords Reference Keyword meanings
- Next: Security and Best Practices Privacy patterns
- See also: Explicit Disclosure Disclosure boundary
- See also: Circuits Common circuit mistakes
- See also: Witnesses Witness trust model
- Examples: 14.01 Static Errors