Skip to main content
For the complete documentation index, see llms.txt

Security and best practices

This guide covers the operational and design decisions that keep a Midnight DApp secure: how to protect viewing keys, manage the secret keys your contract relies on, authenticate callers, restrict who can run a circuit, prevent replay, size an anonymity set, and design safe time-based logic. It is written for developers building DApps on Midnight Network.

The Compact language reference already documents the language-level security model: sealed fields, the explicit disclosure requirement, and the cryptographic primitives. This guide does not repeat that material. For the language semantics behind the patterns here, read Smart contract security alongside this page.

info

Security is a design responsibility, not a feature you switch on. Compact gives you strong defaults, such as private-by-default witness data, but the guarantees hold only if you use the primitives correctly. Treat every section below as a decision you own.

Know your threat model

Before you write a line of Compact, decide who you are defending against. Three adversaries matter for a Midnight DApp, and every later section defends against one of them.

A chain observer reads the public ledger. Zero-knowledge proofs hide your witness data, but a transaction still reveals a great deal. The table below lists what an observer sees.

What the observer seesVisible on-chain?
Which exported circuit you calledYes, the entry point is part of the transaction
Which contract you calledYes, 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 includedYes, block timing is observable
Witness function return valuesNo, unless you disclose them
Internal circuit computationNo
The leaf inserted into a MerkleTree or HistoricMerkleTreeNo, this is the one ledger operation that hides its argument

A malicious prover controls their own frontend. The prover supplies every witness value, including ownPublicKey(), and the protocol does not cross-check it against the wallet that signed the transaction. The only thing standing between a lying prover and your ledger is the set of assert statements in your circuit. Anything you do not constrain, the prover chooses.

An indexer operator serves the chain data your wallet reads. If you connect to a third-party indexer with your viewing key, that operator can decrypt and read your shielded activity. The next section covers this in detail.

For the conceptual model behind these boundaries, see Private data and the security model overview.

Protect your viewing key

A viewing key is a wallet-level key that grants read access to your shielded transactions. It is Bech32m-encoded and derived from your wallet seed, separately from your spending key. The distinction is the whole point:

  • Your spending key authorizes spends. Nothing moves funds without it.
  • Your viewing key decrypts your shielded transaction data so software can display your balance and history. It cannot spend.

Because the viewing key decrypts your history, anyone who holds it can read your entire shielded transaction history. It carries no spend authority, so the risk is disclosure, not theft, but for a privacy-preserving DApp, disclosure is the threat you exist to prevent.

The Midnight indexer exposes a connect mutation that takes a viewing key and opens a session that scans the chain for your shielded transactions:

mutation Connect($viewingKey: ViewingKey!) {
connect(viewingKey: $viewingKey)
}

Handing your viewing key to an indexer is what lets it find your transactions. That is a trust decision:

warning

Connecting to a third-party or hosted 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 and their infrastructure. For sensitive applications, run your own indexer instead of connecting to a shared one.

Two consequences for your DApp design:

  • Never log, transmit, or persist a user's viewing key outside the wallet and the indexer it connects to. Treat it with the same care as a password.
  • There is no viewing-key rotation. A viewing key is bound to the wallet seed and cannot be revoked or rotated independently. Once you share it, assume the holder can read your shielded history indefinitely. Design your flows so a viewing key never leaves the wallet and the indexer it connects to.

For how the indexer provider fits into your provider stack, see How to configure providers.

Authenticate with derived identity, not ownPublicKey()

ownPublicKey() looks like a caller identity, but it is a witness. Its value is whatever the prover's frontend supplies, and the protocol does not cross-check it against the wallet that signed the transaction. Gating access on it proves nothing:

pragma language_version 0.23.0;
import CompactStandardLibrary;

export ledger owner: Bytes<32>;

export circuit withdraw(): [] {
// Anti-pattern. ownPublicKey() is a witness: a modified frontend can
// return any value, including the stored owner. This assert compiles,
// and it is bypassable.
assert(ownPublicKey().bytes == owner, "not owner");
// ... privileged action ...
}

Because ownPublicKey().bytes and owner are both values the prover controls, an attacker can read the public owner from the ledger, set their frontend to return it, and satisfy the assert without holding any secret.

Authenticate against a secret the prover must know. Derive a public identity by hashing a secret witness with a domain separator, store that commitment once, and re-derive it at call time:

pragma language_version 0.23.0;
import CompactStandardLibrary;

export ledger owner: Bytes<32>;

witness secretKey(): Bytes<32>;

// A public identity derived from a secret only the owner knows.
circuit derivePublicKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:owner"), sk]);
}

// Store the commitment once, at setup.
export circuit claimOwnership(): [] {
owner = disclose(derivePublicKey(secretKey()));
}

export circuit withdraw(): [] {
// Only a caller who knows the secret can re-derive a matching commitment.
assert(derivePublicKey(secretKey()) == owner, "not owner");
// ... privileged action ...
}

The ledger stores only the hash. An attacker who copies it into their proof still cannot supply a secret whose hash matches, so the assert holds only for the real owner.

