DEV Community

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

Posted on • Edited on

Build a Private Vault DApp Smart Contract on Midnight with Compact

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
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

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

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
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 pinned language pragma:

pragma language_version 0.23.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

The vault has two exported circuits:

export circuit deposit(
    _coin: ShieldedCoinInfo
): []{
}

export circuit withdraw(_amount: Uint<128>): []{
}
Enter fullscreen mode Exit fullscreen mode

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
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>;
    coinColor: Bytes<32>;
    ownerHash: Bytes<32>;
}
Enter fullscreen mode Exit fullscreen mode

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>;
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

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

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

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

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);
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.

Next, derive the user ID:

const userId = generateUserId(disclose(getSecretKey()));
const hasVault = vaults.member(userId);
Enter fullscreen mode Exit fullscreen mode

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

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

The cast to Uint<128> matters because coin.value is Uint<128>.

Run the compile script 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

Reconstruct the owner commitment from the public key creating the transaction:

const reconstructedOwnerHash = generateOwnerCommitment(ownPublicKey().bytes);
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

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");
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 creating the transaction, so we use:

left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey())
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

Run:

bun run test:run
Enter fullscreen mode Exit fullscreen mode

Expected result:

Test Files  1 passed (1)
Tests       9 passed (9)
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 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)