In this guide, we will 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: receiving shielded tokens, tracking user vault balances, accumulating multiple deposits, 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 token vault contract where a user can:
- deposit a shielded coin into the vault
- automatically create a vault on the first deposit
- keep later deposits accumulated in the same 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
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
Make sure your Compact compiler version matches the language version used in this project. This contract was built against Compact compiler 0.31.0 and language version 0.23.0, so the pragma is pinned:
pragma language_version 0.23.0;
Pinning the pragma avoids drift when newer compiler versions introduce language changes.
You will also want the Compact standard library and Ledger data type documentation open because we be will using the following:
Step 1: Create the 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 pinned language pragma:
pragma language_version 0.23.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
The vault has two exported circuits:
export circuit deposit(
_coin: ShieldedCoinInfo
): []{
}
export circuit withdraw(_amount: Uint<128>): []{
}
These are the user-facing actions:
-
deposit: deposit shielded tokens into the contract, creating the user's vault on the first deposit -
withdraw: withdraw shielded tokens from the contract
Run a quick compile 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>;
coinColor: Bytes<32>;
ownerHash: Bytes<32>;
}
The vault stores:
-
balance: the amount deposited into the vault -
coinColor: the token color this vault accepts -
ownerHash: a commitment to the public key that owns the vault
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 keeps one vault position tied to one asset type.
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.
Privacy Trade-Off
This tutorial keeps the contract intentionally small, but the design has an important privacy trade-off.
The shielded coin itself is private, and token movement uses receiveShielded and sendShielded. However, each Vault.balance is stored as a plain Uint<128> in a public Map, and each vault also stores the accepted coinColor. That means the contract publicly exposes each vault's running accounting balance and asset color, even though the underlying coin is shielded.
This is acceptable for a learning example focused on shielded token handling and UTXO management. A production privacy-first vault should usually keep per-user balances in private state and store only commitments on-chain, for example with a Merkle-tree based design that proves balance updates without exposing each user's 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
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 the Witness Function
We do not want to store the user's raw secret in public ledger state.
Instead, we derive a user ID from a secret key stored in the user's private state.
Declare the witness:
witness getSecretKey(): Bytes<32>;
getSecretKey() lets the contract receive the user's secret key from private state during execution.
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
This is the commitment-based per-user identity used by the vault. It is the part of the design that lets the contract track balances by a commitment derived from private witness data instead of by storing a raw user address.
The current code uses "vault: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: Add the Owner Commitment Helper
The owner commitment stores a hash of the public key used when the vault is first created:
circuit generateOwnerCommitment(pk: Bytes<32>): Bytes<32>{
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "vault:owner:commitment"),
pk
]);
}
This is not meant to reveal the public key as a raw field in the vault. The vault stores only the commitment hash.
Step 10: Implement deposit
The user deposits a shielded coin:
export circuit deposit(
_coin: ShieldedCoinInfo
): []{
const coin = disclose(_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.
Next, derive the user ID:
const userId = generateUserId(disclose(getSecretKey()));
const hasVault = vaults.member(userId);
On the first deposit, create the vault:
if(!hasVault){
const ownerCommitmentHash = generateOwnerCommitment(ownPublicKey().bytes);
const newVault = Vault {
balance: coin.value,
coinColor: disclose(coin.color),
ownerHash: ownerCommitmentHash
};
vaults.insert(userId, newVault);
}
On later deposits, require the same coin color and add the new amount to the existing balance:
else{
const vault = vaults.lookup(userId);
assert (coin.color == vault.coinColor, "Invalid coin type deposited");
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>.
Run the compile script 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()));
Reconstruct the owner commitment from the public key creating the transaction:
const reconstructedOwnerHash = generateOwnerCommitment(ownPublicKey().bytes);
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");
Then verify that the current public key matches the owner commitment stored when the vault was created:
assert (reconstructedOwnerHash == vault.ownerHash, "Unauthorized: You are not the owner");
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 creating the transaction, so we use:
left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey())
When to use ownPublicKey()
ownPublicKey() is useful when a circuit needs the current transaction creator's public key as the target of an operation, for example sending a shielded coin to the caller.
Do not treat ownPublicKey() as a substitute for a verified private identity by itself. If a circuit authorizes access to private-account state, bind that access to private witness data or to a commitment established earlier in the contract. In this vault, the public key is committed into ownerHash when the vault is first created, and withdrawal checks that same commitment before sending funds.
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);
}
Then reduce the user's vault balance:
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()
Full Contract
Here is the complete src/vault.compact:
/**
* Minimal Single Shielded Token Type Vault Dapp
*
* Key Concept demonstrated:
* Structured onchain pattern for contract state management involving Maps, Struct and hashes
* Shielded liquidity managment techniques using using receiveShielded and sendShielded
* Risk managment for private state compromise scenario using generateOwnerCommitment and ownerHash field for ownership verification
*
* NB: In this contract individual vault balance is publicly visible onchain which is a trade-off to keep the contract minimal as the focus
* of this example is to demonstrate shielded token management. Usually individual shielded token balance should be kept in private state while commitments are stored onchain
* utilzing ADT types like Merkle trees (completely shielded and verifiable by zk proof) or Maps
*/
pragma language_version 0.23.0;
import CompactStandardLibrary;
export struct Vault {
balance: Uint<128>;
coinColor: Bytes<32>;
ownerHash: Bytes<32>;
}
export ledger vaults: Map<Bytes<32>, Vault>;
export ledger balances: Map<Bytes<32>, QualifiedShieldedCoinInfo>;
witness getSecretKey(): Bytes<32>;
export circuit deposit(
_coin: ShieldedCoinInfo
): []{
const coin = disclose(_coin);
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 userId = generateUserId(disclose(getSecretKey()));
const hasVault = vaults.member(userId);
if(!hasVault){
const ownerCommitmentHash = generateOwnerCommitment(ownPublicKey().bytes);
const newVault = Vault {
balance: coin.value,
coinColor: disclose(coin.color),
ownerHash: ownerCommitmentHash
};
vaults.insert(userId, newVault);
}else{
const vault = vaults.lookup(userId);
assert (coin.color == vault.coinColor, "Invalid coin type deposited");
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()));
const reconstructedOwnerHash = generateOwnerCommitment(ownPublicKey().bytes);
assert (vaults.member(userId), "You have no vault position");
const vault = vaults.lookup(userId);
assert (vault.balance >= amount, "Insufficient vault balance");
assert (reconstructedOwnerHash == vault.ownerHash, "Unauthorized: You are not the owner");
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
]);
}
circuit generateOwnerCommitment(pk: Bytes<32>): Bytes<32>{
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "vault:owner:commitment"),
pk
]);
}
Step 12: Implement the TypeScript Witness
The Compact file declares the witness. TypeScript provides the runtime implementation.
Create src/witness.ts:
import {WitnessContext} from "@midnight-ntwrk/compact-runtime";
import type { Ledger } from "./managed/contract";
export interface VaultPrivateState {
secretKey: Uint8Array
}
export function createVaultPrivateState(secretKey: Uint8Array): VaultPrivateState{
return {
secretKey
}
}
export const witnesses = {
getSecretKey: (
{privateState}: WitnessContext<Ledger, VaultPrivateState>
): [VaultPrivateState, Uint8Array] => {
return [privateState, privateState.secretKey]
},
}
The witness returns a tuple:
[privateState, witnessValue]
In this project, the private state does not change, so the witness returns the same private state it received.
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:
deposit(amount: bigint, color = TEST_COIN_COLOR): Ledger
withdraw(amount: bigint): Ledger
The tests cover:
- deploying with empty ledgers
- creating a vault on the first valid deposit
- accumulating two deposits into one vault balance
- rejecting invalid coin colors
- partial withdrawal
- full withdrawal and balance removal
- rejecting withdrawal when the witness secret does not match an existing vault
- rejecting withdrawal when a stolen witness secret is used from a different public key
The accumulation test is important. The core behavior is not only accepting deposits, but merging multiple deposits into one running vault balance:
simulator.deposit(100n);
simulator.deposit(200n);
const { item: vault } = getOnlyVault(simulator);
expect(vault.balance).toBe(300n);
Run:
bun run test:run
Expected result:
Test Files 1 passed (1)
Tests 9 passed (9)
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 commitment-based 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 deposit and withdraw actions
- supporting only one approved asset instead of multiple coin colors
- moving per-user balances into private state and storing only commitments on-chain
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.
GitHub link: https://github.com/codeBigInt/midnight-simplified-tutorial-dapps/tree/main/vault-dapp
Top comments (0)