DEV Community

Tosin Akinbowa
Tosin Akinbowa

Posted on

Accepting token deposits into a contract: receiveShielded and escrow patterns

Most smart contracts treat token transfers as public events. Anyone can see who sent what to whom. Midnight flips this default. Tokens move privately, balances stay hidden, and contracts can hold funds on behalf of users without exposing the amounts involved.

This tutorial walks you through building a shielded escrow contract on Midnight. The escrow accepts a private deposit from one party, holds it securely, and releases it either to a beneficiary or back to the original depositor. You will see exactly how receiveShielded, writeCoin, mergeCoinImmediate, and sendShielded work together, and why the distinction between contract-held and user-held coins matters at every step.

What you'll build

A two-party escrow contract that:

  • Accepts a shielded deposit via receiveShielded and stores it using writeCoin
  • Accumulates additional funds using mergeCoinImmediate
  • Releases funds to a beneficiary via sendShielded
  • Returns funds to the original depositor via sendShielded
  • Tracks escrow state with a settled flag to prevent double-spending

Prerequisites

  • Midnight toolchain installed (installation guide)
  • Basic familiarity with Compact. If you haven't gone through the hello world tutorial, start there
  • Node.js installed for running tests

Contract-held coins vs user-held coins

Before writing any code, you need to understand the most important distinction in Midnight's shielded token model.

Every shielded coin has a recipient encoded into its commitment. That recipient is either a user's public key or a contract address. This isn't a metadata field. It's cryptographically embedded in the coin commitment hash itself:

commitment = hash(domain_sep, coin_info, recipient_type, recipient_bytes)
Enter fullscreen mode Exit fullscreen mode

When recipient_type is left (a ZswapCoinPublicKey), the coin belongs to a user and lives in their wallet. When it's right (a ContractAddress), the coin belongs to the contract and only the contract can authorize spending it.

In Compact, you express this using the Either type:

// Sends coin to a user's wallet - user-held
left<ZswapCoinPublicKey, ContractAddress>(disclose(publicKey))

// Keeps coin with the contract - contract-held
right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
Enter fullscreen mode Exit fullscreen mode

