Writing a Contract
This note covers the structure of a Compact contract, the four mandatory pieces and how they fit together.
Intuition First
Every Compact contract has four mandatory pieces:
pragma, Declares the language version.export ledger, Public state that lives on-chain.witness, Declares where private data comes from (body is in TypeScript).export circuit, Logic that compiles to ZK circuits.
The optional fifth piece is the constructor, runs once on deployment to initialize state.
Think of it this way:
export ledger= what everyone can readwitness= where your secrets come fromexport circuit= what you can prove happenedconstructor= how it starts
Minimal Contract
pragma language_version >= 0.22;
export ledger message: Opaque<"string">;
export circuit post(msg: Opaque<"string">): [] {
message = disclose(msg);
}
This is a bulletin board: anyone can post a message that becomes public. No privacy, just the simplest possible contract.
Full Contract: Bulletin Board
A contract where one person posts a message, then takes it down. Ownership is verified via a derived public key.
pragma language_version >= 0.20;
import CompactStandardLibrary;
export enum State { VACANT, OCCUPIED }
export ledger state: State;
export ledger message: Maybe<Opaque<"string">>;
export ledger sequence: Counter;
export ledger owner: Bytes<32>;
constructor() {
state = State.VACANT;
message = none<Opaque<"string">>();
sequence.increment(1);
}
witness localSecretKey(): Bytes<32>;
export circuit post(newMessage: Opaque<"string">): [] {
assert(state == State.VACANT, "Board occupied");
owner = disclose(publicKey(localSecretKey(), sequence as Field as Bytes<32>));
message = disclose(some<Opaque<"string">>(newMessage));
state = State.OCCUPIED;
}
export circuit takeDown(): Opaque<"string"> {
assert(state == State.OCCUPIED, "Board empty");
assert(owner == publicKey(localSecretKey(), sequence as Field as Bytes<32>), "Not owner");
const msg = message.value;
state = State.VACANT;
sequence.increment(1);
message = none<Opaque<"string">>();
return msg;
}
pure circuit publicKey(sk: Bytes<32>, seq: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>([pad(32, "bboard:pk:"), seq, sk]);
}
Walk through what happens:
- Deployment:
constructor()runs once. State is VACANT, message is none. - Post: Caller’s derived public key is stored as
owner. The message is disclosed and stored. - TakeDown: Verifies the caller knows the secret key that derives the stored public key. Returns the message.
The key pattern here: the secret key never leaves the caller’s machine. The public key is derived and stored. When takeDown runs, it verifies ownership by checking if publicKey(callerSecret, sequence) == owner. The ZK proof proves the caller knew the secret without revealing it.
The Four Pieces
Pragma & Import
pragma language_version >= 0.22;
import CompactStandardLibrary;
The pragma declares minimum language version. The import brings in the standard library.
Ledger (Public State)
export ledger message: Opaque<"string">;
export ledger counter: Uint<64>;
export ledger state: State;
export= readable from TypeScript (your DApp can see it)ledger= on-chain, public state- Default initialization: zero or empty. Override in
constructor.
Types: Uint<64>, Bytes<32>, Opaque<"string">, State, Counter, Map<K, V>, Set<T>, MerkleTree<n, T>, and more.
Circuits (Logic)
export circuit get(): Opaque<"string"> {
return message;
}
export circuit post(msg: Opaque<"string">): [] {
message = disclose(msg);
}
export= callable from your DApp[]= no return value (procedure-style)assert(condition, "message")= runtime guard
Circuits are the logic layer. They compile to ZK circuits.
Witnesses (Private Input)
witness secretKey(): Bytes<32>;
Declares a callback. The body is provided by your TypeScript DApp, not in Compact.
Constructor (Initialization)
constructor(initial: Uint<64>) {
value = disclose(initial);
}
Runs once on deployment. Use disclose() for values that should be public from the start.
What Happens Under the Hood
When you compile:
Your .compact file
↓
Compiler parses → type checks → generates ZKIR
↓
ZKIR + proving keys → ZK proof (at runtime)
↓
Proof submitted → validated → state updated
Your DApp calls contract.circuits.post(context, msg). Behind the scenes:
- Your DApp invokes the circuit with the message.
- The witness function (
localSecretKey) is called, returns the private key. - The proof server generates a ZK proof of correct execution.
- The proof is submitted to the chain.
- Validators verify the proof, they never see the secret key.
Common Mistakes
-
Forgetting
disclose()on ledger writes. If you’re writing a witness-derived value to the ledger, you needdisclose(). The compiler catches this, but understanding why matters. -
Not using
asserton witness outputs. Witnesses are untrusted. Always validate their outputs:assert(balance > amount, "Insufficient balance"). -
Writing logic in the constructor. The constructor runs once and is done. If you need ongoing logic, use circuits.
-
Returning witness data directly. Returning a witness value from an exported circuit is a disclosure. Wrap it in
disclose()or don’t return it. -
Using
exporteverywhere.exportmakes fields and circuits callable from outside. Internal helpers should not be exported.
Comparison Layer
| Concept | Solidity | TypeScript | Compact |
|---|---|---|---|
| State | uint256 publicVar | class fields | export ledger f: T |
| Functions | function name() external | method() | export circuit name(): T |
| Constructor | constructor() { } | constructor() | constructor(params) { } |
| Private data | private (convention) | private fields | witness (stays local) |
| Initialization | in constructor | in constructor | constructor + disclose() |
Quick Recap
- Every contract has:
pragma,export ledger,witness,export circuit. - Optional:
constructorfor initialization. export ledger= public on-chain state.witness= private input (body in TypeScript).export circuit= logic that compiles to ZK circuits.assert()= runtime guard. Always validate witness outputs.disclose()= marks intentional disclosure to the public world.
Cross-Links
- Previous: Setting Up the Compiler, Toolchain setup
- Next: Ledger State, Public vs private state model
- See also: Circuits, How circuits work
- See also: Witnesses, Private input mechanism