Most blockchain applications treat token balances as public information. You can look up any address and see exactly what it holds. Midnight takes a different approach. It lets you build contracts where balances stay private, transactions reveal only what they must, and users retain control of their financial data.
This tutorial walks you through building a shielded token vault on Midnight. By the end, you'll have a working Compact contract that accepts private deposits, accumulates them using coin merging, and handles withdrawals, along with tests to verify each circuit.
What you'll build
A vault contract that:
- Accepts shielded token deposits via
receiveShielded - Accumulates multiple deposits into a single vault coin using
mergeCoinImmediate - Tracks vault state using ledger commitments
- Handles withdrawals via
sendShielded, splitting the vault coin into a user coin and a change coin - Distinguishes between contract-held and user-held coins at every step
Prerequisites
- Midnight toolchain installed (installation guide)
- Basic familiarity with Compact. If you haven't gone through the hello world tutorial, start there
- Node.js and a package manager for running tests
Contract-held coins vs user-held coins
Before writing any code, you need to understand the most fundamental distinction in Midnight's shielded token model.
Every shielded coin has a recipient encoded into its commitment. That recipient is either a user's public key or a contract address. This isn't a metadata field. It's cryptographically embedded in the coin commitment hash itself:
commitment = hash(domain_sep, coin_info, recipient_type, recipient_bytes)
When recipient_type is left (a ZswapCoinPublicKey), the coin belongs to a user and lives in their wallet. When it's right (a ContractAddress), the coin belongs to the contract and the contract controls when it moves.
In Compact, you express this distinction using the Either type:
// Sends coin to a user's wallet - user-held
left<ZswapCoinPublicKey, ContractAddress>(disclose(publicKey))
// Keeps coin with the contract - contract-held
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
A vault contract holds tokens on behalf of users. When someone deposits, their coin becomes contract-held. When they withdraw, the contract sends a user-held coin back to their wallet. This transition from user-held to contract-held on deposit, and back to user-held on withdrawal, is the core lifecycle of your vault.
The vault contract
Create a file called vault.compact. Here is the full contract:
pragma language_version 0.22;
import CompactStandardLibrary;
// The shielded coin currently held by the vault
export ledger shieldedVault: QualifiedShieldedCoinInfo;
// Whether the vault currently holds tokens
export ledger hasShieldedTokens: Boolean;
// The vault owner's public key - set at deploy time
export ledger owner: Bytes<32>;
// Total number of deposits made
export ledger totalShieldedDeposits: Counter;
// Total number of withdrawals made
export ledger totalShieldedWithdrawals: Counter;
constructor(ownerPk: Bytes<32>) {
owner = disclose(ownerPk);
}
// First deposit: call this when the vault is empty
export circuit initVault(coin: ShieldedCoinInfo): [] {
assert(!hasShieldedTokens, "vault already initialized");
receiveShielded(disclose(coin));
shieldedVault.writeCoin(
disclose(coin),
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
hasShieldedTokens = true;
totalShieldedDeposits.increment(1);
}
// Subsequent deposits: merges new coin into the existing vault coin
export circuit depositShielded(coin: ShieldedCoinInfo): [] {
assert(hasShieldedTokens, "vault not initialized, call initVault first");
const pot = shieldedVault;
receiveShielded(disclose(coin));
const merged = mergeCoinImmediate(pot, disclose(coin));
shieldedVault.writeCoin(
merged,
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
totalShieldedDeposits.increment(1);
}
export circuit withdrawShielded(
publicKey: ZswapCoinPublicKey,
value: Uint<128>
): ShieldedSendResult {
assert(hasShieldedTokens, "vault is empty");
const pot = shieldedVault;
const result = sendShielded(
pot,
left<ZswapCoinPublicKey, ContractAddress>(disclose(publicKey)),
disclose(value)
);
if (result.change.is_some) {
shieldedVault.writeCoin(
result.change.value,
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
} else {
hasShieldedTokens = false;
}
totalShieldedWithdrawals.increment(1);
return result;
}
Let's go through each section.
Ledger state
The ledger declarations are what the contract stores on-chain. Every field is public, visible to anyone who queries the contract state, but the values are carefully chosen to reveal nothing sensitive.
shieldedVault holds the current vault coin as a QualifiedShieldedCoinInfo. This is a coin that already exists on the ledger and can be spent. It contains a nonce, a color (the token type), a value, and an mt_index for its position in the Merkle tree.
hasShieldedTokens is a simple flag. initVault sets it to true on the first deposit. withdrawShielded clears it if the vault is fully drained.
owner stores the vault owner's public key as a Bytes<32>. You set this at deploy time via the constructor. A production vault would use this to gate withdrawals so only the owner can call withdrawShielded.
totalShieldedDeposits and totalShieldedWithdrawals are Counter types. They don't reveal amounts, but they give you an on-chain audit trail of activity. You increment them with .increment(1), not arithmetic assignment.
Initializing the vault with initVault
The vault uses two separate deposit circuits because the first deposit and subsequent deposits require different operations.
initVault handles the first deposit. The vault is empty, so there is no existing coin to merge with:
export circuit initVault(coin: ShieldedCoinInfo): [] {
assert(!hasShieldedTokens, "vault already initialized");
receiveShielded(disclose(coin));
shieldedVault.writeCoin(
disclose(coin),
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
hasShieldedTokens = true;
totalShieldedDeposits.increment(1);
}
receiveShielded is a standard library circuit that marks a shielded coin as received by the calling contract. Its signature is:
circuit receiveShielded(coin: ShieldedCoinInfo): [];
It takes a ShieldedCoinInfo, a struct containing a nonce, a color (token type identifier), and a value:
struct ShieldedCoinInfo {
nonce: Bytes<32>;
color: Bytes<32>;
value: Uint<128>;
}
You must call disclose(coin) when passing the coin to receiveShielded. This makes the coin information visible in the transaction's public transcript, which the network needs to validate that the shielded output actually exists.
After receiving the coin, writeCoin stores it in the shieldedVault ledger field:
shieldedVault.writeCoin(disclose(coin), right<ZswapCoinPublicKey, ContractAddress>(kernel.self()));
writeCoin accepts a ShieldedCoinInfo and a recipient, and automatically looks up the correct Merkle tree index at runtime. You cannot set mt_index to a meaningful value at circuit time because the index is assigned by the blockchain when the transaction is confirmed. writeCoin handles this lookup for you.
The right wrapper makes the coin contract-held, meaning only this contract can spend it.
Accumulating deposits with depositShielded and mergeCoinImmediate
depositShielded handles all deposits after the first. This is where the vault's accumulation logic lives:
export circuit depositShielded(coin: ShieldedCoinInfo): [] {
assert(hasShieldedTokens, "vault not initialized, call initVault first");
const pot = shieldedVault;
receiveShielded(disclose(coin));
const merged = mergeCoinImmediate(pot, disclose(coin));
shieldedVault.writeCoin(
merged,
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
totalShieldedDeposits.increment(1);
}
mergeCoinImmediate combines the existing vault coin with the newly deposited coin:
circuit mergeCoinImmediate(a: QualifiedShieldedCoinInfo, b: ShieldedCoinInfo): CoinInfo;
The key distinction between its two parameters:
-
ais aQualifiedShieldedCoinInfo, an existing coin already on the ledger (your vault's current balance) -
bis aShieldedCoinInfo, a newly created coin arriving in the current transaction (the deposit)
This is different from mergeCoin, which takes two coins both already on the ledger. When a user deposits, their coin exists only as a transient created within the same transaction. mergeCoinImmediate is designed exactly for this case.
The function returns a CoinInfo. You store it back to the vault ledger field using writeCoin, which handles the Merkle tree index automatically:
const merged = mergeCoinImmediate(pot, disclose(coin));
shieldedVault.writeCoin(merged, right<ZswapCoinPublicKey, ContractAddress>(kernel.self()));
The order matters: read the existing vault coin first, receive the new deposit, then merge.
Tracking balances with commitments
The vault's on-chain state never stores a raw balance. It stores QualifiedShieldedCoinInfo and the actual value inside that struct is part of the shielded coin commitment, not plaintext.
If you need to track per-user private balances separately from the coin itself, Compact provides persistentCommit:
commitment = persistentCommit<Uint<64>>(balance, randomness)
This produces a Bytes<32> that you can store on the ledger. Anyone can see the commitment, but without knowing both the balance and the randomness, they can't determine the actual value. In a ZK circuit, you can prove that a new commitment is consistent with an old one, that the balance changed by exactly a certain delta, without revealing either value.
For the vault contract in this tutorial, the coin value itself serves as the balance tracker. The shieldedVault field always reflects the current accumulated total inside its shielded commitment. You don't need a separate balance commitment unless you're building multi-user vaults where different depositors track their individual shares.
Withdrawing with sendShielded
sendShielded is the circuit that moves a contract-held coin to a user's wallet:
circuit sendShielded(
input: QualifiedShieldedCoinInfo,
recipient: Either<ZswapCoinPublicKey, ContractAddress>,
value: Uint<128>
): ShieldedSendResult;
It returns a ShieldedSendResult:
struct ShieldedSendResult {
change: Maybe<ShieldedCoinInfo>;
sent: ShieldedCoinInfo;
}
sent is the user-held coin that goes to the recipient's wallet. change is the remaining balance, a new ShieldedCoinInfo if input.value > value. Your withdrawShielded circuit handles both cases:
const result = sendShielded(
pot,
left<ZswapCoinPublicKey, ContractAddress>(disclose(publicKey)),
disclose(value)
);
if (result.change.is_some) {
shieldedVault.writeCoin(
result.change.value,
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
} else {
hasShieldedTokens = false;
}
The change field is a Maybe<ShieldedCoinInfo>. You check result.change.is_some before accessing result.change.value. The change coin is stored back into the vault using writeCoin, keeping it contract-held.
The left wrapper sends the sent coin to the user's public key, making it user-held. The network creates an encrypted output so the user's wallet can discover and track it.
One important note from the documentation: sendShielded does not currently create coin ciphertexts for arbitrary recipients. This means that if you're sending to a public key other than the caller's own wallet, that recipient won't automatically discover the coin. For production vaults, ensure the recipient is the same user calling the circuit.
Compiling the contract
Run the Compact compiler against your vault contract:
compact compile vault.compact managed/vault
This generates:
-
managed/vault/contract/index.js: the compiled JavaScript contract -
managed/vault/contract/index.d.ts: TypeScript type definitions -
managed/vault/zkir/: ZK intermediate representation files for each circuit
Testing the vault
Install the required dependencies:
npm install --save-dev vitest
npm install @midnight-ntwrk/compact-runtime
Create a test file at src/vault.test.ts. Compact's compiled output is a standard ES module, so you can test circuits directly without a running node or proof server:
import { describe, it, expect } from 'vitest';
import {
createConstructorContext,
createCircuitContext,
sampleContractAddress,
} from '@midnight-ntwrk/compact-runtime';
import { Contract, ledger } from '../managed/vault/contract/index.js';
const ownerPk = new Uint8Array(32);
const coinPublicKey = new Uint8Array(32);
const contractAddress = sampleContractAddress();
function createSimulator() {
const contract = new Contract({});
const constructorCtx = createConstructorContext({}, coinPublicKey);
const { currentPrivateState, currentContractState, currentZswapLocalState } =
contract.initialState(constructorCtx, ownerPk);
const circuitContext = createCircuitContext(
contractAddress,
currentZswapLocalState,
currentContractState,
currentPrivateState,
);
return { contract, circuitContext };
}
const mockCoin = {
nonce: new Uint8Array(32).fill(1),
color: new Uint8Array(32).fill(2),
value: 100n,
};
describe('vault contract', () => {
it('initializes vault on first deposit', () => {
let { contract, circuitContext } = createSimulator();
({ context: circuitContext } = contract.impureCircuits.initVault(circuitContext, mockCoin));
const state = ledger(circuitContext.currentQueryContext.state);
expect(state.hasShieldedTokens).toBe(true);
expect(state.totalShieldedDeposits).toBe(1n);
});
it('increments deposit counter on subsequent deposit', () => {
let { contract, circuitContext } = createSimulator();
({ context: circuitContext } = contract.impureCircuits.initVault(circuitContext, mockCoin));
({ context: circuitContext } = contract.impureCircuits.depositShielded(circuitContext, mockCoin));
const state = ledger(circuitContext.currentQueryContext.state);
expect(state.totalShieldedDeposits).toBe(2n);
});
it('clears hasShieldedTokens after a full withdrawal', () => {
let { contract, circuitContext } = createSimulator();
({ context: circuitContext } = contract.impureCircuits.initVault(circuitContext, mockCoin));
const userPublicKey = { bytes: new Uint8Array(32).fill(9) };
({ context: circuitContext } = contract.impureCircuits.withdrawShielded(
circuitContext,
userPublicKey,
100n,
));
const state = ledger(circuitContext.currentQueryContext.state);
expect(state.hasShieldedTokens).toBe(false);
expect(state.totalShieldedWithdrawals).toBe(1n);
});
it('keeps change in vault after a partial withdrawal', () => {
let { contract, circuitContext } = createSimulator();
({ context: circuitContext } = contract.impureCircuits.initVault(circuitContext, {
...mockCoin,
value: 200n,
}));
const userPublicKey = { bytes: new Uint8Array(32).fill(9) };
({ context: circuitContext } = contract.impureCircuits.withdrawShielded(
circuitContext,
userPublicKey,
50n,
));
const state = ledger(circuitContext.currentQueryContext.state);
expect(state.hasShieldedTokens).toBe(true);
expect(state.totalShieldedWithdrawals).toBe(1n);
});
});
Run the tests:
npx vitest run src/vault.test.ts
Each test calls a circuit directly on the compiled contract object, passes a context with the initial ledger and private state, and asserts on the resulting state. No network, no proof server, no wallet required.
What you've built
Your vault contract demonstrates the full shielded token lifecycle on Midnight:
-
initVaultaccepts the first coin deposit and stores it usingwriteCoin, which handles the Merkle tree index lookup automatically -
depositShieldedusesmergeCoinImmediateto accumulate subsequent deposits, merging the existing ledger coin with the new transient deposit coin -
writeCoinis the correct way to persist any shielded coin to ledger state for future spending -
sendShieldedwithleft<ZswapCoinPublicKey, ContractAddress>transfers a user-held coin back to the caller's wallet - The
changefield inShieldedSendResultlets partial withdrawals leave a remainder in the vault, also stored viawriteCoin
The distinction between contract-held and user-held coins isn't just architectural. It's cryptographic. The recipient type is embedded in the coin commitment hash, and only the right key can authorize spending each type. Your vault exploits this to hold tokens privately on behalf of users while maintaining full control over when and how they move.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.