DEV Community

Tosh
Tosh

Posted on

Compact Standard Library: A Practical Guide to Every Export

Compact Standard Library: A Practical Guide to Every Export

The Compact standard library ships with every project — you don't install it, you don't import a package. It's just there. But the docs are sparse enough that most developers only discover what's in it by reading other people's contracts or tripping over a type error. This guide aims to fix that. I'll walk through every meaningful export, grouped by what it actually does, with working code examples for each.


Generic Types: Maybe and Either

These two types show up constantly in real contracts. If you've written any amount of TypeScript or Rust, the concepts will be familiar, but the ZK context adds some nuances worth understanding.

Maybe<T>

Maybe<T> is an optional value — it either holds something (Some(value)) or it doesn't (None). In Compact, it's used wherever you need to represent the absence of a value without resorting to sentinel values like 0 or -1.

contract MaybeExample {
  ledger owner: Maybe<ContractAddress>;

  circuit setOwner(addr: ContractAddress): [] {
    ledger.owner = Maybe.some(addr);
  }

  circuit clearOwner(): [] {
    ledger.owner = Maybe.none();
  }

  circuit checkOwner(addr: ContractAddress): [Boolean] {
    const current = ledger.owner;
    return [current.isSome && current.value == addr];
  }
}
Enter fullscreen mode Exit fullscreen mode

