DEV Community

Cover image for Build a Private Vault DApp Smart Contract on Midnight with Compact
Elliot lucky
Elliot lucky

Posted on

Build a Private Vault DApp Smart Contract on Midnight with Compact

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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:

  • receiveShielded
  • sendShielded
  • mergeCoinImmediate
  • insertCoin
  • ownPublicKey
  • kernel.self

Step 1: Create the Project

In the video, we start from a blank project.

Create the folder:

mkdir vault-dapp
cd vault-dapp
Enter fullscreen mode Exit fullscreen mode

Initialize the project with Bun:

bun init
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Then import the standard library:

import CompactStandardLibrary;
Enter fullscreen mode Exit fullscreen mode

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>): []{
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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
  • insertCoin converts 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>;
Enter fullscreen mode Exit fullscreen mode

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
    ]);
}
Enter fullscreen mode Exit fullscreen mode

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()));
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

Then create the empty vault:

const emptyVault = Vault {
    balance: 0,
    coinColor: disclose(_color),
    createdAt: disclose(getCurrentTime())
};
Enter fullscreen mode Exit fullscreen mode

Finally, insert it:

vaults.insert(newUserId, emptyVault);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Run a quick compile:

bun run test-compile
Enter fullscreen mode Exit fullscreen mode

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()));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Then make sure the deposited coin is the right type:

assert (coin.color == vault.coinColor, "Invalid coin type deposited");
Enter fullscreen mode Exit fullscreen mode

Now receive the shielded coin:

receiveShielded(coin);
Enter fullscreen mode Exit fullscreen mode

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())
    );
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Compile again:

bun run test-compile
Enter fullscreen mode Exit fullscreen mode

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()));
Enter fullscreen mode Exit fullscreen mode

Check that the user has a vault:

assert (vaults.member(userId), "You have no vault position");
Enter fullscreen mode Exit fullscreen mode

Load the vault and check the user has enough balance:

const vault = vaults.lookup(userId);
assert (vault.balance >= amount, "Insufficient vault balance");
Enter fullscreen mode Exit fullscreen mode

Load the contract-held shielded coin for the vault's coin color:

const balanceToSendFrom = balances.lookup(vault.coinColor);
Enter fullscreen mode Exit fullscreen mode

Now call sendShielded:

const sendResult = sendShielded(
    balanceToSendFrom,
    left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey()),
    amount
);
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Run the compile script again:

bun run test-compile
Enter fullscreen mode Exit fullscreen mode

If you see an error around ownPublicKey, check the capitalization. It should be:

ownPublicKey()
Enter fullscreen mode Exit fullscreen mode

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())]
    },
}
Enter fullscreen mode Exit fullscreen mode

The witness returns a tuple:

[privateState, witnessValue]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Expected result:

Test Files  1 passed (1)
Tests       7 passed (7)
Enter fullscreen mode Exit fullscreen mode

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
    ]);
}
Enter fullscreen mode Exit fullscreen mode

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)