When ownPublicKey() is fine

ownPublicKey() is safe when you are routing a value to the caller, not gating access. Sending a minted coin to ownPublicKey(), as the shielded token tutorial does, only ever hurts a prover who lies about their own address. The rule is: never let the result of ownPublicKey() decide whether to allow an action. See ownPublicKey() is a witness function.

Restrict who can call your circuits

Access control builds on the derived-identity pattern from the previous section. Pick the scheme that matches how many callers you need to authorize and how much privacy they need.

One owner. The withdraw circuit above is already owner-only: it authorizes exactly the holder of one secret.

A set of authorized callers. Store each caller's derived identity in a Set<Bytes<32>> and check membership. The identities are public, so an observer learns who is authorized and which member acted:

pragma language_version 0.23.0;
import CompactStandardLibrary;

export ledger admins: Set<Bytes<32>>;

witness secretKey(): Bytes<32>;

circuit derivePublicKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:admin"), sk]);
}

export circuit adminAction(): [] {
const me = derivePublicKey(secretKey());
assert(admins.member(disclose(me)), "not an admin");
// ... admin-only action ...
}

An anonymous membership group. When callers must prove they belong without revealing which member they are, store commitments in a HistoricMerkleTree and verify a membership proof. A witness supplies the caller's path off-chain; the circuit recomputes the root and checks it:

pragma language_version 0.23.0;
import CompactStandardLibrary;

export ledger members: HistoricMerkleTree<10, Bytes<32>>;
export ledger actions: Counter;

witness secretKey(): Bytes<32>;
witness memberPath(pk: Bytes<32>): MerkleTreePath<10, Bytes<32>>;

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));
}

export circuit act(): [] {
const me = derivePublicKey(secretKey());
const path = memberPath(me);
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 could replay it and act as a member.
assert(path.leaf == me, "path not bound to caller");
actions.increment(1);
}

The binding assert is the security-critical line. A membership proof on its own is not tied to who submits it, so a party who observes a valid path in a public transaction could replay it. Asserting that the proven leaf equals the caller's own derived identity closes that gap. Note that a HistoricMerkleTree accepts proofs against earlier roots, so paths never expire on their own, which makes the caller binding the load-bearing check.

caution

The OpenZeppelin Compact contracts library provides Ownable, AccessControl, and other access modules built on the derived-identity pattern shown here, not on ownPublicKey(). Study them as reference implementations, but note the library states it has not been audited. Treat it as patterns to learn from, not a dependency to trust unreviewed.

Manage secret keys in your DApp

The witness secrets your contract authenticates against do not come from the wallet. Your DApp generates and stores them, so their lifecycle is your responsibility.

Generate secrets with a cryptographically secure source. Use crypto.getRandomValues(), never Math.random(). A predictable secret defeats every commitment built on top of it.

// Generate a 32-byte witness secret with a cryptographically secure source.
const secretKey = new Uint8Array(32);
crypto.getRandomValues(secretKey);

Store secrets in the private state, which never leaves the device. The levelPrivateStateProvider persists private state to a LevelDB database encrypted with AES-256-GCM, and private state is never sent to the network. That protects the secret at rest on disk. It does not protect against a compromised runtime or a malicious browser extension with access to the running page, so treat the device itself as part of your trust boundary. See How to configure providers for the provider setup.

Plan for device loss. The on-chain commitment to a secret persists forever, but the secret itself lives only in local private state. If the user loses their device, they lose the secret, and any circuit gated on it becomes uncallable. You can verify ownership only from the same wallet and device where you created the secret. If that is unacceptable for your application, design a rotation or recovery circuit before you deploy, not after.

Never reuse a commitment salt. Reusing a random value across commitments lets an observer link them. Derive fresh randomness per commitment, or, if you reuse a secret as a randomness source, fold in a round counter so the committed data is never identical.

For the related problem of protecting the keys that control contract upgrades, see Making a decision on contract updatability, which covers maintenance-authority key custody.

Handle deadlines and time windows

Compact exposes block time through four standard-library predicates. Each takes a time as Uint<64> seconds since the Unix epoch and returns a Boolean:

  • blockTimeLt(time) and blockTimeLte(time)
  • blockTimeGt(time) and blockTimeGte(time)

There is no raw block-time accessor, only these comparisons. To build a deadline, store the cutoff and gate the action on it. Sealing the field pins the deadline at deployment so no later circuit can move it:

pragma language_version 0.23.0;
import CompactStandardLibrary;

// Sealed: the deadline is fixed at deployment and immutable afterward.
export sealed ledger deadline: Uint<64>;
export ledger claimed: Boolean;

constructor(deadlineTime: Uint<64>) {
deadline = disclose(deadlineTime);
claimed = false;
}

export circuit claim(): [] {
assert(blockTimeLt(deadline), "expired");
claimed = true;
}

claim succeeds while the block time is before the deadline and fails the expired assert at or after it. The node evaluates the predicate against the block that includes the transaction, so it enforces the gate at validation time.

