midnight-contracts: six Compact smart contracts to learn Midnight from, tested without a node

The first thing most people do when they want to read a Midnight contract is install things. Docker, a local node, a wallet, a proof server, then a frontend to click on. All of that is needed to run a dApp. None of it is needed to read a contract and check that it behaves.
midnight-contracts is the repo we keep for that second job. It holds six Compact smart contracts in order of difficulty, each in its own workspace with the source, the witnesses, an in-memory simulator, a Vitest suite and a docs.md walkthrough written for someone reading their first contract. Clone it, run npm install and npm test, and 59 tests pass without a node, a wallet or a proof server. Beyond Node itself, the Compact compiler is the only tool you need.
The two newest workspaces landed this week. They build tokens from OpenZeppelin's Compact modules, and they are the reason for this post. They also sit at the top of a ladder, so the tour starts at the bottom.
Six contracts, in order
- 01CounterPublic state, one circuit.
- 02Bulletin boardA witness, and disclose.
- 03Unshielded tokenPublic coins, from the docs.
- 04Shielded tokenZswap coins, hidden value.
- 05OZ fungibleModules; balances in a Map.
- 06OZ native shieldedModules; coins in wallets.
Every workspace has the same five parts, so the second contract costs less to read than the first:
src/<name>.compact → the contract source
↓ compact compile
src/managed/<name>/ → generated TypeScript API, ZK keys, circuit IR
src/witnesses.ts → private-state type + witness functions
src/test/simulators/ → in-memory CircuitContext harness
src/test/<name>.test.ts → Vitest suiteAnd every docs.md follows the same ten sections: what the contract does, run it in sixty seconds, the source line by line, public versus private state, what the compiler generates, witnesses, the simulator, the tests, exercises, credits. Once you have read the counter's walkthrough you know where everything is in the other five.
The counter: nine lines
This is the whole contract:
pragma language_version >= 0.23;
import CompactStandardLibrary;
// public state
export ledger round: Counter;
// transition function changing public state
export circuit increment(): [] {
round.increment(1);
}One piece of public state, one circuit that changes it. What the walkthrough spends its time on is the word circuit. It is not a function. Every state transition on Midnight is compiled into a zero-knowledge circuit, and when someone calls increment they do not just run it, they produce a proof that they ran it correctly. The counter has nothing private to hide, so its proof is trivial. The machinery is the same one the private contracts use, which is why it is worth meeting on a contract this small.
The bulletin board: where disclose lives
The second contract adds a witness: a value that comes from the caller's own machine and never touches the chain. Anyone can post one note to the board, and only the author can take it down. The author's identity is a hash of a secret key, and the secret key stays with them.
witness local_secret_key(): Bytes<32>;
pure circuit compute_author_commitment(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "bboard:author:"), sk]);
}
export circuit postNote(content: Opaque<"string">): [] {
assert(disclose(!hasNote), "Board is full - someone already posted a note");
const sk = local_secret_key();
const commitment = compute_author_commitment(sk);
note = disclose(content);
authorCommitment = disclose(commitment);
hasNote = true;
}- the note, as typed
- the hash of your secret key
- local_secret_keynever crosses
- note
- authorCommitment
- hasNote
The walkthrough checked one rule against the compiler, and it is the rule to carry into every later contract: disclose is required when a private value is written to the ledger. The two calls on the assignments are mandatory. The one wrapping the assert is not, and the contract compiles without it, because asserting on a private value is not a write. A detail that surprises people: content is a circuit parameter, and the compiler still treats it as private. In Compact everything flowing into a circuit is private by default, and the ledger is the only public surface. So the mental model is narrow. A private value becomes public the moment it is written to the ledger, and that write is the thing you declare.
Two tokens from the Midnight docs
The third and fourth contracts are the unshielded and shielded token examples from the official documentation, ported verbatim so you can diff them against the source. An unshielded token is a number in public state: mint adds to it, send moves it, anyone can read it. A shielded token is a Zswap coin, an object with a hidden value and a hidden owner, and sending part of one means destroying it and minting two new coins, one to the recipient and one back to you as change.
The shielded walkthrough has one gotcha that costs people an afternoon. The contract has to re-export the coin types by name:
export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult };Without that line the compiler inlines the shapes anonymously, no named type reaches the generated .d.ts, and TypeScript callers pick up the identically named type from the runtime package instead, whose colour field is type rather than color. The code still runs, because the objects are right at runtime. Only the types lie.
The new step: OpenZeppelin modules
The two workspaces added this week are oz-fungible-token-contract and oz-native-shielded-token-contract. Both vendor modules from OpenZeppelin's Compact library, unmodified and pinned at v0.3.0-alpha.2, and both are as much about composition as about tokens.
A module is not a contract. It has no constructor and nothing to deploy. It is a bundle of ledger fields and circuits that gets merged into a host contract under a prefix you choose:
import "./modules/token/FungibleToken" prefix FungibleToken_;
import "./modules/access/Ownable" prefix Ownable_;
import "./modules/security/Pausable" prefix Pausable_;
export {
FungibleToken__balances,
FungibleToken__allowances,
FungibleToken__totalSupply
};
export { Ownable__owner };
export { Pausable__isPaused };The double underscore trips everyone up once. It is the prefix you chose, Pausable_, followed by a name that began with an underscore inside the module: Pausable_ and _isPaused make Pausable__isPaused, while isPaused() had no underscore and comes out as Pausable_isPaused(). Nothing clever is happening, as the walkthrough puts it.
Here is the part that matters more than the naming. OpenZeppelin's module knows how to mint. It does not decide who may. Every _-prefixed circuit in the library (FungibleToken__mint, Pausable__pause, NativeShieldedToken__burn) ships with no access control on purpose, and the contract that imports the module is expected to add it. In the fungible token that is one line:
export circuit mint(
account: Either<Bytes<32>, ContractAddress>,
value: Uint<128>
): [] {
Ownable_assertOnlyOwner();
FungibleToken__mint(account, value);
}The walkthrough's first exercise is to delete Ownable_assertOnlyOwner(), recompile and run the tests. Two fail. Nineteen still pass, and anyone in the world can now mint themselves an unlimited balance. The suite stays mostly green because most of it tests mechanics, and mechanics are what the module gets right on its own. The tests that catch this are the ones written to defend a decision, which is the next section.
Same library, two machines
The second OpenZeppelin workspace uses the same Ownable module and the same two-line shape, and produces a different kind of thing. Instead of keeping balances in a Map inside contract state, it mints real Zswap coins that live in wallets. The walkthrough puts the two side by side:
| Native shielded token | Fungible token | |
|---|---|---|
| Value lives | in wallets, as Zswap coins | in a Map in contract state |
| Ledger grows with holders | no, it is fixed | yes, one row per holder |
| Balances visible | no | yes, to everyone |
| Partial spend | destroy and re-mint change | decrement a number |
| Is it chain money | yes | no, a bookkeeping convention |
The difference shows in the signature of burn:
export circuit burn(
coin: ShieldedCoinInfo,
amount: Uint<128>,
refundTo: Either<ZswapCoinPublicKey, ContractAddress>
): Maybe<ShieldedCoinInfo> {
Ownable_assertOnlyOwner();
return NativeShieldedToken__burn(coin, amount, refundTo);
}Coins are indivisible objects, not numbers. Burning part of one means destroying it and minting the remainder back, and the returned Maybe<ShieldedCoinInfo> is that remainder: your change. Burn the whole coin and you get is_some: false. The token's metadata is declared sealed ledger, which means write-once at construction; a later write is a compile-time error, not a runtime one. A contract whose state does not grow with its user count is a different thing from an ERC-20, and reading the two workspaces together is the fastest way to feel that.
Tests you can read, and break
The test suites are written to be read. Each test carries a comment saying which decision it defends, and the walkthroughs mark the ones that pin a choice rather than a mechanism. Two from the fungible token:
it("lets the owner burn a balance they do not hold", () => {
// Worth pinning explicitly, because it is a confiscation power and it is
// easy to miss: the module checks only that the target HAS the funds, not
// that the caller owns them. Alice loses her balance without consenting.
token.as("owner").burn(ALICE.either, 1_000n);
expect(token.as("owner").balanceOf(ALICE.either)).toBe(0n);
});it("rejects transferring more than the balance", () => {
// Match the message, not just any throw: the line after the module's
// `assert(fromBal >= value)` underflows a Uint<128> and panics on its own,
// so a bare toThrow() would stay green even with that assert deleted.
expect(() => token.as("alice").transfer(BOB.either, 1_001n)).toThrow(
/insufficient balance/i
);
});The second one had a weaker first version. It used a bare toThrow(), which stayed green even with the balance check deleted, because the subtraction on the next line underflows and panics by itself. It was testing the underflow, not the check it named. The walkthrough keeps the story in, with the lesson attached: a reverting test that does not match its message is often testing less than it appears to.
What the docs refuse to claim
Each OpenZeppelin workspace ships a src/modules/README.md that reads more like a list of refusals than a feature list. The modules are unmodified copies, and the rule is never to edit one, because a local edit silently forks from upstream and the word "unmodified" becomes false. Instead of a promise, the repo gives you a gh api command to pull the file at the pinned tag and diff it yourself.
The audit row of its table says "not this version". The README records that OpenZeppelin commissioned a full audit of the library in May 2026, covering v0.1.0. The version pinned here is newer, and some modules in the v0.3.0-alpha line, including the native shielded token, did not exist at v0.1.0 and have never been audited. The README ends with one sentence: these workspaces are teaching material, do not put value behind them.
We write caveats like that into a teaching repo because they are the part a beginner cannot work out alone. Which line to copy is easy to see. Which claim the copied line does not carry is not.
Start here
- Cloneeddalabs/midnight-contracts on GitHub, with git clone
- Installnpm install, with Node 18 or newer and the Compact compiler on your path
- Testnpm test at the root runs all six suites, and each one compiles its contract first
- Readcounter-contract/docs.md, then up the ladder; read the fungible token before the native shielded one
- BreakEvery walkthrough ends with exercises that delete a guard or reuse a nonce and tell you what should turn red
The library has more modules than these two workspaces use, and the native shielded walkthrough ends by asking you to vendor one more, the extension that tracks total supply, and to watch which tests turn red. Adding a workspace of your own means following the five-part pattern the six existing ones follow.