This article is the written companion to my Midnight Simplified video:
https://www.youtube.com/watch?v=MCumZemoX5c
In the video, we build a vault dApp smart contract from scratch using Compact on Midnight. The goal is to help developers who already know the basics of Compact start writing a complete smart contract that handles real vault behavior: creating positions, receiving shielded tokens, tracking balances, and withdrawing funds.
We will focus on the contract side of the dApp. There is no frontend in this guide. The point is to understand the smart contract mechanics clearly before adding UI complexity.
What We Are Building
We are building a shielded vault contract where a user can:
- create a vault position
- choose the coin color that vault accepts
- deposit a shielded coin into the vault
- reject deposits with the wrong coin color
- withdraw from the vault
- keep the contract's shielded UTXO balance updated manually
The final project has this shape:
vault-dapp/
|-- package.json
|-- src/
| |-- vault.compact
| |-- witness.ts
| |-- managed/
| `-- test/
| |-- vault.test.ts
| |-- vault-setup.ts
| `-- utils.ts
`-- tsconfig.json
The core files are:
-
src/vault.compact: the Compact smart contract -
src/witness.ts: the TypeScript witness implementation -
src/test/vault-setup.ts: the simulator setup -
src/test/vault.test.ts: the behavior tests
Github link: https://github.com/codeBigInt/midnight-simplified-tutorial-dapps/tree/main/vault-dapp
Requirements
To follow along, you should already have:
- your Midnight developer environment set up
- the Compact toolchain installed
- basic Compact knowledge
- Bun installed
- a code editor
In the video description, I also mention linking to the updated setup resources because the Midnight toolchain has had upgrades. Make sure your Compact compiler version matches the language version used in this project.
This contract uses:
pragma language_version >= 0.22.0;
That goes with the newer Compact compiler version used in the tutorial.
You will also want the Compact standard library documentation open because we use shielded token helpers such as:
receiveShieldedsendShieldedmergeCoinImmediateinsertCoinownPublicKeykernel.self
Step 1: Create the Project
In the video, we start from a blank project.
Create the folder:
mkdir vault-dapp
cd vault-dapp
Initialize the project with Bun:
bun init
Choose the blank template. Bun will create the initial package.json.
Then create the source folder and the Compact file:
mkdir src
touch src/vault.compact
Step 2: Add Compile Scripts
Before writing the full contract, add scripts so you do not have to keep typing the Compact compiler command manually.
In package.json, add:
{
"scripts": {
"test-compile": "compact compile --skip-zk src/vault.compact ./src/managed",
"compile": "compact compile src/vault.compact ./src/managed"
}
}
The test-compile script compiles the Compact code without generating the full zero-knowledge materials. That makes it useful while you are actively developing.
The compile script performs the full compile.
Both scripts write generated artifacts into:
src/managed
If that folder does not exist, the compiler creates it.
Step 3: Start the Compact Contract
Open src/vault.compact.
The first line is the language pragma:
pragma language_version >= 0.22.0;
Then import the standard library:
import CompactStandardLibrary;
The standard library gives us access to the built-in circuits and types we need for token transfer, ledger helpers, hashing, and shielded coin management.
Step 4: Add the Main Circuit Skeleton
Before defining all the state, it is useful to outline the main behavior of the vault.
The vault needs three exported circuits:
export circuit createVault(
_color: Bytes<32>
): []{
}
export circuit deposit(
_coin: ShieldedCoinInfo
): []{
}
export circuit withdraw(_amount: Uint<128>): []{
}
These are the three user-facing actions:
-
createVault: create a vault position -
deposit: deposit shielded tokens into the contract -
withdraw: withdraw shielded tokens from the contract
At this point in the video, we compile early to confirm the setup works:
bun run test-compile
If the setup is correct, Compact generates the managed TypeScript artifacts in src/managed.
Step 5: Define the Vault Struct
Now we define the shape of a vault position:
export struct Vault {
balance: Uint<128>;
createdAt: Uint<64>;
coinColor: Bytes<32>;
}
The vault stores:
-
balance: the amount deposited into the vault -
createdAt: when the vault was created -
coinColor: the token color this vault accepts
On Midnight, token types are identified by a byte string called a color. Different assets have different colors, so we use coinColor to make sure a user deposits the same asset type they selected when creating the vault.
The final code uses Uint<128> for balance because ShieldedCoinInfo.value is also Uint<128>. If you use Uint<64> for balance, the compiler will complain when you add a coin value to the vault balance.
Step 6: Add Ledger State
We need one ledger map for vault positions:
export ledger vaults: Map<Bytes<32>, Vault>;
This maps a generated user ID to a vault.
We also need another map to manage shielded coin balances:
export ledger balances: Map<Bytes<32>, QualifiedShieldedCoinInfo>;
This map stores contract-held shielded coins by coin color.
This part is important: when you deal with shielded tokens, the contract does not automatically maintain an easy balance table for you. You receive the shielded coin, but you still need to manage the resulting UTXO state yourself. That is why the balances map exists.
ShieldedCoinInfo vs QualifiedShieldedCoinInfo
The video spends time on this because it matters for token transfers.
ShieldedCoinInfo represents a newly created shielded coin used when receiving or spending a coin in a transaction. It has fields such as:
- nonce
- color
- value
QualifiedShieldedCoinInfo represents a shielded coin that already exists on the ledger and is ready to be spent. It includes the extra information needed to locate it, such as the Merkle tree index.
That is why:
- deposits accept
ShieldedCoinInfo - contract balances are stored as
QualifiedShieldedCoinInfo -
insertCoinconverts the received shielded coin into the qualified form stored in the ledger
Step 7: Add Witness Functions
We do not want to store the user's public wallet address as the vault owner. That would defeat the purpose of building a shielded contract.
Instead, we derive a user ID from a secret key stored in the user's private state.
Declare the witnesses:
witness getSecretKey(): Bytes<32>;
witness getCurrentTime(): Uint<64>;
getSecretKey() lets the contract receive the user's secret key from private state.
getCurrentTime() lets the contract set the vault creation timestamp.
Step 8: Generate a User ID
Now add a helper circuit:
circuit generateUserId(sk: Bytes<32>): Bytes<32>{
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "vaul:user"),
sk
]);
}
persistentHash gives us a deterministic ID from:
- a fixed domain separator
- the user's secret key
The current tutorial code uses "vaul:user" as the domain separator. For a real deployed contract, choose this string carefully before deployment because changing it later changes all derived user IDs.
Step 9: Implement createVault
Inside createVault, derive the user ID:
const newUserId = generateUserId(disclose(getSecretKey()));
Because the secret key comes from a witness, we disclose it before using it in the circuit.
Next, make sure this user does not already have a vault:
assert (!vaults.member(newUserId), "Already exist");
Then create the empty vault:
const emptyVault = Vault {
balance: 0,
coinColor: disclose(_color),
createdAt: disclose(getCurrentTime())
};
Finally, insert it:
vaults.insert(newUserId, emptyVault);
The complete circuit:
export circuit createVault(
_color: Bytes<32>
): []{
const newUserId = generateUserId(disclose(getSecretKey()));
assert (!vaults.member(newUserId), "Already exist");
const emptyVault = Vault {
balance: 0,
coinColor: disclose(_color),
createdAt: disclose(getCurrentTime())
};
vaults.insert(newUserId, emptyVault);
}
Run a quick compile:
bun run test-compile
During the video, one common issue appears here: forgetting semicolons. Compact expects semicolons at the end of statements, and the compiler will point you to the line that needs fixing.
Step 10: Implement deposit
The user deposits a shielded coin:
export circuit deposit(
_coin: ShieldedCoinInfo
): []{
const coin = disclose(_coin);
const userId = generateUserId(disclose(getSecretKey()));
Before receiving the token, check that the user has a vault:
assert (vaults.member(userId), "You have no vault position");
const vault = vaults.lookup(userId);
Then make sure the deposited coin is the right type:
assert (coin.color == vault.coinColor, "Invalid coin type deposited");
Now receive the shielded coin:
receiveShielded(coin);
receiveShielded validates that the coin is being received by the contract in this transaction.
But receiving is not enough. The contract still needs to store or update the shielded balance manually.
If there is already a balance for this coin color, merge the previous ledger coin with the new coin:
if(balances.member(coin.color)){
const prevBalance = balances.lookup(coin.color);
const newBalance = mergeCoinImmediate(prevBalance, coin);
balances.insertCoin(
coin.color,
newBalance,
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
}else{
balances.insertCoin(
coin.color,
coin,
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
}
Why mergeCoinImmediate?
mergeCoin is for merging two coins that already exist on the ledger. In this case, we are merging an existing QualifiedShieldedCoinInfo with a new incoming ShieldedCoinInfo, so we use mergeCoinImmediate.
Why right<ZswapCoinPublicKey, ContractAddress>(kernel.self())?
The recipient type can be either a user public key or a contract address. Since the contract is storing this balance for itself, we pass the contract address using the right constructor.
Finally, update the vault accounting balance:
const updatedVault = Vault {
...vault,
balance: (vault.balance + coin.value) as Uint<128>
};
vaults.insert(userId, updatedVault);
The cast to Uint<128> matters because coin.value is Uint<128>.
The complete deposit circuit:
export circuit deposit(
_coin: ShieldedCoinInfo
): []{
const coin = disclose(_coin);
const userId = generateUserId(disclose(getSecretKey()));
assert (vaults.member(userId), "You have no vault position");
const vault = vaults.lookup(userId);
assert (coin.color == vault.coinColor, "Invalid coin type deposited");
receiveShielded(coin);
if(balances.member(coin.color)){
const prevBalance = balances.lookup(coin.color);
const newBalance = mergeCoinImmediate(prevBalance, coin);
balances.insertCoin(
coin.color,
newBalance,
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
}else{
balances.insertCoin(
coin.color,
coin,
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
}
const updatedVault = Vault {
...vault,
balance: (vault.balance + coin.value) as Uint<128>
};
vaults.insert(userId, updatedVault);
}
Compile again:
bun run test-compile
Step 11: Implement withdraw
The withdrawal circuit accepts an amount:
export circuit withdraw(_amount: Uint<128>): []{
const amount = disclose(_amount);
const userId = generateUserId(disclose(getSecretKey()));
Check that the user has a vault:
assert (vaults.member(userId), "You have no vault position");
Load the vault and check the user has enough balance:
const vault = vaults.lookup(userId);
assert (vault.balance >= amount, "Insufficient vault balance");
Load the contract-held shielded coin for the vault's coin color:
const balanceToSendFrom = balances.lookup(vault.coinColor);
Now call sendShielded:
const sendResult = sendShielded(
balanceToSendFrom,
left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey()),
amount
);
sendShielded sends a value from a shielded coin owned by the contract to a recipient.
Here the recipient is the user calling the circuit, so we use:
left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey())
ownPublicKey() returns the ZswapCoinPublicKey of the end user creating the transaction.
After sending, Compact returns a send result. The important part for this contract is the change. If there is change left, we must store it. If there is no change, we remove the balance entry:
if(sendResult.change.is_some){
balances.insertCoin(
vault.coinColor,
sendResult.change.value,
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
}else{
balances.remove(vault.coinColor);
}
The auto-generated subtitle says is_sum in this part, but the contract code uses is_some. Follow the generated Compact type in your local compiler output if this API changes.
Then reduce the user's vault balance:
const updatedVault = Vault {
...vault,
balance: (vault.balance - amount) as Uint<128>
};
vaults.insert(userId, updatedVault);
The complete withdraw circuit:
export circuit withdraw(_amount: Uint<128>): []{
const amount = disclose(_amount);
const userId = generateUserId(disclose(getSecretKey()));
assert (vaults.member(userId), "You have no vault position");
const vault = vaults.lookup(userId);
assert (vault.balance >= amount, "Insufficient vault balance");
const balanceToSendFrom = balances.lookup(vault.coinColor);
const sendResult = sendShielded(
balanceToSendFrom,
left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey()),
amount
);
if(sendResult.change.is_some){
balances.insertCoin(
vault.coinColor,
sendResult.change.value,
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
}else{
balances.remove(vault.coinColor);
}
const updatedVault = Vault {
...vault,
balance: (vault.balance - amount) as Uint<128>
};
vaults.insert(userId, updatedVault);
}
Run the compile script again:
bun run test-compile
If you see an error around ownPublicKey, check the capitalization. It should be:
ownPublicKey()
Step 12: Implement the TypeScript Witnesses
The Compact file declares witnesses. TypeScript provides their runtime implementation.
Create src/witness.ts:
import {WitnessContext} from "@midnight-ntwrk/compact-runtime";
import type { Ledger } from "./managed/contract";
export interface VaultPrivateState {
secreteKey: Uint8Array
}
export function createVaultPrivateState(secreteKey: Uint8Array): VaultPrivateState{
return {
secreteKey
}
}
export const witnesses = {
getSecretKey: (
{privateState}: WitnessContext<Ledger, VaultPrivateState>
): [VaultPrivateState, Uint8Array] => {
return [privateState, privateState.secreteKey]
},
getCurrentTime: (
{privateState}: WitnessContext<Ledger, VaultPrivateState>
): [VaultPrivateState, bigint] => {
return [privateState, BigInt(Date.now())]
},
}
The witness returns a tuple:
[privateState, witnessValue]
In this project, the private state does not change, so each witness returns the same private state it received.
One cleanup you can make later is renaming secreteKey to secretKey. The current project uses secreteKey, so the article keeps that spelling where it reflects the code.
Step 13: Test the Contract
The final project includes Vitest tests around the generated contract bindings.
The simulator in src/test/vault-setup.ts deploys the generated Contract, initializes private state, and exposes helper methods:
createVault(color = TEST_COIN_COLOR): Ledger
deposit(amount: bigint, color = TEST_COIN_COLOR): Ledger
withdraw(amount: bigint): Ledger
The tests cover:
- deploying with empty ledgers
- creating a vault
- rejecting deposits before vault creation
- accepting valid deposits
- rejecting invalid coin colors
- partial withdrawal
- full withdrawal and balance removal
Run:
bun run test:run
Expected result:
Test Files 1 passed (1)
Tests 7 passed (7)
Full Contract
Here is the complete src/vault.compact:
pragma language_version >= 0.22.0;
import CompactStandardLibrary;
export struct Vault {
balance: Uint<128>;
createdAt: Uint<64>;
coinColor: Bytes<32>;
}
export ledger vaults: Map<Bytes<32>, Vault>;
export ledger balances: Map<Bytes<32>, QualifiedShieldedCoinInfo>;
witness getSecretKey(): Bytes<32>;
witness getCurrentTime(): Uint<64>;
export circuit createVault(
_color: Bytes<32>
): []{
const newUserId = generateUserId(disclose(getSecretKey()));
assert (!vaults.member(newUserId), "Already exist");
const emptyVault = Vault {
balance: 0,
coinColor: disclose(_color),
createdAt: disclose(getCurrentTime())
};
vaults.insert(newUserId, emptyVault);
}
export circuit deposit(
_coin: ShieldedCoinInfo
): []{
const coin = disclose(_coin);
const userId = generateUserId(disclose(getSecretKey()));
assert (vaults.member(userId), "You have no vault position");
const vault = vaults.lookup(userId);
assert (coin.color == vault.coinColor, "Invalid coin type deposited");
receiveShielded(coin);
if(balances.member(coin.color)){
const prevBalance = balances.lookup(coin.color);
const newBalance = mergeCoinImmediate(prevBalance, coin);
balances.insertCoin(
coin.color,
newBalance,
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
}else{
balances.insertCoin(
coin.color,
coin,
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
}
const updatedVault = Vault {
...vault,
balance: (vault.balance + coin.value) as Uint<128>
};
vaults.insert(userId, updatedVault);
}
export circuit withdraw(_amount: Uint<128>): []{
const amount = disclose(_amount);
const userId = generateUserId(disclose(getSecretKey()));
assert (vaults.member(userId), "You have no vault position");
const vault = vaults.lookup(userId);
assert (vault.balance >= amount, "Insufficient vault balance");
const balanceToSendFrom = balances.lookup(vault.coinColor);
const sendResult = sendShielded(
balanceToSendFrom,
left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey()),
amount
);
if(sendResult.change.is_some){
balances.insertCoin(
vault.coinColor,
sendResult.change.value,
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
}else{
balances.remove(vault.coinColor);
}
const updatedVault = Vault {
...vault,
balance: (vault.balance - amount) as Uint<128>
};
vaults.insert(userId, updatedVault);
}
circuit generateUserId(sk: Bytes<32>): Bytes<32>{
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "vaul:user"),
sk
]);
}
What You Learned
In this tutorial, you learned how to:
- start a Compact project with Bun
- add fast and full compile scripts
- write exported Compact circuits
- define ledger maps
- model a vault position with a struct
- use witness functions for private state
- generate a private user ID
- receive shielded tokens with
receiveShielded - store shielded coin balances with
insertCoin - merge new deposits with
mergeCoinImmediate - send withdrawals with
sendShielded - manage returned change manually
- test the contract with generated TypeScript bindings
Next Improvements
This vault is intentionally minimal. If you want to extend it, try:
- adding zero-amount checks
- supporting multiple vaults per user
- extracting repeated balance update logic into helper circuits
- adding events or richer metadata
- building a frontend for create, deposit, and withdraw actions
- supporting only one approved asset instead of multiple coin colors
- improving naming before production use
Closing
This vault dApp is a compact example of how Midnight smart contracts can combine private state, shielded assets, and public ledger updates.
If you are learning Compact, this is a good project to study because it touches the pieces you need for many real dApps: identity derivation, token transfer, state management, and testing.
Watch the full tutorial video here:
https://www.youtube.com/watch?v=MCumZemoX5c
Github link: https://github.com/codeBigInt/midnight-simplified-tutorial-dapps/tree/main/vault-dapp
Top comments (0)