Two rules keep time-based logic safe:

  • Treat time as coarse. 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. Never encode logic that depends on sub-block precision or on a timestamp being exact.
  • Never use block time as randomness. The only interface is these four boolean comparisons, and any value you derive from them is a deterministic function of the block time that a caller can compute before submitting. Do not use block time to pick a winner, seed a shuffle, or generate a secret.

For the full signatures, see the standard library reference.

Prevent replay attacks

A replay attack resubmits a valid transaction, or reuses a valid proof, to trigger an action twice. Compact gives you two patterns to prevent it.

A sequence counter binds each action to a monotonically increasing value, so an old transaction no longer matches the current state. The bulletin board tutorial folds a Counter into the identity derivation: each takedown increments the counter, which produces a fresh commitment for the next posting cycle and makes the previous transaction unreplayable.

A nullifier records that a one-time action has happened. You derive a nullifier from a secret with a domain-separated persistentHash, store it in a Set<Bytes<32>>, and assert it is not already present before inserting it. Folding a round number into the derivation lets the same secret act once per round:

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]);
}

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 ...
}

A second call with the same round and secret produces the same nullifier and fails the assert; a new round produces a distinct nullifier that succeeds. The nullifier itself reveals nothing about the secret.

The domain separators for a commitment and its matching nullifier must differ. If they share a domain, the two hashes are equal for the same secret, which lets an observer link them. See Double-spend prevention with nullifiers and the commitment/nullifier pattern.

Protect your anonymity set

A membership proof hides you only among the other members. If your MerkleTree holds three leaves, proving membership narrows you to one of three, which is close to no privacy at all. The strength of an anonymous authentication scheme is the size of its anonymity set.

Design for it:

  • Grow the set before you rely on it. A proof against a small tree leaks almost as much as naming yourself. Wait until the membership set is large enough that inclusion is uninformative.
  • Do not store guessable leaves. If the set of possible leaf values is small, an observer can insert each candidate and check it against the tree. Store commitments (a value hashed with randomness), not raw public keys.
  • Prefer HistoricMerkleTree for growing sets. Its checkRoot accepts proofs made against earlier versions of the tree, so a proof stays valid after new members join. A plain MerkleTree invalidates every outstanding proof on each insertion.

Selective disclosure belongs to the same discipline: reveal the answer, not the data. Disclose the boolean result of a check rather than the value behind it:

pragma language_version 0.23.0;
import CompactStandardLibrary;

export ledger isAdult: Boolean;

witness age(): Uint<64>;

export circuit proveAdult(): [] {
// Disclose only the boolean. The age itself never reaches the ledger.
isAdult = disclose(age() >= 18);
}

Comparisons like >= work on Uint<N>, not Field, so hold values you intend to compare as Uint<N>. Keep disclose() as late as possible: once you disclose a value, the compiler stops tracking it for accidental leakage. See Explicit disclosure and place disclose() strategically.

Mitigate front-running

Anything an observer can see before your transaction is confirmed, they can act on first. Because ledger operation arguments and disclosed values are public the moment your transaction reaches the mempool, a bid, an order, or a move submitted in the clear can be front-run.

The primary defense is to commit first and reveal later. Submit a commitment to your action in one transaction, then reveal the action in a second once the ordering is fixed. An observer sees only the commitment during the window when front-running would be profitable:

pragma language_version 0.23.0;
import CompactStandardLibrary;

export ledger stored: Bytes<32>;
export ledger lastRevealed: Uint<8>;

// Phase 1: publish only a commitment. persistentCommit hides the move,
// so it needs no disclose().
export circuit commitMove(move: Uint<8>, rand: Bytes<32>): [] {
stored = persistentCommit<Uint<8>>(move, rand);
}

// Phase 2: reveal and prove the opening matches the commitment.
export circuit revealMove(move: Uint<8>, rand: Bytes<32>): [] {
assert(persistentCommit<Uint<8>>(move, rand) == stored, "bad opening");
lastRevealed = disclose(move);
}

A reveal with the wrong move or rand fails the bad opening assert, so a participant cannot change their move after committing.

The second defense is to disclose as late as you can. The less you make public before confirmation, the less an adversary has to react to. Revisit the threat model table: every value you keep out of disclose() and out of ledger arguments is a value no observer can trade against.

A reveal step is itself an action that can be replayed, so protect it with a sequence counter or a nullifier as described in Prevent replay attacks.

Run this checklist before you deploy

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. See Know your threat model.
  • Test with a malicious private state. Write a test that supplies deliberately wrong witness values and confirm your asserts reject them. The Battleship tutorial demonstrates adversarial testing.
  • Audit every disclose(). For each one, confirm what becomes public, when, and to whom, and that it is the minimum the circuit needs.
  • Check your domain separators. Every commitment and nullifier derivation uses a distinct, purpose-specific domain string, and no commitment shares a domain with its nullifier.
  • Confirm error messages leak nothing. An assert message must not embed private state. See handle errors securely.
  • 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. Confirm any membership-based privacy has enough members to be meaningful.
  • Get an external review. No amount of self-testing replaces a second set of eyes on a security-critical contract.