For the complete documentation index, see llms.txt
Security and best practices
This guide hardens a Compact contract against the threats a Midnight DApp faces. You authenticate callers, restrict who can run a circuit, validate inputs, enforce deadlines, prevent replay, and let owners rotate keys. Each procedure ends with a test you run to prove it holds: the authorized caller succeeds and the attacker's forged attempt fails.
Three adversaries shape every decision here. A chain observer reads everything on the public ledger. A malicious prover controls their own frontend and can supply any witness value, so only your circuit's assert statements constrain them. An indexer operator can read your shielded history if you hand over a viewing key. For the language-level security model behind these patterns, read Smart contract security.
What an observer can see
Zero-knowledge proofs hide your witness data, but a transaction still reveals a great deal. Know the surface before you defend it.
| What the observer sees | Visible on-chain? |
|---|---|
| Which exported circuit you called | Yes, the entry point is part of the transaction |
| Which contract you called | Yes, the contract address is public |
Arguments to ledger operations (Set and Map keys and values, Counter amounts) | Yes |
Values you wrap in disclose() | Yes, by definition |
| When the transaction was included | Yes, block timing is observable |
| Witness function return values | No, unless you disclose them |
| Internal circuit computation | No |
The leaf inserted into a MerkleTree or HistoricMerkleTree | No, this is the one ledger operation that hides its argument |
Prerequisites
Before you begin, ensure you have:
- A compiled Compact contract to modify. If you are starting fresh, follow build your first contract.
- The Compact CLI installed, with
compact compileworking. - Node.js with a test runner. This guide uses Vitest with
@midnight-ntwrk/compact-runtimeto run the verification tests against your compiled contract. - Familiarity with witnesses and
disclose(). If either is new, read Smart contract security first.
Each procedure below builds one small contract and a test file. Compile a contract with compact compile --skip-zk <source> <output-dir> while iterating, and drop the --skip-zk flag for the full proving build.
Authenticate a caller
The task: gate a circuit so only one specific caller can run it. The wrong way is to compare ownPublicKey(), which is a witness the prover controls. The right way derives an identity from a secret the caller must know.
Declare a secret witness
The caller proves who they are by knowing a secret. Declare it as a witness so it stays in private state and never reaches the ledger.
pragma language_version 0.23.0;
import CompactStandardLibrary;
export ledger owner: Bytes<32>;
witness secretKey(): Bytes<32>;
Derive a public identity
Hash the secret with a domain separator to produce a public identity. The hash is one-way, so publishing it reveals nothing about the secret.
circuit derivePublicKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:owner"), sk]);
}
ownPublicKey() is a witness. The prover chooses its return value, and the protocol does not check it against the wallet that signed the transaction. An assert(ownPublicKey().bytes == owner) compares two prover-controlled values, so an attacker reads the public owner and returns it from a modified frontend. ownPublicKey() is only safe when you route a value to the caller, as the shielded token tutorial does, where lying only hurts the prover.
Store the commitment
At setup, derive the caller's identity and store it as the owner. persistentHash is witness-derived, so the ledger write needs disclose().
export circuit claimOwnership(): [] {
owner = disclose(derivePublicKey(secretKey()));
}
Gate the circuit
Re-derive the identity at call time and assert it matches the stored owner. Only a caller who knows the secret can produce a matching hash.
export circuit withdraw(): [] {
assert(derivePublicKey(secretKey()) == owner, "not owner");
// ... privileged action ...
}
Implement the witness
In your TypeScript frontend, generate the secret with a cryptographically secure source and return it from the witness. Store it in private state through levelPrivateStateProvider, which persists to AES-256-GCM-encrypted storage and never sends it to the network.
// Generate the secret once, with a secure source. Never use Math.random().
const sk = new Uint8Array(32);
crypto.getRandomValues(sk);
// The private state holds the secret; the witness returns it.
export const witnesses = {
secretKey: ({ privateState }) => [privateState, privateState.sk],
};
Verify it works
Prove the gate holds: the owner succeeds, and an attacker who copies the stored key into a forged private state is rejected.
import { describe, it, expect, beforeEach } from 'vitest';
import * as RT from '@midnight-ntwrk/compact-runtime';
import { Contract } from '../managed/access-control/contract/index.js';
const COIN = '0'.repeat(64);
const ADDR = RT.sampleContractAddress();
const key = (n) => { const a = new Uint8Array(32); a[31] = n; return a; };
const OWNER = key(1), ATTACKER = key(2);
describe('access control', () => {
let contract, ctx;
beforeEach(() => {
contract = new Contract({ secretKey: (w) => [w.privateState, w.privateState.sk] });
const ctor = contract.initialState(RT.createConstructorContext({ sk: OWNER }, COIN));
ctx = RT.createCircuitContext(ADDR, COIN, ctor.currentContractState, { sk: OWNER });
ctx = contract.impureCircuits.claimOwnership(ctx).context;
});
it('lets the owner withdraw', () => {
expect(() => contract.impureCircuits.withdraw(ctx)).not.toThrow();
});
it('rejects an attacker who forges the stored owner key', () => {
const attackerCtx = { ...ctx, currentPrivateState: { sk: ATTACKER } };
expect(() => contract.impureCircuits.withdraw(attackerCtx)).toThrow('not owner');
});
});
Running it confirms both the success and the failure path:
✓ access-control.test.ts > access control > lets the owner withdraw
✓ access-control.test.ts > access control > rejects an attacker who forges the stored owner key
Test Files 1 passed (1)
Tests 2 passed (2)
Restrict a circuit to a group
The task: let any member of a group run a circuit, without revealing which member. Store member identities in a HistoricMerkleTree and verify a membership proof, then bind the proof to the caller so it cannot be replayed.
Store members in a Merkle tree
A HistoricMerkleTree hides which leaf a proof refers to, and it accepts proofs against earlier roots so a proof stays valid after new members join. Export the derivation so an admin can compute a member's identity to enroll it.
pragma language_version 0.23.0;
import CompactStandardLibrary;
export ledger members: HistoricMerkleTree<10, Bytes<32>>;
export ledger actions: Counter;
witness secretKey(): Bytes<32>;
export circuit derivePublicKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:member"), sk]);
}
export circuit addMember(pk: Bytes<32>): [] {
members.insert(disclose(pk));
}
Prove membership and bind it to the caller
The caller submits a Merkle path. Recompute the root and check it against the tree, then assert the proven leaf equals the caller's own derived identity.
export circuit act(path: MerkleTreePath<10, Bytes<32>>): [] {
assert(members.checkRoot(disclose(merkleTreePathRoot<10, Bytes<32>>(path))),
"not a member");
// Bind the proof to the caller. Without this line, anyone who observed a
// valid path in a public transaction could replay it and act as a member.
assert(path.leaf == derivePublicKey(secretKey()), "path not bound to caller");
actions.increment(1);
}
Verify it works
A member acts with their own path. A non-member who replays that same path is rejected by the binding assert.
import { describe, it, expect, beforeEach } from 'vitest';
import * as RT from '@midnight-ntwrk/compact-runtime';
import { Contract, ledger, pureCircuits } from '../managed/group-access/contract/index.js';
const COIN = '0'.repeat(64);
const ADDR = RT.sampleContractAddress();
const key = (n) => { const a = new Uint8Array(32); a[31] = n; return a; };
const ALICE = key(1), MALLORY = key(2);
describe('group membership', () => {
let contract, ctx, alicePath;
beforeEach(() => {
contract = new Contract({ secretKey: (w) => [w.privateState, w.privateState.sk] });
const ctor = contract.initialState(RT.createConstructorContext({ sk: ALICE }, COIN));
ctx = RT.createCircuitContext(ADDR, COIN, ctor.currentContractState, { sk: ALICE });
ctx = contract.impureCircuits.addMember(ctx, pureCircuits.derivePublicKey(ALICE)).context;
alicePath = ledger(ctx.currentQueryContext.state)
.members.findPathForLeaf(pureCircuits.derivePublicKey(ALICE));
});
it('lets a member act with their own path', () => {
expect(() => contract.impureCircuits.act(ctx, alicePath)).not.toThrow();
});
it("rejects a non-member replaying a member's path", () => {
const malloryCtx = { ...ctx, currentPrivateState: { sk: MALLORY } };
expect(() => contract.impureCircuits.act(malloryCtx, alicePath)).toThrow('path not bound to caller');
});
});
✓ group-access.test.ts > group membership > lets a member act with their own path
✓ group-access.test.ts > group membership > rejects a non-member replaying a member's path
Test Files 1 passed (1)
Tests 2 passed (2)
A membership proof hides you only among the other members. A tree with three leaves narrows you to one of three, which is almost no privacy. Grow the set before you rely on it, and store commitments rather than guessable raw keys. When you only need to prove a property, disclose the boolean result, not the value: disclose(age >= 18). Comparisons like >= work on Uint<N>, not Field. See Explicit disclosure.
Validate inputs and arithmetic
The task: never compute on unchecked inputs. Compact fails safe on arithmetic, but you still validate to enforce your domain rules and to fail with a clear message.
Know how arithmetic fails
Compact does not silently wrap like some languages. A subtraction that would go negative aborts at runtime, and an addition widens its result type so it cannot silently overflow the field it is written to.
pragma language_version 0.23.0;
import CompactStandardLibrary;
export ledger balance: Uint<64>;
constructor() { balance = 5; }
export circuit unsafeSub(amount: Uint<64>): [] {
balance = balance - disclose(amount);
}
Calling unsafeSub(10) on a balance of 5 aborts with result of subtraction would be negative, rather than wrapping to a huge number.
Uint<64> + Uint<64> produces a wider type than Uint<64>, so balance = balance + disclose(amount) will not compile against a Uint<64> field. Handle the wider result deliberately: assert bounds and narrow it, as in total = (total + amt) as Uint<64> after assert(amt <= CAP, ...). The compiler forces you to acknowledge the overflow rather than letting it happen silently.
Validate before you compute
The runtime guard aborts with a generic message. Assert your own preconditions so failures are explicit and diagnosable, and to enforce domain rules the language cannot know (limits, non-zero recipients, valid state).
export circuit safeSub(amount: Uint<64>): [] {
const amt = disclose(amount);
assert(amt <= balance, "insufficient balance");
balance = balance - amt;
}
For the full set of validation patterns, see input validation and access control.
Verify it works
Confirm the underflow aborts, the guarded circuit gives a clear error, and a valid amount applies.
import { describe, it, expect } from 'vitest';
import * as RT from '@midnight-ntwrk/compact-runtime';
import { Contract, ledger } from '../managed/arithmetic/contract/index.js';
const COIN = '0'.repeat(64);
const ADDR = RT.sampleContractAddress();
const fresh = () => {
const contract = new Contract({});
const ctor = contract.initialState(RT.createConstructorContext({}, COIN));
return { contract, ctx: RT.createCircuitContext(ADDR, COIN, ctor.currentContractState, {}) };
};
describe('arithmetic safety', () => {
it('traps on subtraction underflow instead of wrapping', () => {
const { contract, ctx } = fresh();
expect(() => contract.impureCircuits.unsafeSub(ctx, 10n))
.toThrow('result of subtraction would be negative');
});
it('rejects an over-balance amount with a clear message', () => {
const { contract, ctx } = fresh();
expect(() => contract.impureCircuits.safeSub(ctx, 10n)).toThrow('insufficient balance');
});
it('applies a valid subtraction', () => {
const { contract, ctx } = fresh();
const r = contract.impureCircuits.safeSub(ctx, 3n);
expect(ledger(r.context.currentQueryContext.state).balance).toBe(2n);
});
});
✓ arithmetic.test.ts > arithmetic safety > traps on subtraction underflow instead of wrapping
✓ arithmetic.test.ts > arithmetic safety > rejects an over-balance amount with a clear message
✓ arithmetic.test.ts > arithmetic safety > applies a valid subtraction
Test Files 1 passed (1)
Tests 3 passed (3)
Enforce a deadline
The task: allow an action only before a cutoff time. Compact exposes block time through four standard-library predicates, each taking a Uint<64> count of seconds since the epoch: blockTimeLt, blockTimeLte, blockTimeGt, and blockTimeGte.
Seal the deadline
Store the cutoff and mark it sealed so no later circuit can move it. A sealed field is set once, during construction.
pragma language_version 0.23.0;
import CompactStandardLibrary;
export sealed ledger deadline: Uint<64>;
export ledger claimed: Boolean;
constructor(deadlineTime: Uint<64>) {
deadline = disclose(deadlineTime);
claimed = false;
}
Gate the action
Assert that the current block time is before the deadline. The node evaluates the predicate against the block that includes the transaction.
export circuit claim(): [] {
assert(blockTimeLt(deadline), "expired");
claimed = true;
}
Verify it works
Set the block time in the circuit context (the seventh argument of createCircuitContext) to exercise both sides of the deadline.
import { describe, it, expect } from 'vitest';
import * as RT from '@midnight-ntwrk/compact-runtime';
import { Contract } from '../managed/deadline/contract/index.js';
const COIN = '0'.repeat(64);
const ADDR = RT.sampleContractAddress();
const DEADLINE = 2_000_000_000; // seconds since the epoch
const claimAt = (time) => {
const contract = new Contract({});
const ctor = contract.initialState(RT.createConstructorContext({}, COIN), BigInt(DEADLINE));
const ctx = RT.createCircuitContext(ADDR, COIN, ctor.currentContractState, {}, undefined, undefined, time);
return () => contract.impureCircuits.claim(ctx);
};
describe('deadline', () => {
it('allows the claim before the deadline', () => {
expect(claimAt(DEADLINE - 100)).not.toThrow();
});
it('rejects the claim at or after the deadline', () => {
expect(claimAt(DEADLINE + 100)).toThrow('expired');
});
});
✓ deadline.test.ts > deadline > allows the claim before the deadline
✓ deadline.test.ts > deadline > rejects the claim at or after the deadline
Test Files 1 passed (1)
Tests 2 passed (2)
Block time advances one step per block, and the producer sets the timestamp within protocol-enforced bounds. A time gate is accurate to the scale of blocks, not seconds, so never encode logic that depends on sub-block precision. Block time is also not a randomness source: the only interface is these four comparisons, and any value you derive from them is deterministic and known to the caller before they submit.
Prevent replay
The task: allow a one-time action to happen exactly once. A nullifier records that it has happened, without revealing the secret behind it. Folding a round number into the derivation lets the same secret act once per round.
Record actions with nullifiers
Derive a nullifier from the secret with a domain-separated persistentHash, and store used nullifiers in a Set.
pragma language_version 0.23.0;
import CompactStandardLibrary;
export ledger spent: Set<Bytes<32>>;
witness secretKey(): Bytes<32>;
circuit nullifier(round: Uint<64>, sk: Bytes<32>): Bytes<32> {
const roundBytes = round as Field as Bytes<32>;
return persistentHash<Vector<3, Bytes<32>>>([pad(32, "myapp:nul"), roundBytes, sk]);
}
The domain separator for a nullifier must differ from the one used for any matching commitment. If they share a domain, the two hashes are equal for the same secret, which lets an observer link them. See the commitment/nullifier pattern.
Check and record
Assert the nullifier is not already present, then insert it. A second attempt with the same round and secret produces the same nullifier and fails.
export circuit act(round: Uint<64>): [] {
const nul = nullifier(round, secretKey());
assert(!spent.member(disclose(nul)), "already acted this round");
spent.insert(disclose(nul));
// ... one-time action ...
}
Verify it works
The first action in a round is recorded. A replay in the same round is rejected; a new round succeeds.
import { describe, it, expect } from 'vitest';
import * as RT from '@midnight-ntwrk/compact-runtime';
import { Contract } from '../managed/replay/contract/index.js';
const COIN = '0'.repeat(64);
const ADDR = RT.sampleContractAddress();
const SK = (() => { const a = new Uint8Array(32); a[31] = 1; return a; })();
const setup = () => {
const contract = new Contract({ secretKey: (w) => [w.privateState, w.privateState.sk] });
const ctor = contract.initialState(RT.createConstructorContext({ sk: SK }, COIN));
const ctx = RT.createCircuitContext(ADDR, COIN, ctor.currentContractState, { sk: SK });
return { contract, ctx: contract.impureCircuits.act(ctx, 1n).context };
};
describe('replay protection', () => {
it('rejects a replay in the same round', () => {
const { contract, ctx } = setup();
expect(() => contract.impureCircuits.act(ctx, 1n)).toThrow('already acted this round');
});
it('allows an action in a new round', () => {
const { contract, ctx } = setup();
expect(() => contract.impureCircuits.act(ctx, 2n)).not.toThrow();
});
});
✓ replay.test.ts > replay protection > rejects a replay in the same round
✓ replay.test.ts > replay protection > allows an action in a new round
Test Files 1 passed (1)
Tests 2 passed (2)
The bulletin board tutorial shows the related sequence counter pattern, folding a Counter into the identity derivation so each cycle produces a fresh commitment. See the bulletin board contract.
For front-running, the same commit-then-reveal idea protects ordering: publish a persistentCommit(move, rand) in one transaction and reveal the move in a second, so an observer sees only the commitment while front-running would be profitable. Protect the reveal with a nullifier or sequence counter, since it is itself an action that can be replayed.
Let owners rotate a key
The task: give a key holder a way to move to a new key. The witness secret lives only in local private state, so if it is lost or compromised, the on-chain commitment is permanent. Build the rotation path before you need it.
Add a rotation circuit
The current owner proves control with their secret, then writes a new owner commitment. The new owner generates their own secret locally and shares only the derived public value, so no secret ever crosses the wire.
export circuit rotateOwner(newOwner: Bytes<32>): [] {
assert(derivePublicKey(secretKey()) == owner, "not owner");
owner = disclose(newOwner);
}
Export derivePublicKey so the incoming owner can compute their public value from their secret off-chain.
Verify it works
After rotation, the new key acts and the old key no longer does.
import { describe, it, expect, beforeEach } from 'vitest';
import * as RT from '@midnight-ntwrk/compact-runtime';
import { Contract, pureCircuits } from '../managed/rotation/contract/index.js';
const COIN = '0'.repeat(64);
const ADDR = RT.sampleContractAddress();
const key = (n) => { const a = new Uint8Array(32); a[31] = n; return a; };
const OLD = key(1), NEW = key(2);
describe('key rotation', () => {
let contract, ctx;
beforeEach(() => {
contract = new Contract({ secretKey: (w) => [w.privateState, w.privateState.sk] });
const ctor = contract.initialState(RT.createConstructorContext({ sk: OLD }, COIN));
ctx = RT.createCircuitContext(ADDR, COIN, ctor.currentContractState, { sk: OLD });
ctx = contract.impureCircuits.claimOwnership(ctx).context;
ctx = contract.impureCircuits.rotateOwner(ctx, pureCircuits.derivePublicKey(NEW)).context;
});
it('lets the new key act after rotation', () => {
expect(() => contract.impureCircuits.withdraw({ ...ctx, currentPrivateState: { sk: NEW } })).not.toThrow();
});
it('rejects the old key after rotation', () => {
expect(() => contract.impureCircuits.withdraw({ ...ctx, currentPrivateState: { sk: OLD } })).toThrow('not owner');
});
});
✓ rotation.test.ts > key rotation > lets the new key act after rotation
✓ rotation.test.ts > key rotation > rejects the old key after rotation
Test Files 1 passed (1)
Tests 2 passed (2)
A witness secret cannot be recovered from the chain. If the only holder of a role loses their secret and you provided no rotation path or backup, that role is locked permanently. Decide your recovery model before you deploy: multiple authorized keys, a recovery circuit gated on a separate secret, or a threshold of guardians.
Design for durable, bounded state
Two long-term properties can slip past you while a contract still works in testing.
Store only durable hashes. Compact offers persistent and transient variants of its hash and commitment functions. The transient variants (transientHash, transientCommit) are circuit-optimized, and their algorithm may change between compiler versions, so a value stored on-chain today may not match a recomputation after an upgrade. Use persistentHash and persistentCommit for anything written to the ledger, as every example above does. Reserve the transient variants for in-circuit intermediate values that never touch ledger state.
Bound your growth. Ledger collections grow forever and every entry is public. A nullifier Set gains an entry on each action and never shrinks; a Map keyed by user grows with your user base. For a long-lived contract, scope that growth rather than accumulating without limit: derive nullifiers per epoch so old sets can be retired, or key state so it can expire. Design the bound in from the start, because you cannot retroactively shrink public state.
Prove without leaking private data
Generating a zero-knowledge proof requires your private witness values. The proof server performs arithmetic directly over those values, so whichever proof server does the proving receives them in the clear. This trust boundary often goes unnoticed because the proof itself reveals nothing; the machine that builds it sees everything.
- Run the proof server locally. The default setup runs a proof server on your own machine, in Docker on port 6300. Private inputs never leave the loopback interface, so no external party sees them. This is the safe default and the right choice for sensitive applications.
- A remote or shared proof server sees your private inputs. If you point proving at a server you do not operate, its operator receives your full witness data in order to compute the proof. Transport encryption matters, but it is not the point here: HTTPS stops a network eavesdropper from reading the payload in transit, yet the operator still decrypts and processes your private values. Choosing a remote proof server is a trust decision about that operator, not a transport setting.
- Understand wallet-delegated proving. With the DApp connector's proving provider, your DApp hands the built proof preimage to the wallet, which proves on the user's behalf using whatever proof server it is configured with. Your DApp still computes the witness values itself; delegation only moves the proving step. Know where the wallet ultimately proves before you rely on it.
Running the proof server locally keeps witness data on your machine. A remote or shared proof server sees that data in the clear to build the proof, and switching it to HTTPS does not change what the operator can see.
Protect your viewing key
Your contract logic is not the only attack surface. A viewing key is a wallet-level key, Bech32m-encoded and derived from your wallet seed separately from your spending key. It decrypts your shielded transaction data so software can display your balance and history, but it cannot spend.
Because it decrypts your history, anyone who holds it can read your entire shielded transaction history. The Midnight indexer's connect mutation takes a viewing key and opens a session that scans the chain for your transactions, which is what makes connecting to a third-party indexer a trust decision.
Connecting to a hosted or third-party indexer gives that operator read access to your entire shielded history. A well-behaved indexer stores connected viewing keys encrypted at rest, but you are still trusting the operator. There is no viewing-key rotation: a viewing key is bound to the wallet seed and cannot be revoked independently, so once you share it, assume the holder can read your history indefinitely. Never log, transmit, or persist a user's viewing key outside the wallet and the indexer it connects to, and run your own indexer for sensitive applications.
Before you ship
Work through this list before mainnet:
- Assert every assumption about witness data. A witness value you do not constrain is a value the prover chooses.
- Validate inputs before you compute. Check bounds, ranges, non-zero values, and state preconditions, and handle the widened result of any addition.
- Test with a malicious private state. Supply deliberately wrong witness values and confirm your asserts reject them, as each procedure above does. The Battleship tutorial shows a full adversarial suite.
- Audit every
disclose(). Confirm what becomes public, when, and that it is the minimum the circuit needs. - Check your domain separators. Every commitment and nullifier derivation uses a distinct domain string, and no commitment shares a domain with its nullifier.
- Store only durable hashes. Anything written to the ledger uses
persistentHashorpersistentCommit, never the transient variants. - Bound your state growth. Confirm any
SetorMapthat grows per action has a retirement or expiry strategy. - Confirm error messages leak nothing. An assert message must not embed private state.
- Verify no salt is reused across commitments.
- Provide a key-recovery path. Confirm no role is permanently lockable by a single lost secret.
- Decide where you prove. Confirm private witness inputs are only sent to a proof server you trust.
- Decide viewing-key handling. Confirm no user viewing key is logged, transmitted, or persisted outside the wallet and its indexer.
- Decide your upgrade-key custody. If the contract is upgradeable, distribute control across independent parties. See Making a decision on contract updatability.
- Size your anonymity set so any membership-based privacy is meaningful.
- Get an external review. No amount of self-testing replaces a second set of eyes on a security-critical contract.
Additional resources
- Smart contract security: the language-level security model, sealed fields, and cryptographic primitives.
- Private data: commitments, nullifiers, and Merkle trees in depth.
- Explicit disclosure: how the compiler tracks private data and when
disclose()is required. - OpenZeppelin Compact contracts: reference
Ownable,AccessControl, and other modules built on the derived-identity pattern. Study them as patterns; note the library states it has not been audited. - Test and debug: broader testing strategies for Compact contracts.
- How to configure providers: wiring the indexer and private-state providers your DApp uses.