The key properties: .isSome (a Boolean), .isNone, and .value (the inner value, only safe to access when you know it's Some). Accessing .value on a None in a ZK circuit causes a constraint failure — it doesn't throw an exception, it makes the proof impossible to generate. Keep that in mind.

A practical pattern is using Maybe<T> for optional ledger fields that get initialized lazily:

circuit initializeIfNeeded(config: Uint<64>): [] {
  if ledger.config.isNone {
    ledger.config = Maybe.some(config);
  }
}
Enter fullscreen mode Exit fullscreen mode

Either<L, R>

Either<L, R> represents one of two possible types — a Left(L) or a Right(R). By convention, Left is used for error states and Right for success, but Compact doesn't enforce this — it's just a tagged union.

circuit divide(a: Uint<64>, b: Uint<64>): [Either<Bytes<32>, Uint<64>>] {
  if b == 0 {
    return [Either.left(bytes("division by zero"))];
  }
  return [Either.right(a / b)];
}
Enter fullscreen mode Exit fullscreen mode

You can discriminate on Either with .isLeft, .isRight, .leftValue, and .rightValue. Same caveat as Maybe — accessing the wrong branch fails proof generation.

Either is less common than Maybe in practice but comes up when you need to return structured errors from a circuit, or when modeling two distinct outcomes of a ledger operation (think: deposit vs. withdrawal result).


Merkle Tree Utilities

Merkle trees are central to ZK contract design. The standard library gives you the tree type itself plus the commitment and verification tools you need to do privacy-preserving state proofs.

MerkleTree<N, T>

MerkleTree<N, T> is a complete binary Merkle tree with N levels (so 2^N leaves) holding values of type T. The tree is stored in the ledger and supports O(log N) membership proofs.

contract Registry {
  ledger members: MerkleTree<20, Bytes<32>>;
  ledger memberCount: Uint<64>;

  circuit addMember(commitment: Bytes<32>): [Uint<64>] {
    const index = ledger.memberCount;
    ledger.members.set(index, commitment);
    ledger.memberCount = index + 1;
    return [index];
  }
}
Enter fullscreen mode Exit fullscreen mode

The tree size is fixed at compile time via N. If you need more than 2^N entries, you're out of luck without a redesign. Plan ahead — 20 levels gives you ~1 million entries, which is usually enough for registry-style contracts.

persistentCommit

persistentCommit creates a commitment to a value that gets stored in the Midnight state tree. This is distinct from just hashing — the commitment gets anchored to a specific epoch, making it possible to prove "this value existed at this point in time" without revealing the value.

circuit commitSecret(witness secret: Bytes<32>): [Bytes<32>] {
  const commitment = persistentCommit(secret);
  ledger.commitments.push(commitment);
  return [commitment];
}
Enter fullscreen mode Exit fullscreen mode

The commitment is deterministic for a given secret — same input always produces the same output — which is what makes nullifier-style patterns work. More on that in the replay prevention article.

verifyCommitment

verifyCommitment is the counterpart to persistentCommit. Given a secret and a commitment, it verifies that the commitment was generated from that secret.

circuit revealSecret(
  witness secret: Bytes<32>,
  public commitment: Bytes<32>
): [Boolean] {
  return [verifyCommitment(secret, commitment)];
}
Enter fullscreen mode Exit fullscreen mode

This pattern — commit now, reveal later — is fundamental to privacy-preserving voting, sealed bidding, and ownership proofs. Commit on-chain, reveal off-chain when needed.


Elliptic Curve Types

Midnight's ZK circuits operate over elliptic curves under the hood, but the standard library surfaces some of this for contracts that need to do cryptographic operations explicitly.

CurvePoint

CurvePoint represents a point on the elliptic curve used by Midnight's proof system. You interact with these when doing key derivation, signature verification, or building custom commitment schemes.

circuit derivePublicKey(witness privateKey: Scalar): [CurvePoint] {
  const G = CurvePoint.generator();
  return [G.multiply(privateKey)];
}
Enter fullscreen mode Exit fullscreen mode

The main operations: .multiply(scalar), .add(other), .negate(), and the static .generator() which returns the base point. All operations are group operations in the ZK circuit — they generate constraints, so they're not "free."

Scalar Operations

Scalars are the field elements that serve as private keys and randomness in elliptic curve operations. The scalar type wraps a field element with operations appropriate for the scalar field.

circuit blindValue(
  witness value: Uint<64>,
  witness blinding: Scalar
): [CurvePoint] {
  const H = CurvePoint.hashToCurve(bytes("value-commitment"));
  const G = CurvePoint.generator();
  // Pedersen commitment: value*H + blinding*G
  return [H.multiply(Scalar.fromUint(value)).add(G.multiply(blinding))];
}
Enter fullscreen mode Exit fullscreen mode

The Pedersen commitment pattern above is worth knowing — it's a standard technique for committing to a value while keeping it hidden, with the property that commitments are additively homomorphic.


Kernel Types

These types bridge the ZK contract world and the Midnight network's transaction kernel. They're how your contract talks about identity, addresses, and shielded coin state.

ContractAddress

ContractAddress is the canonical identity type for smart contracts on Midnight. It's derived from the contract's code hash and deployment parameters — it's deterministic and unique.

contract AccessControlled {
  ledger admin: ContractAddress;

  circuit initialize(adminAddr: ContractAddress): [] {
    assert ledger.admin == ContractAddress.zero() : "already initialized";
    ledger.admin = adminAddr;
  }

  circuit adminAction(): [] {
    // thisContractAddress() gives you the calling contract's address
    assert ledger.admin == ContractAddress.self() : "unauthorized";
  }
}
Enter fullscreen mode Exit fullscreen mode

Use ContractAddress (not ownPublicKey()) when you need identity-based access control. The contract address is derived from verifiable on-chain data; ownPublicKey() is not. This distinction matters a lot — see the separate article on ownPublicKey() risks.

ZswapCoinPublicKey

ZswapCoinPublicKey is the public key type for Zswap coin ownership — the shielded asset system on Midnight. When a user deposits shielded tokens, you'll store their ZswapCoinPublicKey to represent who has claim to those tokens.

ledger depositor: ZswapCoinPublicKey;

circuit recordDepositor(pk: ZswapCoinPublicKey): [] {
  ledger.depositor = pk;
}
Enter fullscreen mode Exit fullscreen mode

This type is opaque — you can store it, compare it, and pass it to the shielded transfer functions, but you can't decompose it into its underlying curve points without dropping to the lower-level elliptic curve API.

UserAddress

UserAddress is a higher-level address type that wraps both a ZswapCoinPublicKey and a spending key derivation path. It's what users expose publicly when they want to receive funds.

circuit getRecipientKey(userAddr: UserAddress): [ZswapCoinPublicKey] {
  return [userAddr.spendingKey()];
}
Enter fullscreen mode Exit fullscreen mode

Think of UserAddress as the "public address you share" and ZswapCoinPublicKey as the "internal representation used for coin operations." They're related but not interchangeable.

ShieldedCoinInfo and QualifiedShieldedCoinInfo

ShieldedCoinInfo describes a shielded coin: its value, token type, and randomness. QualifiedShieldedCoinInfo adds the nullifier key needed to spend it.

ledger heldCoin: QualifiedShieldedCoinInfo;

circuit inspectHeldCoin(): [Uint<64>] {
  return [ledger.heldCoin.info.value];
}
Enter fullscreen mode Exit fullscreen mode

You rarely construct these manually — they come from receiveShielded and get consumed by sendShielded. But knowing the structure helps when you need to inspect or route coins based on their value or type.


Helper Circuits

These are the high-level operations for working with tokens and shielded transfers. They're the most practically useful exports for contracts that handle money.

nativeToken()

Returns the token type identifier for MNT, Midnight's native token. This is a Bytes<32> constant that identifies MNT in mixed-asset contexts.

circuit acceptNativeOnly(coinType: Bytes<32>): [] {
  assert coinType == nativeToken() : "only MNT accepted";
}
Enter fullscreen mode Exit fullscreen mode

Straightforward, but important — don't hardcode the native token bytes. Use this function so your contract works correctly across testnet/mainnet where the token identifier might differ.

tokenType(contractAddr: ContractAddress)

Derives the token type identifier for a custom token contract. Every token on Midnight is identified by the contract that minted it.

circuit getMyTokenType(): [Bytes<32>] {
  return [tokenType(ContractAddress.self())];
}

circuit isMyToken(coinType: Bytes<32>): [Boolean] {
  return [coinType == tokenType(ContractAddress.self())];
}
Enter fullscreen mode Exit fullscreen mode

If you're building a DEX or multi-token vault, you'll use this to route coins to the correct handling logic based on their type.

receiveShielded

receiveShielded processes an incoming shielded coin transfer. It validates the coin proof, extracts the coin information, and returns a QualifiedShieldedCoinInfo that your contract can store or act on.

circuit deposit(witness coinProof: CoinProof): [QualifiedShieldedCoinInfo] {
  const coin = receiveShielded(coinProof);
  assert coin.info.tokenType == nativeToken() : "MNT only";
  ledger.balance = ledger.balance + coin.info.value;
  ledger.heldCoin = coin;
  return [coin];
}
Enter fullscreen mode Exit fullscreen mode

The coinProof is a private witness — the depositor provides it off-chain, and the circuit verifies it without revealing the coin's details to the public ledger. The returned QualifiedShieldedCoinInfo contains everything you need to later spend or transfer the coin.

sendShielded

sendShielded is the counterpart — it constructs and sends a shielded transfer to a recipient.

circuit withdraw(
  recipient: ZswapCoinPublicKey,
  amount: Uint<64>
): [] {
  assert amount <= ledger.balance : "insufficient balance";
  ledger.balance = ledger.balance - amount;
  sendShielded(recipient, ledger.heldCoin, amount);
}
Enter fullscreen mode Exit fullscreen mode

The actual coin details stay hidden from chain observers. What's visible: the transfer happened, the contract's ledger state changed. What's hidden: the amount, the recipient's identity, and the coin's lineage.


Putting It Together: A Token Vault

Here's a minimal but complete example using several stdlib exports together:

import * from "stdlib";

contract TokenVault {
  ledger deposits: MerkleTree<16, Bytes<32>>;
  ledger depositCount: Uint<64>;
  ledger totalBalance: Uint<64>;
  ledger heldCoins: MerkleTree<16, QualifiedShieldedCoinInfo>;

  circuit deposit(witness coinProof: CoinProof): [Uint<64>] {
    const coin = receiveShielded(coinProof);
    assert coin.info.tokenType == nativeToken() : "MNT only";

    const index = ledger.depositCount;
    ledger.heldCoins.set(index, coin);
    ledger.totalBalance = ledger.totalBalance + coin.info.value;
    ledger.depositCount = index + 1;

    // Commitment to this deposit for the Merkle proof
    const commitment = persistentCommit(
      coin.info.value.toBytes().concat(index.toBytes())
    );
    ledger.deposits.set(index, commitment);

    return [index];
  }

  circuit withdraw(
    coinIndex: Uint<64>,
    recipient: ZswapCoinPublicKey
  ): [] {
    const coin = ledger.heldCoins.get(coinIndex);
    assert coin.isSome : "coin not found";
    ledger.totalBalance = ledger.totalBalance - coin.value.info.value;
    sendShielded(recipient, coin.value, coin.value.info.value);
    ledger.heldCoins.set(coinIndex, Maybe.none());
  }
}
Enter fullscreen mode Exit fullscreen mode

Quick Reference

Export Category Use For
Maybe<T> Generic Optional values, nullable ledger fields
Either<L,R> Generic Two-outcome results, structured errors
MerkleTree<N,T> Merkle Fixed-size on-chain indexed storage
persistentCommit Merkle Creating verifiable commitments
verifyCommitment Merkle Verifying commitments in proofs
CurvePoint Crypto Key derivation, Pedersen commitments
Scalar Crypto Private keys, blinding factors
ContractAddress Kernel Contract identity, access control
ZswapCoinPublicKey Kernel Shielded coin recipient keys
UserAddress Kernel User-facing addresses
ShieldedCoinInfo Kernel Coin value/type metadata
QualifiedShieldedCoinInfo Kernel Spendable coin with nullifier key
nativeToken() Tokens MNT token type identifier
tokenType(addr) Tokens Custom token type identifier
receiveShielded Transfers Accept incoming shielded coins
sendShielded Transfers Send shielded coins to recipient

Final Notes

The standard library is deliberately minimal. Midnight's philosophy is to give you the cryptographic building blocks and let you compose them — rather than shipping high-level abstractions that bake in assumptions about your use case.

The types you'll use in every contract: Maybe<T>, ContractAddress, receiveShielded, sendShielded. Everything else is situational. Learn the Merkle utilities when you need provable state membership. Learn the elliptic curve types when you're building custom commitment schemes or signature verification. Learn Either<L,R> when you need structured circuit outputs.

The biggest gotcha for newcomers: all of these operations happen inside ZK circuits. "Optional" doesn't mean "try/catch" — it means "the proof is either valid or it isn't." Design your circuits with that constraint in mind.

Top comments (0)