In an escrow contract, the deposited coin transitions from user-held (in the depositor's wallet) to contract-held (locked in escrow) on deposit. When the escrow settles, it transitions back to user-held, going either to the beneficiary or back to the depositor.

The escrow contract

Create a file called escrow.compact. Here is the full contract:

pragma language_version 0.22;

import CompactStandardLibrary;

// The shielded coin currently held in escrow
export ledger escrowVault: QualifiedShieldedCoinInfo;

// Whether the escrow currently holds tokens
export ledger hasDeposit: Boolean;

// The depositor's public key - set at deploy time
export ledger depositor: Bytes<32>;

// The beneficiary's public key - set at deploy time
export ledger beneficiary: Bytes<32>;

// Whether the escrow has been settled (released or reclaimed)
export ledger settled: Boolean;

// Total number of deposits made
export ledger totalDeposits: Counter;

constructor(depositorPk: Bytes<32>, beneficiaryPk: Bytes<32>) {
    depositor = disclose(depositorPk);
    beneficiary = disclose(beneficiaryPk);
}

// First deposit: initializes the escrow with a coin
export circuit deposit(coin: ShieldedCoinInfo): [] {
    assert(!hasDeposit, "escrow already holds a deposit");
    assert(!settled, "escrow has already been settled");
    receiveShielded(disclose(coin));
    escrowVault.writeCoin(
        disclose(coin),
        right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
    );
    hasDeposit = true;
    totalDeposits.increment(1);
}

// Add more funds: merges new coin into the existing escrow balance
export circuit addFunds(coin: ShieldedCoinInfo): [] {
    assert(hasDeposit, "no deposit yet, call deposit first");
    assert(!settled, "escrow has already been settled");
    const pot = escrowVault;
    receiveShielded(disclose(coin));
    const merged = mergeCoinImmediate(pot, disclose(coin));
    escrowVault.writeCoin(
        merged,
        right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
    );
    totalDeposits.increment(1);
}

// Release: sends the escrowed funds to the beneficiary
export circuit release(beneficiaryKey: ZswapCoinPublicKey): ShieldedSendResult {
    assert(hasDeposit, "escrow is empty");
    assert(!settled, "escrow has already been settled");
    const pot = escrowVault;
    const result = sendShielded(
        pot,
        left<ZswapCoinPublicKey, ContractAddress>(disclose(beneficiaryKey)),
        disclose(pot.value)
    );
    hasDeposit = false;
    settled = true;
    return result;
}

// Reclaim: returns the escrowed funds to the depositor
export circuit reclaim(depositorKey: ZswapCoinPublicKey): ShieldedSendResult {
    assert(hasDeposit, "escrow is empty");
    assert(!settled, "escrow has already been settled");
    const pot = escrowVault;
    const result = sendShielded(
        pot,
        left<ZswapCoinPublicKey, ContractAddress>(disclose(depositorKey)),
        disclose(pot.value)
    );
    hasDeposit = false;
    settled = true;
    return result;
}
Enter fullscreen mode Exit fullscreen mode

Let's go through each section.

Ledger state

The ledger fields store everything the escrow needs to track on-chain. Every field is public but carefully chosen to reveal nothing sensitive about the amounts involved.

escrowVault holds the current locked coin as a QualifiedShieldedCoinInfo. This type represents a coin that already exists on the ledger with a known Merkle tree position. It contains a nonce, a color (the token type), a value, and an mt_index.

hasDeposit tracks whether the escrow currently holds funds. The deposit circuit sets it to true. The release and reclaim circuits clear it when the escrow settles.

depositor and beneficiary are set at deploy time via the constructor. They store the public keys of the two parties. A production escrow would use these to enforce who can call release and reclaim.

settled is a one-way flag. Once an escrow settles, no further deposits or withdrawals are allowed. This prevents the contract from being reused after the escrow agreement has concluded.

totalDeposits is a Counter that records how many deposit operations occurred. You increment it with .increment(1).

Accepting deposits with receiveShielded and writeCoin

The deposit circuit handles the first deposit into an empty escrow:

export circuit deposit(coin: ShieldedCoinInfo): [] {
    assert(!hasDeposit, "escrow already holds a deposit");
    assert(!settled, "escrow has already been settled");
    receiveShielded(disclose(coin));
    escrowVault.writeCoin(
        disclose(coin),
        right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
    );
    hasDeposit = true;
    totalDeposits.increment(1);
}
Enter fullscreen mode Exit fullscreen mode

receiveShielded is a standard library circuit that registers a shielded coin as received by the calling contract:

circuit receiveShielded(coin: ShieldedCoinInfo): [];
Enter fullscreen mode Exit fullscreen mode

It takes a ShieldedCoinInfo, a struct with three fields:

struct ShieldedCoinInfo {
  nonce: Bytes<32>;
  color: Bytes<32>;
  value: Uint<128>;
}
Enter fullscreen mode Exit fullscreen mode

You must call disclose(coin) when passing the coin to receiveShielded. This makes the coin information visible in the transaction's public transcript so the network can validate that the shielded output exists.

After calling receiveShielded, you store the coin in the ledger using writeCoin:

escrowVault.writeCoin(
    disclose(coin),
    right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
);
Enter fullscreen mode Exit fullscreen mode

writeCoin is the correct way to persist a received coin to ledger state. It accepts a ShieldedCoinInfo and a recipient, then automatically looks up the correct Merkle tree index at runtime. You cannot set mt_index manually to a meaningful value at circuit time because the blockchain assigns that index when the transaction is confirmed.

The right wrapper makes the stored coin contract-held. Only this contract can authorize spending it.

Accumulating funds with mergeCoinImmediate

The addFunds circuit allows additional deposits after the first, merging them into the existing balance:

export circuit addFunds(coin: ShieldedCoinInfo): [] {
    assert(hasDeposit, "no deposit yet, call deposit first");
    assert(!settled, "escrow has already been settled");
    const pot = escrowVault;
    receiveShielded(disclose(coin));
    const merged = mergeCoinImmediate(pot, disclose(coin));
    escrowVault.writeCoin(
        merged,
        right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
    );
    totalDeposits.increment(1);
}
Enter fullscreen mode Exit fullscreen mode

mergeCoinImmediate combines two coins into one:

circuit mergeCoinImmediate(a: QualifiedShieldedCoinInfo, b: ShieldedCoinInfo): CoinInfo;
Enter fullscreen mode Exit fullscreen mode

The parameters serve different roles:

  • a is a QualifiedShieldedCoinInfo, the existing coin already on the ledger (the current escrow balance)
  • b is a ShieldedCoinInfo, the newly received coin from the current transaction

This is different from mergeCoin, which requires both coins to already be on the ledger. A coin that was just deposited in the current transaction isn't yet on the ledger with a Merkle index. mergeCoinImmediate handles exactly this case.

The function returns a CoinInfo. You pass this result to writeCoin, which handles the Merkle tree index:

const merged = mergeCoinImmediate(pot, disclose(coin));
escrowVault.writeCoin(merged, right<ZswapCoinPublicKey, ContractAddress>(kernel.self()));
Enter fullscreen mode Exit fullscreen mode

The order matters: read the existing escrow coin first, then receive the new deposit, then merge.

Storing coins in QualifiedShieldedCoinInfo ledger fields

The escrowVault ledger field has type QualifiedShieldedCoinInfo. This type represents a coin that is fully qualified for spending, meaning it has a known position in the Merkle tree.

You never construct this type manually inside a circuit. Instead you use writeCoin on the ledger cell. writeCoin takes a ShieldedCoinInfo (or CoinInfo from a merge), looks up the Merkle index allocated during the current transaction, and stores the result as QualifiedShieldedCoinInfo:

escrowVault.writeCoin(coin, recipient);
Enter fullscreen mode Exit fullscreen mode

This is important. Setting mt_index: 0 manually would only be valid if you were spending the coin in the same transaction it was created. For coins that need to persist across transactions, writeCoin is the only correct approach.

Releasing and reclaiming with sendShielded

sendShielded moves a contract-held coin to a user's wallet:

circuit sendShielded(
    input: QualifiedShieldedCoinInfo,
    recipient: Either<ZswapCoinPublicKey, ContractAddress>,
    value: Uint<128>
): ShieldedSendResult;
Enter fullscreen mode Exit fullscreen mode

It returns a ShieldedSendResult:

struct ShieldedSendResult {
  change: Maybe<ShieldedCoinInfo>;
  sent: ShieldedCoinInfo;
}
Enter fullscreen mode Exit fullscreen mode

sent is the coin that goes to the recipient. change holds any remainder if you sent less than the full balance.

Both release and reclaim send the full escrow balance, so there is no change. They use pot.value to read the full amount from the stored coin:

const pot = escrowVault;
const result = sendShielded(
    pot,
    left<ZswapCoinPublicKey, ContractAddress>(disclose(beneficiaryKey)),
    disclose(pot.value)
);
hasDeposit = false;
settled = true;
return result;
Enter fullscreen mode Exit fullscreen mode

The left wrapper sends the coin to the recipient's public key as a user-held coin. The network creates an encrypted output so their wallet can discover it.

Setting settled = true after the transfer permanently closes the escrow. The assert(!settled, ...) guards in every circuit prevent any further operations.

Compiling the contract

Run the Compact compiler against your escrow contract:

compact compile escrow.compact managed/escrow
Enter fullscreen mode Exit fullscreen mode

This generates:

  • managed/escrow/contract/index.js: the compiled JavaScript contract
  • managed/escrow/contract/index.d.ts: TypeScript type definitions
  • managed/escrow/zkir/: ZK intermediate representation files for each circuit

Testing the escrow

Install dependencies:

npm install --save-dev vitest
npm install @midnight-ntwrk/compact-runtime
Enter fullscreen mode Exit fullscreen mode

Create a test file at src/escrow.test.ts:

import { describe, it, expect } from 'vitest';
import {
  createConstructorContext,
  createCircuitContext,
  sampleContractAddress,
} from '@midnight-ntwrk/compact-runtime';
import { Contract, ledger } from '../managed/escrow/contract/index.js';

const depositorPk = new Uint8Array(32).fill(1);
const beneficiaryPk = new Uint8Array(32).fill(2);
const coinPublicKey = new Uint8Array(32);
const contractAddress = sampleContractAddress();

function createSimulator() {
  const contract = new Contract({});
  const constructorCtx = createConstructorContext({}, coinPublicKey);
  const { currentPrivateState, currentContractState, currentZswapLocalState } =
    contract.initialState(constructorCtx, depositorPk, beneficiaryPk);

  const circuitContext = createCircuitContext(
    contractAddress,
    currentZswapLocalState,
    currentContractState,
    currentPrivateState,
  );

  return { contract, circuitContext };
}

const mockCoin = {
  nonce: new Uint8Array(32).fill(3),
  color: new Uint8Array(32).fill(4),
  value: 100n,
};

describe('escrow contract', () => {
  it('accepts a deposit into an empty escrow', () => {
    let { contract, circuitContext } = createSimulator();

    ({ context: circuitContext } = contract.impureCircuits.deposit(circuitContext, mockCoin));
    const state = ledger(circuitContext.currentQueryContext.state);

    expect(state.hasDeposit).toBe(true);
    expect(state.totalDeposits).toBe(1n);
    expect(state.settled).toBe(false);
  });

  it('increments deposit counter when adding more funds', () => {
    let { contract, circuitContext } = createSimulator();

    ({ context: circuitContext } = contract.impureCircuits.deposit(circuitContext, mockCoin));
    ({ context: circuitContext } = contract.impureCircuits.addFunds(circuitContext, mockCoin));
    const state = ledger(circuitContext.currentQueryContext.state);

    expect(state.totalDeposits).toBe(2n);
    expect(state.hasDeposit).toBe(true);
  });

  it('releases funds to beneficiary and marks escrow as settled', () => {
    let { contract, circuitContext } = createSimulator();

    ({ context: circuitContext } = contract.impureCircuits.deposit(circuitContext, mockCoin));

    const beneficiaryKey = { bytes: new Uint8Array(32).fill(2) };
    ({ context: circuitContext } = contract.impureCircuits.release(
      circuitContext,
      beneficiaryKey,
    ));
    const state = ledger(circuitContext.currentQueryContext.state);

    expect(state.hasDeposit).toBe(false);
    expect(state.settled).toBe(true);
  });

  it('reclaims funds to depositor and marks escrow as settled', () => {
    let { contract, circuitContext } = createSimulator();

    ({ context: circuitContext } = contract.impureCircuits.deposit(circuitContext, mockCoin));

    const depositorKey = { bytes: new Uint8Array(32).fill(1) };
    ({ context: circuitContext } = contract.impureCircuits.reclaim(
      circuitContext,
      depositorKey,
    ));
    const state = ledger(circuitContext.currentQueryContext.state);

    expect(state.hasDeposit).toBe(false);
    expect(state.settled).toBe(true);
  });
});
Enter fullscreen mode Exit fullscreen mode

Run the tests:

npx vitest run src/escrow.test.ts
Enter fullscreen mode Exit fullscreen mode

Each test initializes the contract, runs one or more circuits, then reads the resulting ledger state. No network, no proof server, no wallet required.

What you've built

Your escrow contract demonstrates the full lifecycle of shielded token deposits on Midnight:

  • receiveShielded registers a shielded coin as received by the contract, making the coin information visible in the public transaction transcript
  • writeCoin persists the received coin into a QualifiedShieldedCoinInfo ledger field, handling the Merkle tree index lookup automatically
  • mergeCoinImmediate accumulates additional deposits by combining an existing ledger coin with a newly received transient coin
  • sendShielded with left<ZswapCoinPublicKey, ContractAddress> transfers the escrowed funds to either the beneficiary or the depositor as a user-held coin
  • The settled flag permanently closes the escrow after a single settlement, preventing reuse

The transition from user-held to contract-held on deposit, and back to user-held on settlement, is enforced cryptographically. The recipient type is embedded in the coin commitment hash itself. Your escrow exploits this to hold tokens privately on behalf of two parties while maintaining clear rules about when and how the funds move.


Top comments (0)