DEV Community

Cover image for Shielded Token Contracts on Midnight: Real Errors, Real Fixes
Elliot lucky
Elliot lucky

Posted on

Shielded Token Contracts on Midnight: Real Errors, Real Fixes

Written from months of grinding on shielded liquidity DeFi protocols on Midnight.


If you've been trying to build anything serious with shielded fungible tokens on Midnight lending protocols, liquidity pools, DEXes you've probably hit some walls that the documentation doesn't fully prepare you for. The Midnight programming model around shielded tokens is genuinely different from anything in the EVM world, and a lot of the intuitions you carry from Solidity or even other ZK environments will get you into trouble fast.

This post is a breakdown of the most impactful errors and misconceptions I ran into while building shielded liquidity DeFi contracts using Midnight's Compact language. These are not theoretical every single one of these either broke a circuit or caused a proof server failure at some point. I'll walk through what the issue is, why it happens, and what the correct pattern looks like.


Background: How Shielded Tokens Actually Work Under the Hood

Before we get into the errors, let's get clear on the underlying mechanics because this context is what makes the errors make sense.

Midnight uses a protocol called Zswap for shielded token operations. When a user sends tokens to your contract by calling receiveShielded, what actually happens is more involved than it looks on the surface.

When your circuit calls receiveShielded(coin), the Compact runtime records a shielded receive obligation in the transaction being constructed. At this point, the proof server kicks in to generate the ZK proof for your circuit. But here's the thing your circuit only describes what the contract side is doing. The transaction still needs to be balanced: the tokens being received by the contract have to come from somewhere.

This is where the wallet gets involved through an internal mechanism that runs beneath your circuit. The wallet looks at the ShieldedCoinInfo you're receiving the coin's color (token type) and value and finds a matching UTXO in the user's private coin set. It then generates a Zswap ownership proof a ZK proof that proves the wallet owns a valid commitment in the global shielded ledger (via a nullifier), without revealing which UTXO it is. This proof is what actually authorizes the spending of the user's tokens.

The ShieldedCoinInfo that you receive in your circuit as a parameter is essentially the user's declaration: "I have a coin of this color and this value that I'm sending you." The wallet is the one that provides the actual cryptographic evidence backing that claim. The proof server then bundles your circuit's proof together with the wallet's Zswap proof into a single transaction that satisfies all the balance constraints.

This is why ShieldedCoinInfo has to arrive as a circuit parameter it has to exist before the proof is constructed so the wallet knows what to balance against. You cannot dynamically request coins inside a circuit in a way the wallet didn't know about from the start.

// The coin comes in as a circuit parameter - the wallet uses this
// to find the matching UTXO and generate its Zswap ownership proof
export circuit deposit(incomingCoin_: ShieldedCoinInfo): [] {
    const incomingCoin = disclose(incomingCoin_);
    // At this point, the wallet has already committed to providing
    // a UTXO matching incomingCoin.color and incomingCoin.value
    receiveShielded(incomingCoin);
    // ...
}
Enter fullscreen mode Exit fullscreen mode

With that foundation in place, let's get into the errors.


Error 1: Multiple Separate Shielded Balance Mappings

The mistake: Creating separate ledger fields for each type of shielded asset the contract holds, or trying to manage shielded balances across multiple independent QualifiedShieldedCoinInfo ledger fields.

// DON'T do this
export ledger assetABalance: QualifiedShieldedCoinInfo;
export ledger assetBBalance: QualifiedShieldedCoinInfo;
export ledger assetCBalance: QualifiedShieldedCoinInfo;
// ...and growing as you add more assets
Enter fullscreen mode Exit fullscreen mode

Why it fails: The moment you try to receive or send shielded tokens from multiple separate ledger fields in anything but the most trivial scenarios, you'll start hitting proof construction issues and ledger state inconsistencies. It also completely kills your extensibility you can't add a new supported asset without changing the contract's ledger schema.

The correct pattern: One Map<Bytes<32>, QualifiedShieldedCoinInfo> keyed by coin color. That's your single source of truth for everything the contract holds in shielded form. Separate tracking for accounting, positions, pool states all of that can live in whatever structure you want (Maps, commitments, Merkle trees). But the actual shielded custody is one map.

// ONE mapping to manage all shielded holdings
export ledger contractShieldedBalance: Map<Bytes<32>, QualifiedShieldedCoinInfo>;

// Separate ledgers for your protocol accounting - no shielded tokens here
export ledger userPositions: Map<Bytes<32>, UserPosition>;
export ledger assetConfigs: Map<Bytes<32>, AssetConfig>;
Enter fullscreen mode Exit fullscreen mode

