Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. pragma, Declares the language version.
  2. export ledger, Public state that lives on-chain.
  3. witness, Declares where private data comes from (body is in TypeScript).
  4. 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 read
  • witness = where your secrets come from
  • export circuit = what you can prove happened
  • constructor = 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:

  1. Deployment: constructor() runs once. State is VACANT, message is none.
  2. Post: Caller’s derived public key is stored as owner. The message is disclosed and stored.
  3. 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:

  1. Your DApp invokes the circuit with the message.
  2. The witness function (localSecretKey) is called, returns the private key.
  3. The proof server generates a ZK proof of correct execution.
  4. The proof is submitted to the chain.
  5. Validators verify the proof, they never see the secret key.

Common Mistakes

  1. Forgetting disclose() on ledger writes. If you’re writing a witness-derived value to the ledger, you need disclose(). The compiler catches this, but understanding why matters.

  2. Not using assert on witness outputs. Witnesses are untrusted. Always validate their outputs: assert(balance > amount, "Insufficient balance").

  3. Writing logic in the constructor. The constructor runs once and is done. If you need ongoing logic, use circuits.

  4. Returning witness data directly. Returning a witness value from an exported circuit is a disclosure. Wrap it in disclose() or don’t return it.

  5. Using export everywhere. export makes fields and circuits callable from outside. Internal helpers should not be exported.


Comparison Layer

ConceptSolidityTypeScriptCompact
Stateuint256 publicVarclass fieldsexport ledger f: T
Functionsfunction name() externalmethod()export circuit name(): T
Constructorconstructor() { }constructor()constructor(params) { }
Private dataprivate (convention)private fieldswitness (stays local)
Initializationin constructorin constructorconstructor + disclose()

Quick Recap

  • Every contract has: pragma, export ledger, witness, export circuit.
  • Optional: constructor for 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.