For the complete documentation index, see llms.txt
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 implementing 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 programmatically. Due to the strong adherence to the "private by default" model, private state data can also be supplied directly to circuit inputs(internal or exported) and still remain private.
ownPublicKey() is a witness function
The built-in Compact function ownPublicKey() returns the Zswap coin public key of the user executing the circuit, however, it is technically a witness function. ownPublicKey() cannot be trusted without prior verification of the caller and especially not as a caller verification mechanism.
Do not use ownPublicKey() for verification of the caller in Compact circuits! It should only be used after the caller has already been verified through another mechanism.
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: 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;
}