I got frustrated trying to use the Compact standard library.
The docs list types like CurvePoint, Scalar, MerkleTree as if they work. Other articles repeat those names. You copy the pattern, run the compiler, and get an error telling you the name was deprecated two versions ago. Or that the function signature is wrong. Or that the type simply doesn't exist.
So I ran every single export through the compiler. Not "tested the general concept" — ran actual contracts through Compact v0.30.0 until they compiled or didn't. What follows is what I found: exact signatures, working code, and a clear note wherever something that's documented doesn't actually work yet.
Every code block in this article compiles. Where something doesn't, that's stated explicitly, with the error.
The companion repo — github.com/IamHarrie-Labs/compact-stdlib-reference — has all seven contracts as standalone .compact files you can run yourself.
Before you write a single line: the ZK mental model
Compact contracts don't run like normal programs. A circuit is a mathematical constraint system, not a sequence of instructions. When you call rotateNonce(), the compiler doesn't generate code that runs — it generates a zero-knowledge proof that the output was derived correctly from the inputs, without revealing the private inputs themselves.
This matters for two things that catch people off guard:
State branching is constrained. You can't write if (stored == none) { ... } and have different circuit paths execute at runtime. The circuit structure is fixed at compile time. That's why Maybe<T> has no is some operator — conditional branching on optional presence would require a non-deterministic circuit.
Privacy is explicit. Compact distinguishes between witness values (private, only the prover knows them) and public values (on the ledger, visible to everyone). The compiler refuses to let you write a witness value to public state without explicitly calling disclose(). This isn't boilerplate — it's the compiler enforcing the privacy model. If you try to skip it, you get a compilation error, not a runtime error.
Keep these two things in mind and most of the API makes sense immediately.
How to import
Every contract that uses the standard library starts with:
pragma language_version >= 0.20;
import CompactStandardLibrary;
One import, all ~30 exports in scope. You don't need individual imports per type.
1. Maybe<T> — optional values
Maybe<T> represents a value that may or may not exist. Use it for optional ledger fields, nullable state, and anything that gets set after construction.
There are two constructors:
-
none<T>()— the absent case -
some<T>(value)— the present case
And one accessor:
-
.value— retrieves the inner value
The important thing about .value: if you call it on a none, the ZK proof cannot be constructed. No exception is thrown, no runtime panic — proof generation simply fails. This is consistent with how circuits work. The constraint that "this field contains a value" is either satisfiable or it isn't.
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger stored: Maybe<Bytes<32>>;
constructor() {
stored = none<Bytes<32>>();
}
export circuit setStored(val: Bytes<32>): [] {
stored = disclose(some<Bytes<32>>(val));
}
export circuit clearStored(): [] {
stored = disclose(none<Bytes<32>>());
}
export circuit useStored(): Bytes<32> {
// If stored is none, proof generation fails here — not a runtime exception
return stored.value;
}
There is no is some or is none keyword in Compact. You can't branch on whether a Maybe is present inside a circuit. The right design: use separate circuits. Have one circuit that runs when the field is guaranteed to contain a value (useStored) and a different one to set it (setStored). The application layer decides which to call based on ledger state it reads off-chain.
2. Either<L, R> — two-branch union
Either<L, R> holds exactly one of two typed values. The standard library uses it in a few places internally — shieldedBurnAddress() returns Either<ZswapCoinPublicKey, ContractAddress>, and the OpenZeppelin Ownable.compact stores the owner as Either<ZswapCoinPublicKey, ContractAddress> to allow both user-key and contract-address ownership.
Constructors:
-
left<L, R>(value)— left branch -
right<L, R>(value)— right branch
Accessors:
-
.left— retrieves the left value; proof fails if this is a right value -
.right— retrieves the right value; proof fails if this is a left value
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger choice: Either<Bytes<32>, Bytes<32>>;
constructor() {
choice = disclose(left<Bytes<32>, Bytes<32>>(pad(32, "initial")));
}
export circuit pickLeft(val: Bytes<32>): [] {
choice = disclose(left<Bytes<32>, Bytes<32>>(val));
}
export circuit pickRight(val: Bytes<32>): [] {
choice = disclose(right<Bytes<32>, Bytes<32>>(val));
}
export circuit readLeft(): Bytes<32> {
// Proof fails if choice is currently right — not a runtime exception
return choice.left;
}
Accessing .left on a right value means the proof can't be constructed — same failure mode as .value on none. No branching on which side is active inside a circuit. The application layer reads the ledger state and calls the right circuit.
By convention, left tends to hold the "primary" or "success" case and right the "alternative" — but Compact doesn't enforce this. OpenZeppelin's Ownable.compact uses it as Either<ZswapCoinPublicKey, ContractAddress> where left is a user key and right is a contract address, which is probably the most common real-world usage.
3. Counter — monotonic sequence numbers
Counter is a ledger-only type that increments. It's the canonical way to track sequence numbers, nonces, and usage counts in Compact.
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger seq: Counter;
export ledger lastKey: Bytes<32>;
witness secretKey(): Bytes<32>;
constructor() {
seq.increment(1);
}
export circuit nextKey(): [] {
// Use the counter value as part of a domain-separated hash
const key = persistentHash<Vector<2, Bytes<32>>>([
seq as Field as Bytes<32>,
secretKey()
]);
lastKey = disclose(key);
seq.increment(1);
}
seq.increment(n) mutates the ledger field in place. There's no assignment syntax — you don't write seq = seq + 1. The mutation is applied directly.
seq as Field casts the counter to a Field value you can feed into arithmetic or hash inputs. The double cast seq as Field as Bytes<32> above handles the type coercion needed for vector input.
Counters can't be decremented. A ZK circuit that allowed decrement would need to prove the new value is less than the old one, which changes the circuit structure. So Compact just doesn't allow it: counters go up, never down.
4. Hashing and commitments
persistentHash — domain-separated one-way hash
persistentHash is the primary hash function in Compact. Commitments, access control checks, nullifiers — if you're doing any kind of cryptographic binding in a circuit, you're using this.
persistentHash<Vector<N, Bytes<32>>>(inputs: Vector<N, Bytes<32>>): Bytes<32>
The type parameter tells the compiler how many inputs you're providing. You get back a 32-byte hash.
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger commitment: Bytes<32>;
witness secretValue(): Bytes<32>;
export circuit commit(): [] {
const h = persistentHash<Vector<2, Bytes<32>>>([
pad(32, "myapp:commit:"),
secretValue()
]);
commitment = disclose(h);
}
export circuit verify(): [] {
const h = persistentHash<Vector<2, Bytes<32>>>([
pad(32, "myapp:commit:"),
secretValue()
]);
assert(h == commitment, "Proof invalid: secret does not match commitment");
}
Without a domain prefix, the same secret value hashes to the same output in every circuit — which means a proof generated for one circuit can potentially satisfy constraints in a different one. Adding a domain string like "myapp:commit:" ties each hash to its specific application. The example-bboard canonical reference uses "bboard:pk:" as its domain separator. Pick something specific to your contract and use it consistently.
pad — string to bytes
pad(n, str) converts a string literal to a Bytes<n> value, right-padded with zeros. Almost always used to create domain separator inputs for persistentHash.
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger h: Bytes<32>;
export circuit hashDomain(): [] {
h = disclose(persistentHash<Vector<1, Bytes<32>>>([pad(32, "myapp:hash:")]));
}
n must match the target type exactly. pad(32, "myapp:") produces Bytes<32>. If you use pad(16, "myapp:") and the target is Bytes<32>, you'll get a type error.
persistentCommit — append-only commitment accumulator
persistentCommit advances a commitment root by incorporating a new value. Think of it as a Merkle accumulator in a single function call: you have a root, you add a value, you get a new root that commits to everything that's been added so far.
persistentCommit<T>(root: Bytes<32>, value: Bytes<32>): Bytes<32>
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger root: Bytes<32>;
constructor() {
root = pad(32, "empty");
}
export circuit append(value: Bytes<32>): [] {
root = disclose(persistentCommit<Bytes<32>>(root, disclose(value)));
}
The root represents the accumulated state of all values appended so far. You can't remove values or reorder them — the accumulator is append-only, which is exactly what you want for an audit log or a history of actions.
One more thing on MerkleTree: the type MerkleTree<N, T> exists in the type system and appears in documentation, but you cannot store it directly as a ledger field. The compiler rejects it as an ADT type in ledger position. The error looks like:
error: algebraic data types not supported in ledger fields
If you need accumulator functionality — which is what MerkleTree gives you in most use cases — persistentCommit with a Bytes<32> root is exactly what you want. The root is the Merkle accumulator state; persistentCommit is the operation that advances it.
5. Kernel identity types
ZswapCoinPublicKey — user public keys
ZswapCoinPublicKey is the type returned by ownPublicKey() and used to represent a user's public key on Midnight. It shows up in ownership patterns, access control, and the shieldedBurnAddress() return type.
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger registered: ZswapCoinPublicKey;
export circuit register(): [] {
registered = disclose(ownPublicKey());
}
There's a significant security problem with this pattern. ownPublicKey() compiles to an unconstrained private_input — the prover supplies the value without any proof of ownership. Anyone who reads registered from the ledger can call withdraw() with that same value in their CircuitContext. They don't need the secret key. The circuit has no way to distinguish the real key holder from someone who just copied the stored value.
The fix is to store a commitment instead:
export ledger vault_owner: Bytes<32>;
witness localSecretKey(): Bytes<32>;
circuit ownerCommitment(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "vault:owner:"), sk]);
}
export circuit deposit(): [] {
vault_owner = disclose(ownerCommitment(localSecretKey()));
}
export circuit withdraw(): [] {
assert(ownerCommitment(localSecretKey()) == vault_owner, "Not the vault owner");
}
Now the contract stores a hash of the secret key, not the key itself. The withdraw circuit requires the prover to supply the actual secret key (which they keep private), and proves that its hash matches what's on the ledger. An attacker who copies vault_owner from the ledger can't construct a valid proof without the secret key. This is covered in detail in the companion article on the ownPublicKey() vulnerability.
ContractAddress — contract identity
Represents the on-chain address of a deployed contract. Used in token type derivation and any ownership pattern where a contract, rather than a user, needs to be the owner.
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger nativeT: Bytes<32>;
export ledger customT: Bytes<32>;
export circuit setTokenTypes(addr: ContractAddress): [] {
nativeT = disclose(nativeToken());
customT = disclose(tokenType(pad(32, "token:"), addr));
}
UserAddress — cross-layer user identity
A higher-level user identity type encoding network-specific address formats. Used when interfacing with Midnight's identity or wallet layers rather than the ZK proof system directly. It's in scope from import CompactStandardLibrary; but doesn't appear often in typical contract code.
6. Helper circuits
ownPublicKey
ownPublicKey(): ZswapCoinPublicKey
Returns the public key supplied by the prover in their CircuitContext. Unconstrained — see the ZswapCoinPublicKey section above for the security implications and the fix.
nativeToken
nativeToken(): Bytes<32>
Returns the token type identifier for Midnight's native NIGHT token. Use this when your contract needs to reference or compare against the native token.
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger accepted: Bytes<32>;
export circuit acceptNative(): [] {
accepted = disclose(nativeToken());
}
tokenType
tokenType(prefix: Bytes<32>, addr: ContractAddress): Bytes<32>
Derives a unique token type identifier for a given contract address with a domain prefix. Use this to create a token identifier tied to your contract.
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger nativeT: Bytes<32>;
export ledger customT: Bytes<32>;
export circuit setTokenTypes(tokenContract: ContractAddress): [] {
nativeT = disclose(nativeToken());
customT = disclose(tokenType(pad(32, "token:"), tokenContract));
}
The signature requires two arguments: a Bytes<32> prefix and the ContractAddress. Several community articles show tokenType(contractAddress) with only one argument — the compiler rejects this. The prefix is required. Use pad(32, "token:") or a more specific domain string.
evolveNonce
evolveNonce(nonce: Uint<128>, tag: Bytes<32>): Bytes<32>
Derives a new nonce bytes value from a counter and a domain tag. The main use case is generating unique nullifiers or per-action commitment seeds — values that are different for every action, deterministically derived from a counter, and impossible to predict without knowing the counter state.
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger nonce: Uint<128>;
export ledger nonceHash: Bytes<32>;
constructor() {
nonce = disclose(0 as Uint<128>);
nonceHash = pad(32, "initial:");
}
export circuit rotateNonce(): [] {
nonceHash = disclose(evolveNonce(nonce, pad(32, "rotate:")));
nonce = disclose((nonce + 1) as Uint<128>);
}
The first argument is Uint<128>, not Bytes<32>. This is the most common mistake with this function — other articles show Bytes<32> as the first argument, and the compiler rejects it. The ledger field storing the nonce counter needs to be typed Uint<128>, and the return type is Bytes<32>.
shieldedBurnAddress
shieldedBurnAddress(): Either<ZswapCoinPublicKey, ContractAddress>
Returns the canonical burn address for permanently destroying shielded tokens. Sending tokens to this address is irrecoverable — nothing can spend from the burn address.
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger burnAddr: Either<ZswapCoinPublicKey, ContractAddress>;
constructor() {
burnAddr = disclose(shieldedBurnAddress());
}
disclose — making private values public
disclose(value: T): T
disclose is how Compact enforces the boundary between private and public state. Witness values — anything returned by a witness function — are private: only the prover knows them. To write a witness-derived value to a ledger field, which is public, you must explicitly call disclose.
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger stored: Bytes<32>;
witness privateData(): Bytes<32>;
export circuit store(): [] {
// Without disclose(), this produces a compile error:
// "potential witness-value disclosure not wrapped in disclose()"
stored = disclose(privateData());
}
The compiler will refuse to let you skip this. That's intentional. The requirement to explicitly type disclose is a forcing function: you have to consciously decide, for every ledger write, "yes, I am choosing to make this value public." It catches accidental disclosure of private data at compile time rather than at runtime.
7. Elliptic curve: JubjubPoint
The elliptic curve type in Compact is JubjubPoint. If you've read older documentation or articles that use CurvePoint, that name has been deprecated. The compiler gives you a clear error when you try to use it:
apparent use of an old standard-library name CurvePoint: the new name is JubjubPoint
JubjubPoint is an opaque type. It has no accessible .x or .y fields. You can't add or multiply them directly. The compiler reports it as Opaque<"JubjubPoint">, which means it's a value your circuit can hold and pass around, but the internal structure is hidden. The Jubjub curve is used internally by the ZK proving system for things like Pedersen commitments and keypair operations — it's not a general-purpose EC type you'd use for application-level math.
If you need commitments in your contract, use persistentHash. It works, it's fully supported, and you don't need to touch curve points at all.
Scalar — the companion type used alongside CurvePoint for scalar multiplication in older documentation — is also not available. It produces an "unbound identifier" error in v0.30.0. For scalar arithmetic in circuit code, use Field.
8. Shielded token operations
receiveShielded and sendShielded
These handle private token transfers into and out of contracts. They operate on ShieldedCoinInfo — structured coin data from Midnight's UTXO layer — and interact with the full shielded transaction pipeline.
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger tokenType_: Bytes<32>;
witness inputCoin(): ShieldedCoinInfo;
export circuit depositShielded(): [] {
tokenType_ = disclose(nativeToken());
receiveShielded(inputCoin());
}
Shielded transfers need the full proof pipeline — proof server, shielded coin commitment tree, the whole thing. skipZk: true won't catch the proof-level constraints; you need a running local Devnet. sendShielded is the harder one — it requires the proof server to generate output proofs, and local Devnet setup is the only reliable way to test it end-to-end.
ShieldedCoinInfo is in scope from import CompactStandardLibrary;. No separate import needed.
9. Block-time queries
The bounty specification and a few community articles reference block-time functions: getBlockTime(), getBlockNumber(), getEpoch(). As of compiler v0.30.0, none of these compile.
I tried every reasonable naming variant: blockHeight, currentBlock, currentBlockHeight, blockNumber, blockTime, slotNumber, currentSlot, epoch, currentEpoch, getSlot, getHeight, getTimestamp, and about a dozen others. None of them resolve. The compiler reports "unbound identifier" for all of them.
These functions may land in a future compiler version, or may require a transaction context that isn't available in the static compilation model used by the playground API. I don't know which — the compiler error doesn't distinguish between "not implemented yet" and "wrong name."
If you need time-gated logic today, the workaround is to pass the block target as a circuit parameter and store it in ledger state:
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger lockUntil: Field;
export circuit setLock(blockTarget: Field): [] {
lockUntil = disclose(blockTarget);
}
The enforcement happens off-chain. The application layer reads lockUntil from the ledger, checks the current block, and refuses to call the guarded circuit until the lock expires. It's not as elegant as on-chain enforcement, but it works.
10. Common pitfalls
These are the issues that either break compilation silently, cause proof generation to fail with a confusing error, or create a security hole. Most of them aren't documented clearly anywhere.
Accessing .value on none fails proof generation with no message
// If stored is none and you call this, proof generation fails.
// You don't get a clear error — the proof just can't be constructed.
export circuit useStored(): Bytes<32> {
return stored.value;
}
Structure your contracts so circuits that access .value are only called when the value is guaranteed to be present. Separate the "set" and "use" circuits. Have the application layer check ledger state before deciding which to call.
CurvePoint and Scalar are gone
// Both of these produce compiler errors in v0.30.0
export ledger pt: CurvePoint; // error: use JubjubPoint
export ledger s: Scalar; // error: unbound identifier
Use JubjubPoint for the curve point type. For scalar values, use Field. Both of the old names are rejected.
evolveNonce takes Uint<128>, not Bytes<32>
// WRONG — compiler rejects Bytes<32> as the first argument
export ledger nonce: Bytes<32>;
evolveNonce(nonce, pad(32, "tag:"))
// RIGHT — first argument must be Uint<128>
export ledger nonce: Uint<128>;
evolveNonce(nonce, pad(32, "tag:"))
The return type is Bytes<32>, so a lot of articles store both the counter and the result as Bytes<32>. That's wrong for the input. The counter must be Uint<128>.
tokenType takes two arguments
// WRONG — compiler rejects this (missing prefix)
tokenType(contractAddress)
// RIGHT
tokenType(pad(32, "token:"), contractAddress)
The prefix argument is required. No overload without it exists in v0.30.0.
MerkleTree<N, T> can't live in ledger state
// WRONG — compiler error: ADT types not supported in ledger fields
export ledger tree: MerkleTree<8, Bytes<32>>;
// RIGHT — store the accumulator root as Bytes<32>
export ledger root: Bytes<32>;
root = disclose(persistentCommit<Bytes<32>>(root, disclose(newLeaf)));
MerkleTree is a valid type in the type system but can't be stored as a ledger field. The error message is algebraic data types not supported in ledger fields. Use persistentCommit with a Bytes<32> root instead.
Every witness-to-ledger write requires disclose()
// WRONG — compile error: potential witness-value disclosure
export ledger h: Bytes<32>;
witness sk(): Bytes<32>;
export circuit store(): [] {
h = persistentHash<Vector<1, Bytes<32>>>([sk()]);
}
// RIGHT
export circuit store(): [] {
h = disclose(persistentHash<Vector<1, Bytes<32>>>([sk()]));
}
This applies even when the witness value has been transformed by persistentHash. The result of persistentHash(sk()) is still derived from a private input, so the compiler requires disclose() on the assignment.
11. A note on verifyCommitment
Some documentation and older articles reference a verifyCommitment function. It's not in v0.30.0. The compiler reports it as an unbound identifier. To verify a commitment, re-derive the hash with the same inputs and compare with assert:
export circuit verify(): [] {
const h = persistentHash<Vector<2, Bytes<32>>>([
pad(32, "myapp:commit:"),
secretValue()
]);
assert(h == commitment, "Commitment mismatch");
}
This is equivalent and compiles cleanly.
12. Quick reference
All exports verified against Compact compiler v0.30.0. The "Status" column is honest — red means I tried and it doesn't work in the current version, not that I skipped testing it.
| Export | Category | Signature / Type | Status |
|---|---|---|---|
Maybe<T> |
Generic |
none<T>() / some<T>(v) / .value
|
✅ |
Either<L,R> |
Generic |
left<L,R>(v) / right<L,R>(v) / .left / .right
|
✅ |
Counter |
Type |
.increment(n) / as Field
|
✅ |
persistentHash |
Hash |
<Vector<N,Bytes<32>>>([...inputs]) → Bytes<32>
|
✅ |
pad |
Utility |
(n, str) → Bytes<n>
|
✅ |
persistentCommit |
Commitment |
<T>(root: Bytes<32>, val: Bytes<32>) → Bytes<32>
|
✅ |
disclose |
Privacy |
(value: T) → T
|
✅ |
ZswapCoinPublicKey |
Identity | Type — returned by ownPublicKey()
|
✅ |
ContractAddress |
Identity | Type — used in tokenType()
|
✅ |
UserAddress |
Identity | Type | ✅ |
ownPublicKey |
Helper |
() → ZswapCoinPublicKey (unconstrained — see security warning) |
✅ |
nativeToken |
Token |
() → Bytes<32>
|
✅ |
tokenType |
Token |
(prefix: Bytes<32>, addr: ContractAddress) → Bytes<32>
|
✅ |
evolveNonce |
Helper |
(nonce: Uint<128>, tag: Bytes<32>) → Bytes<32>
|
✅ |
shieldedBurnAddress |
Helper |
() → Either<ZswapCoinPublicKey, ContractAddress>
|
✅ |
receiveShielded |
Token | (coin: ShieldedCoinInfo) |
✅ |
sendShielded |
Token | Requires proof server | ✅ |
JubjubPoint |
Crypto | Opaque type — replaces deprecated CurvePoint
|
✅ |
ShieldedCoinInfo |
Type | Coin data for shielded transfers | ✅ |
Field |
Type | Native ZK field element | ✅ |
Bytes<n> |
Type | Byte array of length n | ✅ |
Uint<n> |
Type | Unsigned integer up to n bits | ✅ |
Opaque<"string"> |
Type | Circuit-opaque string data | ✅ |
MerkleTree<N,T> |
Type | ADT — exists in type system, can't be stored in ledger | ⚠️ |
getBlockTime |
Block | Unbound identifier in v0.30.0 | ❌ |
getBlockNumber |
Block | Unbound identifier in v0.30.0 | ❌ |
getEpoch |
Block | Unbound identifier in v0.30.0 | ❌ |
CurvePoint |
Crypto | Deprecated — use JubjubPoint
|
🚫 |
Scalar |
Crypto | Unbound in v0.30.0 — use Field
|
🚫 |
verifyCommitment |
Commitment | Not found in v0.30.0 | ❌ |
What this all adds up to
The standard library is actually pretty small once you strip out what doesn't work yet. In practice, you're reaching for maybe eight things: Maybe, Either, Counter, persistentHash, pad, disclose, ZswapCoinPublicKey, and whichever token functions you need. The rest is either advanced infrastructure (receiveShielded, sendShielded) or future capability (getBlockTime, getEpoch).
Two things tripped up every other implementation I looked at while writing this:
The type renames. CurvePoint and Scalar still appear in community articles and the older docs. The compiler is clear about what happened — it tells you the new name in the error message — but only if you actually run your code. Articles that copy-paste from older documentation without running the compiler will keep getting this wrong.
The function signatures. evolveNonce takes Uint<128> as its first argument, not Bytes<32>. tokenType takes two arguments. persistentCommit takes two Bytes<32> values, not a MerkleTree. These aren't obscure edge cases — they're the exact calls you'd write in almost any real contract. If your code doesn't compile, it's not a reference.
All seven contracts in the companion repo — github.com/IamHarrie-Labs/compact-stdlib-reference — are standalone, one-contract-per-concept files. Run any of them through the Midnight MCP toolchain or the playground API and they'll compile cleanly.
All code compiled and verified against Compact compiler v0.30.0 using the Midnight MCP toolchain. Questions or corrections? Leave a comment — if something changed in a newer compiler version, I'll update the article.
Top comments (0)