DEV Community

Tosh
Tosh

Posted on

Building a Shielded Token Vault in Compact: Deposit, Accumulate, and Withdraw with Privacy

Building a Shielded Token Vault in Compact: Deposit, Accumulate, and Withdraw with Privacy

Midnight Network's privacy model flips the default assumption of most blockchains: instead of everything being public with optional encryption, everything relevant to you is private by default, with selective disclosure controlled by zero-knowledge proofs. Nowhere is this distinction more practically useful than in a token vault — a contract that holds funds on behalf of a user, accumulates them over time, and allows withdrawal under specific conditions.

This article walks through building a fully shielded token vault in Compact, Midnight's domain-specific language for ZK-circuit-based smart contracts. We'll cover how a shielded vault differs architecturally from a traditional escrow, the structure of the deposit and withdrawal circuits, and a complete working example with salary accumulation as the target use case.


Shielded Vault vs. Regular Escrow

A traditional escrow on an EVM chain holds funds in a contract account. The balance is publicly visible at all times. Anyone watching the chain knows: how much is locked, when deposits happened, and when withdrawals occur. Even with access-controlled functions, the economic state is fully transparent.

A shielded vault on Midnight works differently at every layer:

  • The vault balance is private. It exists as a commitment on-chain — a cryptographic hash of the actual value. No observer can determine how much is inside without knowing the underlying secret.
  • Deposits are unlinkable. Multiple deposits to the same vault can't be correlated by amount or timing without the depositor's private key material.
  • Withdrawals are zero-knowledge. A user proves they have the right to withdraw a specific amount without revealing the total vault balance, the history of deposits, or any other information beyond what the circuit mandates as public.

The tradeoff: complexity. You're not just writing logic — you're writing constraints that the ZK prover will verify. Every operation must be expressible as an arithmetic circuit.


Architecture Overview

A shielded token vault has three core components:

  1. Deposit circuit — Takes a token amount and a commitment key, produces a commitment that gets stored on-chain.
  2. Accumulate state — The ledger maintains a Merkle tree of all deposit commitments, allowing proofs of inclusion.
  3. Withdraw circuit — Proves knowledge of a valid commitment (deposit exists, amount is correct, owner is authorized) and nullifies it to prevent double-spending.

The nullifier pattern is critical. When you withdraw, you don't update a balance — you prove a commitment exists and hasn't been nullified, then publish its nullifier. The contract checks that the nullifier hasn't been seen before, then releases funds. This is how Zcash and similar systems work, and Midnight's Compact makes this pattern first-class.


Compact Contract Structure

Let's define the full contract. We'll use a simplified token interface and focus on the vault mechanics.

// Vault state stored on the public ledger
ledger {
  // Merkle tree of deposit commitments
  deposit_tree: MerkleTree<Commitment, TREE_DEPTH>,

  // Nullifier set — spent commitments can't be reused
  nullifiers: Set<Field>,

  // Total public debt (optional: for auditing total liabilities without revealing per-deposit)
  // Omit if full privacy is required
}

// A commitment to a deposit: hash(amount, secret, owner_pubkey)
type Commitment = Field;
type Nullifier = Field;

const TREE_DEPTH: Uint<8> = 20;
Enter fullscreen mode Exit fullscreen mode

Deposit Circuit

The deposit circuit takes private inputs from the depositor and produces a public commitment that gets inserted into the Merkle tree.

circuit deposit(
  // Private inputs (witness — only the depositor sees these)
  witness amount: Uint<64>,
  witness secret: Field,
  witness owner_pubkey: PublicKey,

  // Public output
  public commitment_out: Commitment
) {
  // Compute the commitment
  let commitment = hash(amount, secret, owner_pubkey);

  // Assert the public output matches our private computation
  assert commitment == commitment_out;

  // Constrain amount is non-zero (no empty deposits)
  assert amount > 0;

  // The actual token transfer happens via the token contract interface
  // The ZK proof guarantees the commitment is correctly formed
}

contract ShieldedVault {
  fn deposit(commitment: Commitment, token_amount: Uint<64>) {
    // Caller provides proof of valid deposit circuit execution
    // Token transfer: pull token_amount from caller to vault
    token.transfer_from(caller(), self(), token_amount);

    // Insert commitment into the Merkle tree
    ledger.deposit_tree.insert(commitment);
  }
}
Enter fullscreen mode Exit fullscreen mode

A few design points:

  • The amount is a witness — it's never posted on-chain directly.
  • The commitment is a hash of the amount, a random secret, and the owner's public key. This binds the deposit to a specific owner without revealing which owner.
  • The token transfer uses the actual token amount publicly, but this amount is unlinkable to future withdrawals (the commitment scheme hides which deposit corresponds to which withdrawal).

For salary use cases, each payroll deposit produces a new commitment. The employee accumulates many commitments over time. To spend, they can aggregate or withdraw individually.

Withdraw Circuit

The withdrawal circuit is more complex — it proves:

  1. A commitment exists in the deposit tree (Merkle inclusion proof)
  2. The commitment corresponds to the claimed amount and owner
  3. The commitment hasn't been spent (nullifier is fresh)