The QualifiedShieldedCoinInfo returned by operations like insertCoin or mergeCoinImmediate already encapsulates the UTXO pointer the contract needs to spend from. You want all of that living in one place, keyed by color, so any circuit that needs to receive or send a particular asset knows exactly where to look.


Error 2: Receiving and Sending from the Same Contract Balance in One Circuit

This one caused the most confusing errors. The symptom is a public mismatch error from the proof server which is cryptic enough that you might chase it for a while before finding the root cause.

The mistake: Writing a single circuit that both receives shielded tokens into the contract balance (receiveShielded then update your balance map) and sends shielded tokens from the same balance map (sendShielded) for the same coin type.

The intuition that leads you here is natural in a two-sided operation like a collateral deposit paired with a loan payout, you want to take in one token and release another in the same transaction. In a liquidity rebalance, you take in one asset and send another. Seems like one atomic operation. It isn't, at least not when both sides touch the same QualifiedShieldedCoinInfo.

Why it fails: When your circuit calls receiveShielded, it marks that UTXO slot as modified in one direction. When it calls sendShielded from the same balance map entry, the proof server tries to reconcile the public inputs/outputs for that UTXO across both operations in the same proof and the balance equations don't hold cleanly. This manifests as the public input mismatch error.

The correct pattern: Split the operation into two circuits one for receiving, one for sending.

// Phase 1: receive the incoming token, update contract balance, mark state as pending
export circuit actionReceive(incomingCoin_: ShieldedCoinInfo): [] {
    const incomingCoin = disclose(incomingCoin_);
    // ... validate, update accounting state ...

    receiveShielded(incomingCoin);

    const existing = contractShieldedBalance.member(incomingCoin.color)
        ? mergeCoinImmediate(contractShieldedBalance.lookup(incomingCoin.color), incomingCoin)
        : incomingCoin;

    contractShieldedBalance.insertCoin(
        incomingCoin.color,
        existing,
        right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
    );

    // Flag that the send side is pending
    userState.insert(userKey, { ...currentState, status: Status.sendPending });
}

// Phase 2: send from contract balance, clear pending state
export circuit actionSend(outgoingCoinColor_: Bytes<32>): [] {
    const outgoingCoinColor = disclose(outgoingCoinColor_);
    const state = userState.lookup(userKey);
    assert(state.status == Status.sendPending, "Send is not pending");

    // Only sending here - no receiveShielded in this circuit
    const balance = contractShieldedBalance.lookup(outgoingCoinColor);
    const result = sendShielded(balance, left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey()), state.pendingAmount);

    if (result.change.is_some) {
        contractShieldedBalance.insertCoin(outgoingCoinColor, result.change.value, right<ZswapCoinPublicKey, ContractAddress>(kernel.self()));
    } else {
        contractShieldedBalance.remove(outgoingCoinColor);
    }

    userState.insert(userKey, { ...state, status: Status.settled, pendingAmount: 0 });
}
Enter fullscreen mode Exit fullscreen mode

The two-phase pattern also gives you a cleaner state machine for your protocol. The pending status flags aren't just organizational they're the safety check that ensures the second circuit can only run after the first one completed successfully on-chain.

There are two specific cases where you can mix shielded operations in one circuit, and they work fine because they don't touch the same QualifiedShieldedCoinInfo on both sides:

Case A: receiveShielded + mintShieldedToken You're receiving one type of token (into your balance map) and minting a different token (LP tokens, receipt tokens, etc.) directly to the user. No conflict because mint creates a new UTXO that wasn't in the contract's balance.

// Works fine: receive principal token, mint a receipt token to user
export circuit depositAndMintReceipt(incomingCoin_: ShieldedCoinInfo): [] {
    const incomingCoin = disclose(incomingCoin_);

    receiveShielded(incomingCoin);

    const existing = contractShieldedBalance.member(incomingCoin.color)
        ? mergeCoinImmediate(contractShieldedBalance.lookup(incomingCoin.color), incomingCoin)
        : incomingCoin;

    contractShieldedBalance.insertCoin(
        incomingCoin.color,
        existing,
        right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
    );

    // Mint a receipt token of a completely different color to the user
    mintShieldedToken(
        receiptTokenDomainSeparator,
        incomingCoin.value,
        mintNonce(),
        left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey())
    );
}
Enter fullscreen mode Exit fullscreen mode

Case B: receiveShielded + sendImmediateShielded You receive a token and immediately send it to the burn address (or anywhere else) without going through the contract's managed balance map. The sendImmediate family of operations routes tokens directly without touching the contract's stored QualifiedShieldedCoinInfo, so there's no conflict. This is the correct pattern for burning.

