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, enforce deadlines, and prevent replay. Each mitigation 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.
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.
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.
- 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.
- Confirm error messages leak nothing. An assert message must not embed private state.
- Verify no salt is reused across commitments.
- 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.