Accepting Token Deposits in Compact: ReceiveShielded, Escrow, and Coin Merging
Building a contract that holds money is one of the first non-trivial things you'll do on Midnight. Unlike account-based systems where "holding tokens" means incrementing a number, Midnight's shielded coin model requires you to explicitly receive, store, and later spend coin objects. Each coin carries its own proof of validity, and your contract acts as a custodian of those coin objects.
This guide walks through the full lifecycle: accepting deposits with receiveShielded, storing coins in ledger state, releasing them with sendShielded, merging multiple deposits into one, and finally a complete escrow contract that ties it all together.
How Shielded Coins Work
Before the code, a quick mental model. On Midnight, shielded tokens are not balances — they're UTXO-like objects called coins. Each coin has:
- A value (
Uint<64>) — the amount - A token type (
Bytes<32>) — which asset (MNT, or a custom token) - A nullifier key — used to spend the coin (kept private)
- A randomness/commitment — makes the coin cryptographically unique
When you "deposit" tokens into a contract, you're not moving a number from one account to another. You're transferring custodianship of a coin object. The contract receives the coin, stores its QualifiedShieldedCoinInfo (the full coin data including the spending key), and holds it until the contract logic permits withdrawal.
When you "withdraw," the contract calls sendShielded which consumes the stored coin and creates a new coin owned by the recipient. The old coin is nullified (spent); a new coin appears in the recipient's wallet.
Receiving a Deposit with receiveShielded
The entry point for accepting coins is receiveShielded. It takes a CoinProof — a private witness provided by the depositor — validates it, and returns the QualifiedShieldedCoinInfo you can store.
import * from "stdlib";
contract SimpleVault {
ledger heldCoin: Maybe<QualifiedShieldedCoinInfo>;
ledger balance: Uint<64>;
circuit deposit(witness coinProof: CoinProof): [] {
// Validate the coin proof and get the coin object
const coin = receiveShielded(coinProof);
// Only accept the native token (MNT)
assert coin.info.tokenType == nativeToken() : "only MNT accepted";
// Only allow one deposit at a time for simplicity
assert ledger.heldCoin.isNone : "vault already holds a coin";
// Store the coin and update balance
ledger.heldCoin = Maybe.some(coin);
ledger.balance = coin.info.value;
}
}
The CoinProof is generated by the depositor's wallet software off-chain. It contains:
- The coin's secret information (never revealed on-chain)
- A ZK proof that the coin exists, is unspent, and belongs to the depositor
After receiveShielded succeeds, the original coin is effectively transferred. The depositor's wallet marks it as spent; your contract holds the QualifiedShieldedCoinInfo.
What QualifiedShieldedCoinInfo Contains
// Structure (conceptual — actual field access via dot notation)
QualifiedShieldedCoinInfo {
info: ShieldedCoinInfo {
value: Uint<64>, // coin amount
tokenType: Bytes<32>, // asset identifier
randomness: Bytes<32>, // uniqueness commitment
},
nullifierKey: Bytes<32>, // needed to spend this coin
}
Store the entire QualifiedShieldedCoinInfo — you need both the info and the nullifierKey to later spend the coin. If you only store ShieldedCoinInfo, you'll have the metadata but won't be able to release the coin.
Storing Coins in Ledger State
For a single coin, a Maybe<QualifiedShieldedCoinInfo> field works fine. For multiple deposits, use a MerkleTree indexed by deposit number:
contract MultiDepositVault {
ledger coins: MerkleTree<16, Maybe<QualifiedShieldedCoinInfo>>;
ledger depositCount: Uint<64>;
ledger depositors: MerkleTree<16, ZswapCoinPublicKey>;
ledger depositAmounts: MerkleTree<16, Uint<64>>;
circuit deposit(
witness coinProof: CoinProof,
depositorKey: ZswapCoinPublicKey
): [Uint<64>] {
const coin = receiveShielded(coinProof);
assert coin.info.tokenType == nativeToken() : "MNT only";
assert coin.info.value >= 1_000_000 : "minimum deposit 1 MNT";
const index = ledger.depositCount;
// Store coin, depositor, and amount separately for easy querying
ledger.coins.set(index, Maybe.some(coin));
ledger.depositors.set(index, depositorKey);
ledger.depositAmounts.set(index, coin.info.value);
ledger.depositCount = index + 1;
// Return the deposit index so the depositor can reference it
return [index];
}
}
The deposit index is returned as a public output — the depositor records this to reference their deposit in future transactions.
Ledger Field Design Considerations
A few patterns that come up when storing coins:
Separate amounts from coin objects: The coin object itself reveals nothing about its value to on-chain observers — but if you store depositAmounts separately, that becomes public. Decide whether your use case requires amount privacy or amount transparency, and store accordingly.
Use Maybe<QualifiedShieldedCoinInfo> for slots that can be emptied: When a withdrawal happens, you want to mark that slot as empty. Storing Maybe<...> lets you set it to None after withdrawal. A bare QualifiedShieldedCoinInfo has no "empty" state.
Consider slot reuse carefully: If you allow slot reuse (overwriting a None slot), make sure the deposit index system doesn't allow a malicious user to reference a reused slot unexpectedly.
Releasing Coins with sendShielded
When the contract logic permits, use sendShielded to transfer the coin to a recipient:
circuit withdraw(
depositIndex: Uint<64>,
recipient: ZswapCoinPublicKey
): [] {
const coinSlot = ledger.coins.get(depositIndex);
assert coinSlot.isSome : "deposit not found";
const coin = coinSlot.value;
const depositorKey = ledger.depositors.get(depositIndex);
// Verify the caller is the depositor
// (In practice, use commit/reveal — see the access control article)
assert recipient == depositorKey : "unauthorized withdrawal";
// Clear the slot BEFORE sending
ledger.coins.set(depositIndex, Maybe.none());
ledger.depositAmounts.set(depositIndex, 0);
// Send the full coin to recipient
sendShielded(recipient, coin, coin.info.value);
}
sendShielded takes three arguments:
-
recipient: ZswapCoinPublicKey— who receives the coin -
coin: QualifiedShieldedCoinInfo— the coin to spend -
amount: Uint<64>— the amount to send (can be less than the coin's full value, creating change)
Partial sends require careful handling. When you send less than the full coin value, the remainder must be accounted for explicitly — either returned to the contract as a new coin receipt, or the circuit must guarantee no value is destroyed. Check the runtime documentation for the exact mechanics in your toolchain version before relying on partial sends in production.
Coin Merging with mergeCoinImmediately
If a contract accumulates multiple small deposits that need to be sent as a single larger payment, you can merge coins with mergeCoinImmediately. This combines two coins into one, simplifying state management.
contract MergingVault {
ledger primaryCoin: Maybe<QualifiedShieldedCoinInfo>;
ledger primaryBalance: Uint<64>;
circuit depositAndMerge(witness coinProof: CoinProof): [] {
const newCoin = receiveShielded(coinProof);
assert newCoin.info.tokenType == nativeToken() : "MNT only";
if ledger.primaryCoin.isNone {
// First deposit — just store it
ledger.primaryCoin = Maybe.some(newCoin);
ledger.primaryBalance = newCoin.info.value;
} else {
// Merge into existing coin
const existingCoin = ledger.primaryCoin.value;
const mergedCoin = mergeCoinImmediately(existingCoin, newCoin);
ledger.primaryCoin = Maybe.some(mergedCoin);
ledger.primaryBalance = ledger.primaryBalance + newCoin.info.value;
}
}
circuit withdrawAll(recipient: ZswapCoinPublicKey): [] {
assert ledger.primaryCoin.isSome : "nothing to withdraw";
const coin = ledger.primaryCoin.value;
ledger.primaryCoin = Maybe.none();
ledger.primaryBalance = 0;
sendShielded(recipient, coin, coin.info.value);
}
}
mergeCoinImmediately requires both coins to have the same token type. Attempting to merge MNT with a custom token will fail. The resulting coin has a combined value and a fresh randomness commitment.
When to Merge
Merging makes sense when:
- You're aggregating deposits for a pooled payout (staking rewards, crowdfund)
- You want to simplify ledger state by reducing the number of stored coins
- Your contract charges a fee and needs to combine fee coins with deposit coins
The tradeoff: merging is a ZK operation that adds circuit constraints and proof generation cost. Don't merge unless you have a good reason — multiple stored coins is often fine.
Complete Escrow Contract
Here's a full escrow contract that combines everything above. The scenario: a buyer deposits funds, a seller delivers goods, and either party can release or refund based on the outcome.
import * from "stdlib";
const DOMAIN_BUYER = bytes("midnight:escrow-v1:buyer-claim");
const DOMAIN_SELLER = bytes("midnight:escrow-v1:seller-claim");
contract Escrow {
// Escrow state
ledger state: Uint<8>;
// 0 = empty, 1 = funded, 2 = delivered, 3 = complete, 4 = refunded
// The held coin
ledger escrowCoin: Maybe<QualifiedShieldedCoinInfo>;
ledger escrowAmount: Uint<64>;
// Identity commitments (not public keys — see access control article)
ledger buyerCommitment: Bytes<32>;
ledger sellerCommitment: Bytes<32>;
// Nullifiers for claiming
ledger buyerNullifier: Maybe<Bytes<32>>;
ledger sellerNullifier: Maybe<Bytes<32>>;
// Arbitration deadline (block time)
ledger deadline: Uint<64>;
// --- Setup ---
circuit initialize(
buyerCommitment: Bytes<32>,
sellerCommitment: Bytes<32>,
deadline: Uint<64>
): [] {
assert ledger.state == 0 : "already initialized";
ledger.buyerCommitment = buyerCommitment;
ledger.sellerCommitment = sellerCommitment;
ledger.deadline = deadline;
ledger.state = 1; // ready to receive funds
}
// --- Deposit (Buyer) ---
circuit fund(witness coinProof: CoinProof): [] {
assert ledger.state == 1 : "not in funding state";
assert ledger.escrowCoin.isNone : "already funded";
const coin = receiveShielded(coinProof);
assert coin.info.tokenType == nativeToken() : "MNT only";
assert coin.info.value > 0 : "zero-value deposit";
ledger.escrowCoin = Maybe.some(coin);
ledger.escrowAmount = coin.info.value;
// State stays at 1 (funded, awaiting delivery confirmation)
}
// --- Delivery Confirmation (Seller marks delivered) ---
circuit markDelivered(
witness sellerSecret: Bytes<32>,
public nullifier: Bytes<32>
): [] {
assert ledger.state == 1 : "escrow not active";
assert ledger.escrowCoin.isSome : "not funded";
assert currentBlockTime() <= ledger.deadline : "escrow expired";
// Verify seller identity
assert verifyCommitment(sellerSecret, ledger.sellerCommitment)
: "not the seller";
// Replay prevention
assert nullifier == persistentCommit(
sellerSecret.concat(ContractAddress.self().toBytes()).concat(DOMAIN_SELLER)
) : "invalid nullifier";
assert ledger.sellerNullifier.isNone : "already marked delivered";
ledger.sellerNullifier = Maybe.some(nullifier);
ledger.state = 2; // awaiting buyer confirmation
}
// --- Release to Seller (Buyer confirms receipt) ---
circuit confirmAndRelease(
witness buyerSecret: Bytes<32>,
public nullifier: Bytes<32>,
sellerRecipient: ZswapCoinPublicKey
): [] {
assert ledger.state == 2 : "not in confirmation state";
// Verify buyer identity
assert verifyCommitment(buyerSecret, ledger.buyerCommitment)
: "not the buyer";
// Replay prevention
assert nullifier == persistentCommit(
buyerSecret.concat(ContractAddress.self().toBytes()).concat(DOMAIN_BUYER)
) : "invalid nullifier";
assert ledger.buyerNullifier.isNone : "buyer already acted";
ledger.buyerNullifier = Maybe.some(nullifier);
// Release funds to seller
const coin = ledger.escrowCoin.value;
ledger.escrowCoin = Maybe.none();
ledger.state = 3; // complete
sendShielded(sellerRecipient, coin, coin.info.value);
}
// --- Refund (Buyer requests refund after deadline, or before delivery) ---
circuit refund(
witness buyerSecret: Bytes<32>,
public nullifier: Bytes<32>,
buyerRecipient: ZswapCoinPublicKey
): [] {
// Can refund if: not yet delivered, OR deadline has passed
const canRefund = (ledger.state == 1)
|| (ledger.state == 2 && currentBlockTime() > ledger.deadline);
assert canRefund : "cannot refund at this state";
assert ledger.escrowCoin.isSome : "no funds to refund";
// Verify buyer identity
assert verifyCommitment(buyerSecret, ledger.buyerCommitment)
: "not the buyer";
// Replay prevention (same domain as confirmAndRelease — one action per buyer)
assert nullifier == persistentCommit(
buyerSecret.concat(ContractAddress.self().toBytes()).concat(DOMAIN_BUYER)
) : "invalid nullifier";
assert ledger.buyerNullifier.isNone : "buyer already acted";
ledger.buyerNullifier = Maybe.some(nullifier);
// Return funds to buyer
const coin = ledger.escrowCoin.value;
ledger.escrowCoin = Maybe.none();
ledger.state = 4; // refunded
sendShielded(buyerRecipient, coin, coin.info.value);
}
// --- View functions ---
circuit getEscrowAmount(): [Uint<64>] {
return [ledger.escrowAmount];
}
circuit getState(): [Uint<8>] {
return [ledger.state];
}
}
How to Use This Contract
Buyer setup:
// Off-chain: generate buyer secret and commitment
const buyerSecret = crypto.randomBytes(32);
const buyerCommitment = hash(buyerSecret); // store buyerSecret securely!
// Deploy or initialize escrow
await callCircuit('initialize', {
buyerCommitment,
sellerCommitment, // provided by seller
deadline: currentBlockTime() + 7 * 24 * 3600 // 7 days
});
Buyer funds the escrow:
// Wallet generates coinProof from buyer's MNT balance
const coinProof = await wallet.generateCoinProof(amount);
await callCircuit('fund', { coinProof });
Seller marks delivered:
const sellerNullifier = persistentCommit(
sellerSecret + contractAddress + DOMAIN_SELLER
);
await callCircuit('markDelivered', {
witness: { sellerSecret },
public: { nullifier: sellerNullifier }
});
Buyer confirms and releases:
const buyerNullifier = persistentCommit(
buyerSecret + contractAddress + DOMAIN_BUYER
);
await callCircuit('confirmAndRelease', {
witness: { buyerSecret },
public: { nullifier: buyerNullifier },
sellerRecipient: seller.spendingKey
});
Edge Cases and Design Notes
What if the buyer's coin proof is for the wrong amount? The contract accepts whatever coin is presented — there's no "expected amount" check in this example. In production, you'd want to check coin.info.value == agreedPrice and reject otherwise.
What if the seller disappears? The deadline mechanism handles this. If the seller never calls markDelivered, the buyer can call refund after the deadline. Set deadlines generously — block time on Midnight can vary.
What about fees? You could modify confirmAndRelease to split the coin: send (value - fee) to the seller and a separate fee amount to a fee address. Partial sends with change require careful handling to avoid value destruction.
What about disputes? This contract has no arbitration. For real escrow, you'd add a third party (arbitrator commitment stored at init) who can call arbitrate(winner) to release funds to either party. The same commit/reveal identity pattern applies.
Privacy: The escrowAmount is stored in the ledger and is thus publicly visible. If you need amount privacy, don't store it separately — just check ledger.escrowCoin.isSome to know if funded. The coin's actual value stays hidden unless you expose it.
Summary
The deposit/withdrawal pattern in Compact:
-
Accept:
receiveShielded(coinProof)— validates and returnsQualifiedShieldedCoinInfo -
Store: Assign to
ledger.field— preserve the fullQualifiedShieldedCoinInfo -
Check: Access
coin.info.value,coin.info.tokenTypefor business logic -
Release:
sendShielded(recipient, coin, amount)— spends the coin, creates new coin for recipient -
Merge (optional):
mergeCoinImmediately(coin1, coin2)— combine same-type coins
The non-obvious thing: coins are objects, not numbers. You don't add to a balance — you hold a coin object and its associated value. This makes the state management feel more like UTXO accounting than account-based EVM development, but it's what enables the shielded transfer privacy guarantees that make Midnight worth building on.
Top comments (0)