// Works fine: receive a receipt token from user, burn it immediately
export circuit burnReceiptAndWithdraw(receiptCoin_: ShieldedCoinInfo, underlyingCoinColor_: Bytes<32>): [] {
    const receiptCoin = disclose(receiptCoin_);
    const underlyingColor = disclose(underlyingCoinColor_);
    const burnAddr = shieldedBurnAddress();

    receiveShielded(receiptCoin);
    sendImmediateShielded(receiptCoin, burnAddr, receiptCoin.value); // burn receipt token directly

    // Now send the underlying asset FROM the contract balance (different coin type = no conflict)
    const balance = contractShieldedBalance.lookup(underlyingColor);
    const withdrawAmount = calculateWithdrawable(receiptCoin.value);
    const result = sendShielded(balance, left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey()), withdrawAmount);

    if (result.change.is_some) {
        contractShieldedBalance.insertCoin(underlyingColor, result.change.value, right<ZswapCoinPublicKey, ContractAddress>(kernel.self()));
    } else {
        contractShieldedBalance.remove(underlyingColor);
    }
}
Enter fullscreen mode Exit fullscreen mode

Error 3: Treating Shielded Token Balance Like an Unshielded Balance

If you're coming from building unshielded token contracts on Midnight, you might expect the contract runtime to track your shielded holdings automatically. It doesn't.

With unshielded tokens, the runtime handles the accounting your contract just calls the right transfer functions and the ledger reflects what's there. Shielded tokens work completely differently. The contract has no automatic awareness of what shielded UTXOs it holds. If you receive a shielded deposit and don't explicitly track it in a ledger field, that UTXO is effectively lost you can't spend it because you have no reference to it.

The QualifiedShieldedCoinInfo type is what gives you that reference. It's the pointer from your contract's ledger into the shielded UTXO set. Without it, you cannot call sendShielded.

export ledger contractShieldedBalance: Map<Bytes<32>, QualifiedShieldedCoinInfo>;

export circuit receiveDeposit(incomingCoin_: ShieldedCoinInfo): [] {
    const incomingCoin = disclose(incomingCoin_);

    receiveShielded(incomingCoin);

    // Without this insertCoin call, the tokens are received but permanently unspendable
    const coinToStore = contractShieldedBalance.member(incomingCoin.color)
        ? mergeCoinImmediate(contractShieldedBalance.lookup(incomingCoin.color), incomingCoin)
        : incomingCoin;

    contractShieldedBalance.insertCoin(
        incomingCoin.color,
        coinToStore,
        right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
    );
}
Enter fullscreen mode Exit fullscreen mode

The insertCoin call is what binds the received UTXO to your contract's ledger state. Skip it and you've essentially sent tokens into a black hole.


Best Practice 4: Always Receive ShieldedCoinInfo as a Circuit Parameter

This one is less a confirmed error and more a pattern I've settled on firmly: always accept ShieldedCoinInfo as a circuit parameter before calling receiveShielded. Do not attempt to derive or build the coin info from other inputs inside the circuit body.

The reason this matters comes down to how the proof system works. The wallet needs to see the ShieldedCoinInfo at the point of transaction construction so it can identify the matching UTXO from the user's private coin set and generate its Zswap ownership proof. The circuit parameters are what the wallet reads to know what to balance against. If the coin information isn't surfaced as a parameter, the wallet has no clean way to participate in the proof at the right time.

The correct pattern: Accept ShieldedCoinInfo as a parameter, disclose it inside the circuit, then pass it to receiveShielded.

// The coin arrives as a parameter - the wallet can read it and balance accordingly
export circuit deposit(incomingCoin_: ShieldedCoinInfo): [] {
    const incomingCoin = disclose(incomingCoin_);
    receiveShielded(incomingCoin);
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The frontend or CLI is responsible for constructing the ShieldedCoinInfo from the user's wallet (the wallet API exposes the user's available coins), then passing it as a parameter to the circuit call. The wallet then handles the UTXO selection and ownership proof on its side.


Error 5: Storing Each Deposit as an Independent UTXO

The mistake: Instead of merging new deposits into a single QualifiedShieldedCoinInfo balance, someone might reach for a Set<QualifiedShieldedCoinInfo> to accumulate UTXOs, thinking they can collect them all and deal with selection later.

// DON'T do this - accumulating independent UTXOs in a set
export ledger assetUTXOs: Set<QualifiedShieldedCoinInfo>;

export circuit deposit(incomingCoin_: ShieldedCoinInfo): [] {
    const incomingCoin = disclose(incomingCoin_);

    receiveShielded(incomingCoin);

    // Each deposit added as its own independent UTXO entry
    assetUTXOs.insertCoin(incomingCoin);
}
Enter fullscreen mode Exit fullscreen mode

Why this is a problem: When the contract needs to send, sendShielded takes a single QualifiedShieldedCoinInfo as input. If your liquidity is spread across a set of independent UTXOs, you now have to filter and sort through them to find entries that cover the amount you need, handle partial coverage across multiple entries, and manually merge before you can send anything. That logic is complex, brittle, and entirely avoidable. You end up writing significant off-chain UTXO selection code for a problem that doesn't need to exist.

