Midnight gives you two ways to represent value in a contract. Most tutorials pick one and move on. This one covers both: what the real compiler-verified API looks like, where each model breaks, and why the UTXO path has more gotchas than it appears.
Short version: UTXO tokens for privacy and real transferability. Ledger-state accounting for queryable bookkeeping. Both, when your contract needs both.
All three contracts here compile against the latest Compact compiler. Verified CI run: IamHarrie-Labs/compact-token-guide
Two separate systems
Midnight runs a public ledger and a shielded Zswap layer. Not two views of the same data. Genuinely separate systems with different guarantees.
Counter and Map fields live on the public ledger. Every insert, every increment is readable by anyone watching the chain. You can query balances, enumerate holders, build conditional logic on accumulated state. You cannot hide amounts.
Zswap is a zero-knowledge Merkle tree. Tokens committed into it have their amounts, owners, and transfer history hidden. You can't ask "how much does Alice hold?" from inside a contract. The contract has no view into individual UTXO holdings. What it can do: mint coins, verify a coin is valid, send coins to recipients.
Pick the wrong model and you'll have balances everyone can read when they shouldn't, or a contract that can't answer "how much does this user have?" That's often the entire point of the contract.
Ledger-state accounting
Ledger-state accounting uses Counter and Map fields to track values inside the contract. Think of it as an on-chain database where your contract is the only writer.
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger totalPoints: Counter;
export ledger userPoints: Map<Bytes<32>, Uint<64>>;
witness getAdminSecret(): Bytes<32>;
circuit adminKey(): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "accounting:admin:v1"),
getAdminSecret()
]);
}
export ledger admin: Bytes<32>;
export circuit initialize(adminPubkey: Bytes<32>): [] {
admin = disclose(adminPubkey);
}
export circuit awardPoints(
userKey: Bytes<32>,
amount: Uint<64>,
newBalance: Uint<64>
): [] {
assert(disclose(adminKey()) == admin, "Only admin can award points");
if (!userPoints.member(disclose(userKey))) {
assert(disclose(newBalance) == disclose(amount), "First award: balance must equal amount");
} else {
const current = userPoints.lookup(disclose(userKey));
assert(disclose(newBalance) >= current, "Balance must not decrease");
assert(disclose(newBalance) - current == disclose(amount), "Invalid balance update");
}
userPoints.insert(disclose(userKey), disclose(newBalance));
totalPoints.increment(1);
}
export circuit redeemPoints(
userKey: Bytes<32>,
amount: Uint<64>
): [] {
assert(userPoints.member(disclose(userKey)), "User has no points");
const current = userPoints.lookup(disclose(userKey));
assert(current >= disclose(amount), "Insufficient points");
userPoints.insert(disclose(userKey), current - disclose(amount));
}
export circuit pointsOf(userKey: Bytes<32>): Uint<64> {
if (!userPoints.member(disclose(userKey))) {
return 0;
}
return userPoints.lookup(disclose(userKey));
}
What you get
Every balance is public. userPoints["alice"] is readable by anyone watching the ledger. That's fine for loyalty points, reputation scores, game credits — anything where the value isn't sensitive. You can enumerate holders, check balances from other circuits, build vesting schedules and rate limits.
The TypeScript call for awardPoints:
// Compute new balance off-chain — arithmetic happens here, not in-circuit
const current = await contract.query.pointsOf(userKey);
const newBalance = current + amount;
await contract.callTx.awardPoints(userKey, amount, newBalance);
The Uint<64> arithmetic constraint
The contract takes newBalance as a parameter from TypeScript rather than computing it in-circuit. This is deliberate. It trips up most developers the first time they write a balance-tracking contract.
When you add two Uint<64> values in-circuit, the result type widens. Uint<64> + Uint<64> produces Uint<1..2^65>, which is too wide to store back into a Map<Bytes<32>, Uint<64>> field:
// ❌ Type error: Uint<64> + Uint<64> = Uint<1..2^65>, not Uint<64>
const newBal = current + amount;
userPoints.insert(disclose(userKey), newBal);
expected right-hand side to have type Uint<64>
but received Uint<1..18446744073709551616>
The fix: compute the result in TypeScript, pass it as a circuit parameter, verify the relationship in-circuit. TypeScript does the arithmetic. The circuit proves it was done correctly.
Subtraction stays within range. Uint<64> - Uint<64> is fine as long as the operand won't go negative, which the assert(current >= amount) guarantees.
When to use it
Use it for internal state that doesn't need to leave the contract, anything you need to query or compare from other circuits, or early development when you don't have a full Midnight node. Avoid it for anything with sensitive amounts.
UTXO-layer tokens
UTXO tokens live in the Zswap Merkle tree, not in ledger fields. The contract is a policy layer: it governs who can create, receive, and send coins. Actual token custody is the shielded transaction engine's job.
The actual standard library API
Most community examples get this wrong. The mint(amount, recipient) you'll see in various tutorials isn't in the standard library. The real functions:
mintShieldedToken(
domainSep: Bytes<32>,
value: Uint<64>,
nonce: Bytes<32>,
recipient: Either<ZswapCoinPublicKey, ContractAddress>
): ShieldedCoinInfo
sendShielded(
input: QualifiedShieldedCoinInfo,
recipient: Either<ZswapCoinPublicKey, ContractAddress>,
value: Uint<128>
): ShieldedSendResult
receiveShielded(coin: ShieldedCoinInfo): []
ownPublicKey(): ZswapCoinPublicKey
The types involved:
-
ShieldedCoinInfo— a coin commitment:{ nonce: Bytes<32>, color: Bytes<32>, value: Uint<128> } -
QualifiedShieldedCoinInfo— a coin plus its Merkle tree position:{ nonce: Bytes<32>, color: Bytes<32>, value: Uint<128>, mtIndex: Uint<64> } -
ZswapCoinPublicKey— a shielded wallet address:{ bytes: Bytes<32> } -
Either<L, R>— useleft<ZswapCoinPublicKey, ContractAddress>(v)for a wallet key
These complex types come from the wallet SDK through witnesses. You can't pass QualifiedShieldedCoinInfo as a direct circuit parameter. The wallet has to provide it.
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger admin: Bytes<32>;
export ledger totalMinted: Counter;
witness getAdminSecret(): Bytes<32>;
witness getMintNonce(): Bytes<32>;
witness getRecipient(): ZswapCoinPublicKey;
witness getQualifiedCoin(): QualifiedShieldedCoinInfo;
witness getCoinToReceive(): ShieldedCoinInfo;
circuit adminKey(): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "utxo:admin:v1"),
getAdminSecret()
]);
}
export circuit initialize(adminPubkey: Bytes<32>): [] {
admin = disclose(adminPubkey);
}
// Mint shielded tokens directly to a recipient's wallet.
// Domain separator scopes these tokens to this contract version.
export circuit mintToWallet(value: Uint<64>): [] {
assert(disclose(adminKey()) == admin, "Only admin can mint");
const recipient = getRecipient();
const nonce = getMintNonce();
mintShieldedToken(
pad(32, "token:v1"),
disclose(value),
disclose(nonce),
left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient))
);
totalMinted.increment(1);
}
// Transfer shielded tokens. Protocol handles double-spend prevention.
export circuit transferShielded(value: Uint<128>): [] {
const coin = getQualifiedCoin();
const recipient = getRecipient();
sendShielded(
disclose(coin),
left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient)),
disclose(value)
);
}
// Receive shielded tokens into this contract.
export circuit receiveIntoContract(): [] {
const coin = getCoinToReceive();
receiveShielded(disclose(coin));
}
// Get this contract's shielded public key (use as recipient address).
export circuit contractPublicKey(): ZswapCoinPublicKey {
return ownPublicKey();
}
How the Zswap layer works
mintShieldedToken creates a coin commitment in the Zswap Merkle tree. The coin's owner, amount, and nonce are hidden inside it. Anyone watching the chain can see a mint happened. Nothing else.
sendShielded takes a coin the caller already owns. The QualifiedShieldedCoinInfo includes the Merkle tree index proving the coin exists and hasn't been spent. Old coin nullified, new commitment created for the recipient.
receiveShielded is for when the contract itself is the recipient. It pulls the coin into the contract's UTXO holdings so the contract can send it onward later.
Double-spend prevention is protocol-level. The Zswap nullifier set handles it. No application logic required.
When token operations get blocked
UTXO operations need the full Midnight node and a Zswap-capable wallet. mintShieldedToken fails in simulator-only environments. sendShielded requires the wallet to provide a valid Merkle inclusion proof. If the wallet doesn't support it, the witness returns nothing and proof generation fails.
Ledger-state accounting works in all environments. UTXO needs the full stack. That's the main practical reason to fall back to accounting even for something that conceptually feels like a token.
When to use it
Use UTXO tokens when value needs to move between wallets privately, when the amounts themselves are sensitive, or when you'd rather not write your own double-spend logic. The tradeoff: you need the full Midnight stack, and the witness-based coin provisioning has its own learning curve.
Combining both: staking rewards
Public participation tracking plus private reward distributions. Staking contracts are a natural fit for this split.
pragma language_version >= 0.20;
import CompactStandardLibrary;
// Public: anyone can see total staked and per-user stakes
export ledger admin: Bytes<32>;
export ledger totalStaked: Counter;
export ledger userStakes: Map<Bytes<32>, Uint<64>>;
// Public aggregate only — individual reward amounts are hidden in Zswap
export ledger totalRewarded: Counter;
witness getAdminSecret(): Bytes<32>;
witness getMintNonce(): Bytes<32>;
witness getRecipient(): ZswapCoinPublicKey;
circuit adminKey(): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "hybrid:admin:v1"),
getAdminSecret()
]);
}
export circuit initialize(adminPubkey: Bytes<32>): [] {
admin = disclose(adminPubkey);
}
// ACCOUNTING: record a stake
export circuit recordStake(
userKey: Bytes<32>,
amount: Uint<64>,
newBalance: Uint<64>
): [] {
if (!userStakes.member(disclose(userKey))) {
assert(disclose(newBalance) == disclose(amount), "First stake: balance must equal amount");
} else {
const current = userStakes.lookup(disclose(userKey));
assert(disclose(newBalance) > current, "Balance must increase");
assert(disclose(newBalance) - current == disclose(amount), "Invalid stake amount");
}
userStakes.insert(disclose(userKey), disclose(newBalance));
totalStaked.increment(1);
}
// ACCOUNTING: remove a stake
export circuit removeStake(
userKey: Bytes<32>,
amount: Uint<64>
): [] {
assert(userStakes.member(disclose(userKey)), "No stake recorded");
const current = userStakes.lookup(disclose(userKey));
assert(current >= disclose(amount), "Cannot remove more than staked");
userStakes.insert(disclose(userKey), current - disclose(amount));
}
// UTXO: distribute real shielded token rewards (private amounts)
export circuit distributeReward(rewardAmount: Uint<64>): [] {
assert(disclose(adminKey()) == admin, "Only admin can distribute rewards");
const recipient = getRecipient();
const nonce = getMintNonce();
mintShieldedToken(
pad(32, "hybrid:reward:v1"),
disclose(rewardAmount),
disclose(nonce),
left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient))
);
totalRewarded.increment(1);
}
export circuit stakeOf(userKey: Bytes<32>): Uint<64> {
if (!userStakes.member(disclose(userKey))) {
return 0;
}
return userStakes.lookup(disclose(userKey));
}
The accounting circuits give auditors a clear view of who staked what. The UTXO circuit keeps reward amounts hidden. totalRewarded increments on-chain, but who got what stays inside Zswap.
Decision table
| Scenario | Model |
|---|---|
| Loyalty points, credits, scores | Accounting |
| Participation tracking, voting weight | Accounting |
| Internal rate limits or quotas | Accounting |
| Payment tokens, transferable assets | UTXO |
| Amount and participant privacy required | UTXO |
| Unit-testing without full Midnight node | Accounting |
| Internal tracking + real payouts | Hybrid |
If you need to query balances inside the contract, UTXO won't work. The contract can't read Zswap holdings. If you need private transfers between wallets, ledger-state won't work. Those balances are fully public.
Pitfalls
1. Map.get() doesn't exist — use Map.lookup()
// ❌ Compile error — Map has no .get() method
const balance = userPoints.get(disclose(userKey));
// ✅ Correct
if (userPoints.member(disclose(userKey))) {
const balance = userPoints.lookup(disclose(userKey));
}
2. Map.lookup() panics on a missing key
// ❌ Panics at proof generation if userKey was never inserted
const balance = userPoints.lookup(disclose(userKey));
// ✅ Guard with .member() first
assert(userPoints.member(disclose(userKey)), "User has no balance");
const balance = userPoints.lookup(disclose(userKey));
3. Set<T> doesn't exist — use Map<K, Boolean>
// ❌ Compile error — Compact has no Set type
export ledger spentCoins: Set<Bytes<32>>;
// ✅ Map<K, Boolean> is the set pattern
export ledger spentCoins: Map<Bytes<32>, Boolean>;
spentCoins.insert(disclose(coinId), disclose(true));
4. Uint<64> addition widens the type
// ❌ Uint<64> + Uint<64> = Uint<1..2^65> — can't store back to Uint<64>
const newBalance = current + amount;
userPoints.insert(disclose(key), newBalance);
expected right-hand side to have type Uint<64>
but received Uint<1..18446744073709551616>
// ✅ Compute off-chain, verify in-circuit
export circuit awardPoints(key: Bytes<32>, amount: Uint<64>, newBalance: Uint<64>): [] {
const current = userPoints.lookup(disclose(key));
assert(disclose(newBalance) - current == disclose(amount), "Invalid balance");
userPoints.insert(disclose(key), disclose(newBalance));
}
5. Missing disclose() on exported parameters in comparisons
// ❌ Compiler error
assert(amount > 0, "Amount must be positive");
// ✅ Exported parameters need disclose() before comparisons
assert(disclose(amount) > 0, "Amount must be positive");
Compact compiler error: potential witness-value disclosure must be declared but is not — performing this ledger operation might disclose the boolean value of the result of a comparison involving the witness value
6. Wrong UTXO API signatures
// ❌ Not a standard library function
mint(amount, recipient);
// ❌ Wrong signature — sendShielded doesn't take (amount, recipient)
sendShielded(amount, recipient);
The correct forms:
// ✅ mintShieldedToken: domain separator + nonce + explicit type params on left()
mintShieldedToken(
pad(32, "token:v1"),
disclose(value),
disclose(nonce),
left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient))
);
// ✅ sendShielded: takes QualifiedShieldedCoinInfo from witness, not just an amount
sendShielded(
disclose(coin), // QualifiedShieldedCoinInfo
left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient)),
disclose(value) // Uint<128>
);
mintShieldedToken needs a domain separator to scope the token type to this contract, a per-mint nonce to prevent replay, and left() with explicit type parameters. Type inference doesn't work here.
7. Counter.increment() takes Uint<16>, not Uint<64>
// ❌ Type error
totalPoints.increment(disclose(amount)); // amount: Uint<64>
expected first argument of increment to have type Uint<16>
but received Uint<64>
// ✅ Pass a literal
totalPoints.increment(1);
Counter.increment() accepts step values up to 65535. For large aggregate sums, use a Map field. Use Counter to count operations only.
8. Missing disclose() on witness values passed to UTXO functions
// ❌ Compiler error: witness values passed to UTXO functions need disclose()
mintShieldedToken(pad(32, "token:v1"), disclose(value), nonce, left<...>(recipient));
sendShielded(coin, left<...>(recipient), disclose(value));
receiveShielded(coin);
potential witness-value disclosure must be declared but is not:
the call to standard-library circuit mintShieldedToken might disclose a link between
a coin spend and the coin with the commitment given by a hash of the witness value
// ✅ Disclose witness values before passing to any UTXO standard library function
mintShieldedToken(
pad(32, "token:v1"),
disclose(value),
disclose(nonce),
left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient))
);
sendShielded(
disclose(coin),
left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient)),
disclose(value)
);
receiveShielded(disclose(coin));
This applies to all three: mintShieldedToken, sendShielded, and receiveShielded. Each creates or consumes a coin commitment that hashes your witness value on-chain. Compact requires explicit disclose() to acknowledge that link. The private data stays inside the ZK proof. What you're acknowledging is that a hash of your witness will appear on-chain.
9. Treating accounting balances as private
// This balance is publicly readable — NOT private
export ledger userPoints: Map<Bytes<32>, Uint<64>>;
Ledger fields are visible to anyone reading the chain. For sensitive amounts (stake sizes, bid values, holdings), use the UTXO model or commitment patterns with persistentCommit.
Compiler-verified source
All three contracts — accounting.compact, utxo-token.compact, and hybrid.compact — compile against the latest Compact compiler:
https://github.com/IamHarrie-Labs/compact-token-guide/actions/runs/25700476519
Top comments (0)