Smart contract security
Compact smart contracts on Midnight combine privacy-preserving computation with cryptographic guarantees. The compiler enforces certain rules and restrictions, but much of the responsibility for secure contract interaction is up to the developer writing the smart contract. For this reason, it is very important for smart contract developers to understand common pitfalls and adhere to security best practices.
Security model overview
Compact enforces security through multiple layers:
- Privacy by default: Private data must be explicitly disclosed before appearing on-chain
- Compile-time validation: The compiler prevents accidental disclosure of witness data
- zero-knowledge proofs: All circuit computations are cryptographically verified without revealing inputs
- Bounded execution: Fixed computational bounds prevent resource exhaustion attacks
- Immutable deployments: Contracts cannot tamper with deployed state, transactions always produce a new output state
Three execution contexts
Compact contracts operate across three distinct security contexts:
- Public ledger: On-chain state visible to all network observers
- zero-knowledge circuits: On-chain functions that validate operations using proofs without revealing private inputs
- Local computation: Arbitrary code execution on user machines via witness functions
Understanding these components and their boundaries is crucial for writing secure contracts.
Sealed vs. unsealed ledger fields
Ledger fields can be optionally marked as sealed to make them immutable after contract initialization. A sealed field can only be set during contract deployment by the constructor or helper circuits that the constructor calls. After initialization, no exported circuit can modify sealed fields.
- Unsealed fields (default) - Exported circuits can modify these during contract execution
- Sealed fields - Can only be set during initialization; immutable afterward
sealed ledger field1: Uint<32>;
export sealed ledger field2: Uint<32>;
circuit init(x: Uint<32>): [] {
field2 = x; // Valid: called by constructor
}
constructor(x: Uint<16>) {
field1 = 2 * x; // Valid: in constructor
init(x); // Valid: helper circuit
}
export circuit modify(): [] {
field1 = 10; // ❌ Compilation error: sealed field
}
Use sealed fields for configuration values, contract parameters, or any data that should remain constant after deployment. The compiler enforces this at compile time, preventing accidental modification in exported circuits.
Witness functions and off-chain computation
Witnesses are off-chain functions invoked from on-chain Compact circuits. This enables on-chain verification of off-chain compute. Compact holds only the function declaration and the implementation is written in the TypeScript frontend:
// Compact declaration
witness localSecretKey(): Bytes<32>;
witness getUserBalance(): Uint<64>;
TypeScript implementation of the witness functions:
// TypeScript implementation
export const witnesses = {
localSecretKey: ({ privateState }: WitnessContext<Ledger, PrivateState>) =>
[privateState, privateState.secretKey],
getUserBalance: ({ privateState }: WitnessContext<Ledger, PrivateState>) =>
[privateState, privateState.balance],
};
Witness implementations run outside zero-knowledge circuits and are not cryptographically verified. Each user provides their own witness implementation, so contract logic must never trust witness values without validation.
It is best practice for DApp developers to isolate functions requiring access to private state to witnesses, but this is not enforced programatically. Due to the strong adherance to the "private by default" model, private state data can also be supplied directly to circuit inputs and still remain private.
Privacy-preserving fundamentals
Compact's privacy model is built on the principle that sensitive data remains hidden by default. Strong understanding of how privacy works in Compact is necessary for building secure contracts that protect user data when interacting with the public ledger.
Explicit disclosure requirement
Compact enforces a "private by default" model where all circuit inputs and values derived from witness functions remain private unless explicitly disclosed. The compiler tracks private data flow and requires the disclose() wrapper before allowing it to be:
- Stored in public ledger state
- Returned from exported circuits
witness secretKey(): Bytes<32>;
// value, _sk and pk are private by default
export circuit set(value: Uint<64>): [] {
const _sk = secretKey();
const pk = persistentHash(Vector<2, Bytes<32>>([pad(32, "domain"), _sk]));
// Must explicitly disclose before storing in ledger
authority = disclose(pk);
storedValue = disclose(value);
}
Attempting to store data publicly or return it from an exported circuit without disclose() results in a compilation error:
Exception: potential witness-value disclosure must be declared but is not:
witness value potentially disclosed:
the return value of witness secretKey at line 1
nature of the disclosure:
assignment to ledger field 'authority'
Prefacing identifier names intended to remain private with an underscore is a best practice in cryptography to allow developers to track which values they need to keep private. Compact provides significant built-in privacy, but it still requires a rigorous attention to detail by the DApp developer.
disclose() itself does not make a value public, it serves to notify the compiler that this value is safe to store publicly and bypass the private by default mechanism.
Best practice: Minimize disclosure
Only disclose the minimum data necessary for your contract logic: @TODO -- update persistentCommit here to persistentHash
// ✅ Good: Disclose only what's needed
export circuit vote(_choice: Uint<8>): [] {
const _sk = secretKey();
const commitment = persistentCommit(_choice, _sk);
votes.insert(disclose(commitment)); // Commitment disclosed, not choice
}
// ❌ Bad: Unintentional disclosure
export circuit vote(choice: Uint<8>): [] {
const _sk = secretKey();
votes.insert(persistentCommit(_choice, _sk)); // choice and sk may be leaked
}
Best practice: Place disclose() strategically
Position disclose() as close to the disclosure point as possible to prevent accidental disclosure through multiple code paths:
// ✅ Good: Disclose at the point of use
export circuit store(flag: Boolean): [] {
const secret = getSecret();
const derived = computeValue(secret); // Still private
result = disclose(flag) ? disclose(derived) : 0; // Specific explicit disclosure
}
// ❌ Bad: Early disclosure increases risk
export circuit store(flag: Boolean): [] {
const secret = disclose(getSecret()); // Disclosed too early
const derived = computeValue(secret);
result = disclose(flag) ? derived : 0;
}
Cryptographic primitives
Compact provides cryptographic primitives through the standard library for hashing, commitments, and privacy-preserving operations. Understanding when to use each primitive is essential for building secure smart contracts.
These hashing and commitment functions enable you to publicly post a hash on-chain and later prove properties about the committed value (or reveal it) without having disclosed it initially.
Hash functions
Compact provides two hash functions with different guarantees. These are most useful for hashing binary data that has a unique fingerprint and is not easily derivable by a malicious actor on its own.
transientHash<T>(value: T): Field- Circuit-optimized hash for temporary consistency checks; not guaranteed to persist between protocol upgradespersistentHash<T>(value: T): Bytes<32>- SHA-256 hash suitable for deriving state data; guaranteed to remain consistent across upgrades
Use persistentHash for any values stored in ledger state or used for authentication.
Commitment schemes
Commitments allow you to hash values, such as a number, which may be easy for a malicious actor to derive with a brute force attack. Compact commitment functions are paired with a random (salt) value that renders the simple value impossible to guess, unless the random value is also guessed.
transientCommit<T>(value: T, rand: Field): Field- Circuit-efficient commitment for temporary use; not guaranteed to persist between protocol upgradespersistentCommit<T>(value: T, rand: Bytes<32>): Bytes<32>- SHA-256-based commitment for persistent storage; guaranteed to remain consistent across upgrades
Commitment schemes and hashing functions provide two security properties:
- Hiding - The hash reveals nothing about the original value; observers cannot determine what you committed
- Binding - After creating a commitment, you cannot change the value; the commitment permanently binds to the original data.
These are one-way, deterministic functions in that the value under the hash is hidden forever and can never be revealed. In order to verify information under a hash or commitment, have the user provide the same value for verification, run it through the same function and compare the hashes to ensure they match. If they do not match, the user provided a different value.
export ledger valueCommitment: Bytes<32>;
export circuit commitToValue(value: Uint<64>, rand: Bytes<32>): [] {
const commitment = persistentCommit(value, rand);
valueCommitment = commitment;
}
export circuit revealValue(value: Uint<64>, rand: Bytes<32>): [] {
const commitment = persistentCommit(value, rand);
assert(commitment == valueCommitment, "Invalid commitment opening");
// Value is now verified without prior public disclosure
}
The result of a persistentCommit operation does not require disclose() because the use of a random salt value allows the compiler to consider the return value as sufficiently private for storage publicly. It is very important that the random value provided is sufficient and unique in its randomness.
Never reuse randomness across commitments. Reusing a random value with different commitment values enables linking the commitments, breaking a degree of privacy. If the random value is compromised the extent of the potential data that can be compromised is limited to a single value.
Double-spend prevention with nullifiers
Nullifiers prevent double-spending of coins or consumption of private state elements without revealing which specific coin was spent. A nullifier is a one-way hash that uniquely identifies a resource without revealing it:
export ledger usedNullifiers: Set<Bytes<32>>;
circuit nullifier(secretKey: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "nullifier-domain"),
secretKey
]);
}
export circuit spend(secretKey: Bytes<32>): [] {
const nul = nullifier(secretKey);
assert(!usedNullifiers.member(nul), "Already spent");
usedNullifiers.insert(disclose(nul));
}
Use domain separation (different prefixes like "nullifier-my-dapp-1" vs "commitment-my-dapp-2") to prevent hash collision attacks across different purposes.
Best practice: Use appropriate cryptographic primitives
Choose the right primitive for your use case:
| Primitive | Use case | Persistence | Disclosure protection |
|---|---|---|---|
transientHash | Temporary checks | No guarantee | No |
transientCommit | Temporary hiding | No guarantee | Yes |
persistentHash | State derivation, authentication | Guaranteed | No |
persistentCommit | Long-term hiding | Guaranteed | Yes |
Best practice: Use domain separation
Prevent hash collision attacks by using distinct domain separators for different purposes:
circuit publicKey(_sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "commitment-domain"),
_sk
]);
}
circuit nullifier(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "nullifier-domain"), // Different domain
sk
]);
}
Input validation and access control
Compact contracts should validate all inputs and enforce authorization for circuit execution. This is a very important responsibility that lies with the Compact developer. A particular circuits access control is only as good as the developer makes it.
The language provides built-in mechanisms for enabling these security features.
Assert statements
Use assert to validate all inputs, state transitions, and access requirements:
export circuit updateBalance(recipient: Bytes<32>, amount: Uint<64>): [] {
// Validate state
assert(state == State.ACTIVE, "Contract not active");
// Validate inputs
assert(amount > 0, "Amount must be greater than zero");
assert(amount <= balance, "Insufficient balance");
// Validate authorization
const _sk = secretKey();
const pk = publicKey(_sk);
assert(pk == owner, "Unauthorized");
// Update balance
balance = balance - amount;
}
Assertions provide:
- Pre-condition checks - Verify contract state before operations
- Input validation - Reject invalid parameters early
- Authorization - Enforce access control through cryptographic verification
- Invariant preservation - Maintain contract consistency
Best practice: Validate all inputs
Never trust witness data or circuit parameters without validation:
export circuit updateBalance(amount: Uint<64>): [] {
// Validate bounds
assert(amount > 0, "Amount must be greater than zero");
assert(amount <= MAX_TRANSFER, "Amount exceeds limit");
// Validate state
assert(balance >= amount, "Insufficient balance");
// Validate authorization
const _sk = secretKey();
assert(isAuthorized(_sk), "Unauthorized");
balance = balance - amount;
}
Best practice: Handle errors securely
Error messages should not leak sensitive information:
// ✅ Good: Generic error message
export circuit withdraw(amount: Uint<64>): [] {
const authorized = checkAuth();
assert(authorized, "Operation not permitted");
}
// ❌ Bad: Leaks private state
export circuit withdraw(amount: Uint<64>): [] {
const sk = secretKey();
const balance = getBalance();
assert(balance >= amount, "Balance " ++ balance ++ " insufficient");
}
Authentication patterns
Compact circuits can emulate digital signatures using hash-based authentication. This pattern can enable a "DApp specific public key" that is only traceable within a specific DApp. This can enable certain use cases, but may put limitations on others. This pattern allows publicKey to be called once and establish an authority for access to other circuits in the contract throughout its lifecycle.
circuit publicKey(_sk: Bytes<32>): Bytes<32> {
const hash = persistentHash<Vector<2, Bytes<32>>>([
pad(32, "midnight:auth:pk"),
_sk
]);
return hash;
}
export circuit setAuthorized(): [] {
const _sk = secretKey();
const pk = publicKey(_sk);
authority = disclose(pk);
}
// only authority can call authorizedOperation
export circuit authorizedOperation(newLedgerValue: Bytes<32>): [] {
const _sk = secretKey();
const pk = publicKey(_sk);
assert(disclose(pk) == authority, "Authorization failed");
// Perform authorized operation
ledgerValue = disclose(newLedgerValue);
}
To break the linkability of transactions, include a round counter in the hashing function of publicKey that makes the public key only valid for the desired chain of calls. This requires calling publicKey at the start of the chain and incrementing the counter at the end, invalidating the old public key. For an example of this pattern, see Writing a contract.
Testing and validation
Thorough testing is essential for identifying security vulnerabilities before deployment. For comprehensive testing strategies, debugging techniques, and test examples, see the Testing and debugging guide.
Next steps
- Review the explicit disclosure guide for advanced privacy patterns
- Test your contracts on the Preprod network before production deployment