The correct pattern: Always merge deposits into a single aggregated balance per coin color using mergeCoinImmediate. One coin color = one QualifiedShieldedCoinInfo entry in your map. When you deposit, merge. When you send, the single entry has everything you need.

export circuit deposit(incomingCoin_: ShieldedCoinInfo): [] {
    const incomingCoin = disclose(incomingCoin_);

    receiveShielded(incomingCoin);

    // Merge into existing balance, or initialize if first deposit of this type
    const aggregated = contractShieldedBalance.member(incomingCoin.color)
        ? mergeCoinImmediate(contractShieldedBalance.lookup(incomingCoin.color), incomingCoin)
        : incomingCoin;

    contractShieldedBalance.insertCoin(
        incomingCoin.color,
        aggregated,
        right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
    );
}
Enter fullscreen mode Exit fullscreen mode

mergeCoinImmediate is provided by the Compact standard library exactly for this purpose combining multiple UTXOs of the same coin type into a single QualifiedShieldedCoinInfo. Use it.


Error 6: Not Handling Change After Sending

This one is subtle. When your contract calls sendShielded to send tokens to a user, the operation doesn't automatically return the change to the contract. You have to handle it explicitly.

Think of it like spending cash if you hand over a 100-unit note to pay for something that costs 70, you need to receive 30 back. If you don't explicitly handle that change, it evaporates.

The mistake:

export circuit withdraw(coinColor_: Bytes<32>, amount: Uint<128>, receiver: ZswapCoinPublicKey): [] {
    const coinColor = disclose(coinColor_);
    const balance = contractShieldedBalance.lookup(coinColor);
    sendShielded(balance, left<ZswapCoinPublicKey, ContractAddress>(receiver), amount);
    // BUG: change is lost. contractShieldedBalance still points to a spent UTXO.
    // The next attempt to send from this color will fail.
}
Enter fullscreen mode Exit fullscreen mode

The correct pattern: sendShielded returns a result that contains the change (if any). Check it. If there's change, put it back into your balance map. If the balance is fully spent, remove the entry.

export circuit withdraw(coinColor_: Bytes<32>, amount: Uint<128>, receiver: ZswapCoinPublicKey): [] {
    const coinColor = disclose(coinColor_);
    assert(contractShieldedBalance.member(coinColor), "No balance for this coin type");
    assert(amount > 0, "Invalid send amount");

    const balance = contractShieldedBalance.lookup(coinColor);
    const result = sendShielded(
        balance,
        left<ZswapCoinPublicKey, ContractAddress>(receiver),
        amount
    );

    // Always handle the change
    if (result.change.is_some) {
        contractShieldedBalance.insertCoin(
            coinColor,
            result.change.value,
            right<ZswapCoinPublicKey, ContractAddress>(kernel.self())
        );
    } else {
        contractShieldedBalance.remove(coinColor); // fully spent, clean up
    }
}
Enter fullscreen mode Exit fullscreen mode

If you skip this and your contract later tries to call sendShielded on a coin color whose UTXO was already spent in a previous transaction, the transaction will fail because the UTXO no longer exists in the ledger.


Putting It All Together: The Pattern That Works

Combining all of the above, here's the mental model for shielded token management that I now use in every contract:

State design:

  • One Map<Bytes<32>, QualifiedShieldedCoinInfo> as the single source of truth for all shielded holdings
  • All other accounting (user positions, asset configs, protocol state) in separate non-shielded ledger structures

Receive pattern:

  • Accept ShieldedCoinInfo as a circuit parameter
  • Call receiveShielded on the disclosed coin
  • Merge into the balance map with mergeCoinImmediate

Send pattern:

  • Lookup from the balance map
  • Call sendShielded, capture the result
  • Handle change or remove the entry

Split circuits wherever both sides touch the same QualifiedShieldedCoinInfo:

  • Receive in one circuit, update state to pending
  • Send in a separate circuit, verify pending state, execute, finalize

Safe exceptions where you can combine in one circuit:

  • Receiving a token + minting a different token (LP tokens, receipt tokens)
  • Receiving a token + sendImmediate to burn it (doesn't touch the balance map)

References


These patterns took real trial and error to arrive at. The proof server errors in particular are notoriously unhelpful when you're deep in the wrong abstraction a "public mismatch" message doesn't tell you that you're trying to receive and send from the same balance in one circuit. Hopefully this saves someone else those hours.

If you're building shielded DeFi on Midnight and running into something I didn't cover here, feel free to reach out.

Follow Me on X: https://x.com/codebigint_01

Top comments (0)