circuit withdraw(
  // Private inputs
  witness amount: Uint<64>,
  witness secret: Field,
  witness owner_privkey: PrivateKey,
  witness merkle_path: MerklePath<TREE_DEPTH>,

  // Public inputs/outputs
  public nullifier: Nullifier,
  public recipient: Address,
  public withdraw_amount: Uint<64>
) {
  // Derive the public key from the private key
  let owner_pubkey = derive_pubkey(owner_privkey);

  // Recompute the commitment
  let commitment = hash(amount, secret, owner_pubkey);

  // Verify the commitment exists in the tree
  // merkle_path contains the sibling hashes for the inclusion proof
  assert merkle_verify(
    root: ledger.deposit_tree.root(),
    leaf: commitment,
    path: merkle_path
  );

  // Compute the nullifier — deterministic from secret and commitment
  // This prevents double-spending: same deposit always produces same nullifier
  let computed_nullifier = hash(secret, commitment);
  assert computed_nullifier == nullifier;

  // Withdrawal amount must not exceed deposited amount
  assert withdraw_amount <= amount;

  // Bind withdrawal to the specified recipient
  // (recipient is public, so the ZK proof covers who receives funds)
}

contract ShieldedVault {
  fn withdraw(
    proof: ZKProof,
    nullifier: Nullifier,
    recipient: Address,
    withdraw_amount: Uint<64>
  ) {
    // Verify ZK proof for the withdraw circuit
    verify_proof(proof, withdraw_circuit, [nullifier, recipient, withdraw_amount]);

    // Check nullifier hasn't been used
    assert !ledger.nullifiers.contains(nullifier);

    // Mark nullifier as spent
    ledger.nullifiers.insert(nullifier);

    // Release funds
    token.transfer(recipient, withdraw_amount);
  }
}
Enter fullscreen mode Exit fullscreen mode

Privacy Preservation: What the Chain Sees vs. What Stays Private

When a user deposits and later withdraws, the public chain state reveals:

  • A new leaf was added to the Merkle tree (no amount, no owner)
  • A nullifier was added to the spent set (no linkage to the original deposit)
  • Tokens moved to recipient in the withdrawal tx

What remains private:

  • The deposited amount
  • Which commitment corresponds to which withdrawal
  • The depositor's identity (unless the recipient address is the same, which leaks linkability — use fresh addresses)
  • The vault's total balance (sum of all commitments)

This is the core privacy guarantee. An observer sees the tree growing and shrinking, but cannot reconstruct any meaningful information about the economic activity.


Salary Accumulation Use Case

Consider an employer paying salary in USDC to a privacy-preserving vault:

  1. Every two weeks, the employer calls deposit(commitment, salary_amount). The commitment is generated client-side by the employee's wallet, which retains the secret and amount.

  2. The employee's wallet tracks all commitments locally (this is "private state" — more on this in article #232).

  3. When the employee wants to pay rent, they select one or more commitments that sum to the rent amount, generate withdrawal proofs for each, and call withdraw() for each.

  4. The landlord sees incoming token transfers from the vault contract — no connection to the employer, no disclosure of salary amount.

Partial withdrawal: The circuit allows withdraw_amount <= amount. If a deposit was for $3,000 salary and rent is $1,200, the employee withdraws $1,200 and creates a new deposit commitment for the remaining $1,800 (a "change" output pattern).

// Change output pattern for partial withdrawal
circuit withdraw_with_change(
  witness amount: Uint<64>,
  witness secret: Field,
  witness owner_privkey: PrivateKey,
  witness merkle_path: MerklePath<TREE_DEPTH>,
  witness change_secret: Field,

  public nullifier: Nullifier,
  public recipient: Address,
  public withdraw_amount: Uint<64>,
  public change_commitment: Commitment
) {
  let owner_pubkey = derive_pubkey(owner_privkey);
  let commitment = hash(amount, secret, owner_pubkey);

  assert merkle_verify(ledger.deposit_tree.root(), commitment, merkle_path);

  let computed_nullifier = hash(secret, commitment);
  assert computed_nullifier == nullifier;

  assert withdraw_amount < amount;

  let change_amount = amount - withdraw_amount;
  let computed_change = hash(change_amount, change_secret, owner_pubkey);
  assert computed_change == change_commitment;
}
Enter fullscreen mode Exit fullscreen mode

The change_commitment is inserted into the tree in the same transaction as the nullifier is spent — atomic, no gap where funds can be lost.


Subscription Payment Buffer

Another practical application: a user commits funds monthly to a subscription service buffer. The service provider runs an automated prover (they hold the secret for their subscription commitments) and withdraws monthly without the subscriber needing to actively approve each payment — the vault acts like a prepaid subscription slot.

This requires a trust model where the subscriber generates commitments with secrets known to the provider. In practice, you'd use a Diffie-Hellman derived shared secret: subscriber and provider each contribute to the secret derivation, preventing either from spending without the other's cooperation.


Implementation Notes

Proof generation time: Merkle inclusion proofs at depth 20 add significant proving time. For production, consider depth 16 (65,536 leaves) which is typically sufficient for most applications with much faster proofs.

Client-side state management: The depositor must retain (commitment, amount, secret, merkle_position) tuples. If this state is lost, the funds are inaccessible. Encrypted backup to a personal server is the standard recovery approach — encrypt with the user's key and store off-chain.

Batch deposits: For salary use cases, consider batch commitment insertion (insert multiple leaves per tx) to amortize transaction costs.

Audit trail: For regulatory compliance, the circuit can optionally include a viewing_key output — a ciphertext containing (amount, sender) decryptable only by a designated auditor key. This provides auditability without breaking privacy from arbitrary observers.


Summary

A shielded token vault on Midnight gives you a fundamentally different trust model than a standard escrow: the economic state is private by default, access is controlled by ZK proofs rather than access control lists, and the on-chain state reveals nothing about amounts or participants. The commitment + nullifier pattern is the standard foundation, and Compact's circuit primitives make it tractable to implement.

The three circuits covered here — deposit, withdraw, and withdraw-with-change — cover the core mechanics. From this foundation, you can extend to multi-party vaults, time-locked releases, and threshold withdrawal conditions, all while maintaining the same privacy guarantees.

Top comments (0)