{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreicyl6pfrjecvdm5khcavev2qcucjd6ovgh3scstfvlgw5cbu3o6bi",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpkmujzvrqa2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreiabmh7vsmi5gwmxpzh2wnpqu6sov2uadrtm32zp5vsnmno4cton7m"
},
"mimeType": "image/webp",
"size": 298836
},
"path": "/codebigint_01/shielded-token-contracts-on-midnight-real-errors-real-fixes-4fc7",
"publishedAt": "2026-07-01T03:07:56.000Z",
"site": "https://dev.to",
"tags": [
"midnight",
"blockchain",
"defi",
"web3",
"docs.midnight.network/develop/reference/compact",
"docs.midnight.network/develop/guides/shielded-tokens",
"docs.midnight.network/learn/zswap",
"docs.midnight.network/develop/guides/transactions",
"https://x.com/codebigint_01"
],
"textContent": "_Written from months of grinding on shielded liquidity DeFi protocols on Midnight._\n\nIf 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.\n\nThis 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.\n\n## Background: How Shielded Tokens Actually Work Under the Hood\n\nBefore we get into the errors, let's get clear on the underlying mechanics because this context is what makes the errors make sense.\n\nMidnight 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.\n\nWhen 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.\n\nThis 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.\n\nThe `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.\n\nThis 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.\n\n\n\n // The coin comes in as a circuit parameter - the wallet uses this\n // to find the matching UTXO and generate its Zswap ownership proof\n export circuit deposit(incomingCoin_: ShieldedCoinInfo): [] {\n const incomingCoin = disclose(incomingCoin_);\n // At this point, the wallet has already committed to providing\n // a UTXO matching incomingCoin.color and incomingCoin.value\n receiveShielded(incomingCoin);\n // ...\n }\n\n\nWith that foundation in place, let's get into the errors.\n\n## Error 1: Multiple Separate Shielded Balance Mappings\n\n**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.\n\n\n\n // DON'T do this\n export ledger assetABalance: QualifiedShieldedCoinInfo;\n export ledger assetBBalance: QualifiedShieldedCoinInfo;\n export ledger assetCBalance: QualifiedShieldedCoinInfo;\n // ...and growing as you add more assets\n\n\n**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.\n\n**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.\n\n\n\n // ONE mapping to manage all shielded holdings\n export ledger contractShieldedBalance: Map<Bytes<32>, QualifiedShieldedCoinInfo>;\n\n // Separate ledgers for your protocol accounting - no shielded tokens here\n export ledger userPositions: Map<Bytes<32>, UserPosition>;\n export ledger assetConfigs: Map<Bytes<32>, AssetConfig>;\n\n\nThe `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.\n\n## Error 2: Receiving and Sending from the Same Contract Balance in One Circuit\n\nThis 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.\n\n**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.\n\nThe 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`.\n\n**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.\n\n**The correct pattern:** Split the operation into two circuits one for receiving, one for sending.\n\n\n\n // Phase 1: receive the incoming token, update contract balance, mark state as pending\n export circuit actionReceive(incomingCoin_: ShieldedCoinInfo): [] {\n const incomingCoin = disclose(incomingCoin_);\n // ... validate, update accounting state ...\n\n receiveShielded(incomingCoin);\n\n const existing = contractShieldedBalance.member(incomingCoin.color)\n ? mergeCoinImmediate(contractShieldedBalance.lookup(incomingCoin.color), incomingCoin)\n : incomingCoin;\n\n contractShieldedBalance.insertCoin(\n incomingCoin.color,\n existing,\n right<ZswapCoinPublicKey, ContractAddress>(kernel.self())\n );\n\n // Flag that the send side is pending\n userState.insert(userKey, { ...currentState, status: Status.sendPending });\n }\n\n // Phase 2: send from contract balance, clear pending state\n export circuit actionSend(outgoingCoinColor_: Bytes<32>): [] {\n const outgoingCoinColor = disclose(outgoingCoinColor_);\n const state = userState.lookup(userKey);\n assert(state.status == Status.sendPending, \"Send is not pending\");\n\n // Only sending here - no receiveShielded in this circuit\n const balance = contractShieldedBalance.lookup(outgoingCoinColor);\n const result = sendShielded(balance, left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey()), state.pendingAmount);\n\n if (result.change.is_some) {\n contractShieldedBalance.insertCoin(outgoingCoinColor, result.change.value, right<ZswapCoinPublicKey, ContractAddress>(kernel.self()));\n } else {\n contractShieldedBalance.remove(outgoingCoinColor);\n }\n\n userState.insert(userKey, { ...state, status: Status.settled, pendingAmount: 0 });\n }\n\n\nThe 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.\n\nThere 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:\n\n**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.\n\n\n\n // Works fine: receive principal token, mint a receipt token to user\n export circuit depositAndMintReceipt(incomingCoin_: ShieldedCoinInfo): [] {\n const incomingCoin = disclose(incomingCoin_);\n\n receiveShielded(incomingCoin);\n\n const existing = contractShieldedBalance.member(incomingCoin.color)\n ? mergeCoinImmediate(contractShieldedBalance.lookup(incomingCoin.color), incomingCoin)\n : incomingCoin;\n\n contractShieldedBalance.insertCoin(\n incomingCoin.color,\n existing,\n right<ZswapCoinPublicKey, ContractAddress>(kernel.self())\n );\n\n // Mint a receipt token of a completely different color to the user\n mintShieldedToken(\n receiptTokenDomainSeparator,\n incomingCoin.value,\n mintNonce(),\n left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey())\n );\n }\n\n\n**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.\n\n\n\n // Works fine: receive a receipt token from user, burn it immediately\n export circuit burnReceiptAndWithdraw(receiptCoin_: ShieldedCoinInfo, underlyingCoinColor_: Bytes<32>): [] {\n const receiptCoin = disclose(receiptCoin_);\n const underlyingColor = disclose(underlyingCoinColor_);\n const burnAddr = shieldedBurnAddress();\n\n receiveShielded(receiptCoin);\n sendImmediateShielded(receiptCoin, burnAddr, receiptCoin.value); // burn receipt token directly\n\n // Now send the underlying asset FROM the contract balance (different coin type = no conflict)\n const balance = contractShieldedBalance.lookup(underlyingColor);\n const withdrawAmount = calculateWithdrawable(receiptCoin.value);\n const result = sendShielded(balance, left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey()), withdrawAmount);\n\n if (result.change.is_some) {\n contractShieldedBalance.insertCoin(underlyingColor, result.change.value, right<ZswapCoinPublicKey, ContractAddress>(kernel.self()));\n } else {\n contractShieldedBalance.remove(underlyingColor);\n }\n }\n\n\n## Error 3: Treating Shielded Token Balance Like an Unshielded Balance\n\nIf 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.\n\nWith 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.\n\nThe `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`.\n\n\n\n export ledger contractShieldedBalance: Map<Bytes<32>, QualifiedShieldedCoinInfo>;\n\n export circuit receiveDeposit(incomingCoin_: ShieldedCoinInfo): [] {\n const incomingCoin = disclose(incomingCoin_);\n\n receiveShielded(incomingCoin);\n\n // Without this insertCoin call, the tokens are received but permanently unspendable\n const coinToStore = contractShieldedBalance.member(incomingCoin.color)\n ? mergeCoinImmediate(contractShieldedBalance.lookup(incomingCoin.color), incomingCoin)\n : incomingCoin;\n\n contractShieldedBalance.insertCoin(\n incomingCoin.color,\n coinToStore,\n right<ZswapCoinPublicKey, ContractAddress>(kernel.self())\n );\n }\n\n\nThe `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.\n\n## Best Practice 4: Always Receive ShieldedCoinInfo as a Circuit Parameter\n\nThis 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.\n\nThe 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.\n\n**The correct pattern:** Accept `ShieldedCoinInfo` as a parameter, disclose it inside the circuit, then pass it to `receiveShielded`.\n\n\n\n // The coin arrives as a parameter - the wallet can read it and balance accordingly\n export circuit deposit(incomingCoin_: ShieldedCoinInfo): [] {\n const incomingCoin = disclose(incomingCoin_);\n receiveShielded(incomingCoin);\n // ...\n }\n\n\nThe 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.\n\n## Error 5: Storing Each Deposit as an Independent UTXO\n\n**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.\n\n\n\n // DON'T do this - accumulating independent UTXOs in a set\n export ledger assetUTXOs: Set<QualifiedShieldedCoinInfo>;\n\n export circuit deposit(incomingCoin_: ShieldedCoinInfo): [] {\n const incomingCoin = disclose(incomingCoin_);\n\n receiveShielded(incomingCoin);\n\n // Each deposit added as its own independent UTXO entry\n assetUTXOs.insertCoin(incomingCoin);\n }\n\n\n**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.\n\n**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.\n\n\n\n export circuit deposit(incomingCoin_: ShieldedCoinInfo): [] {\n const incomingCoin = disclose(incomingCoin_);\n\n receiveShielded(incomingCoin);\n\n // Merge into existing balance, or initialize if first deposit of this type\n const aggregated = contractShieldedBalance.member(incomingCoin.color)\n ? mergeCoinImmediate(contractShieldedBalance.lookup(incomingCoin.color), incomingCoin)\n : incomingCoin;\n\n contractShieldedBalance.insertCoin(\n incomingCoin.color,\n aggregated,\n right<ZswapCoinPublicKey, ContractAddress>(kernel.self())\n );\n }\n\n\n`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.\n\n## Error 6: Not Handling Change After Sending\n\nThis 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.\n\nThink 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.\n\n**The mistake:**\n\n\n\n export circuit withdraw(coinColor_: Bytes<32>, amount: Uint<128>, receiver: ZswapCoinPublicKey): [] {\n const coinColor = disclose(coinColor_);\n const balance = contractShieldedBalance.lookup(coinColor);\n sendShielded(balance, left<ZswapCoinPublicKey, ContractAddress>(receiver), amount);\n // BUG: change is lost. contractShieldedBalance still points to a spent UTXO.\n // The next attempt to send from this color will fail.\n }\n\n\n**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.\n\n\n\n export circuit withdraw(coinColor_: Bytes<32>, amount: Uint<128>, receiver: ZswapCoinPublicKey): [] {\n const coinColor = disclose(coinColor_);\n assert(contractShieldedBalance.member(coinColor), \"No balance for this coin type\");\n assert(amount > 0, \"Invalid send amount\");\n\n const balance = contractShieldedBalance.lookup(coinColor);\n const result = sendShielded(\n balance,\n left<ZswapCoinPublicKey, ContractAddress>(receiver),\n amount\n );\n\n // Always handle the change\n if (result.change.is_some) {\n contractShieldedBalance.insertCoin(\n coinColor,\n result.change.value,\n right<ZswapCoinPublicKey, ContractAddress>(kernel.self())\n );\n } else {\n contractShieldedBalance.remove(coinColor); // fully spent, clean up\n }\n }\n\n\nIf 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.\n\n## Putting It All Together: The Pattern That Works\n\nCombining all of the above, here's the mental model for shielded token management that I now use in every contract:\n\n**State design:**\n\n * One `Map<Bytes<32>, QualifiedShieldedCoinInfo>` as the single source of truth for all shielded holdings\n * All other accounting (user positions, asset configs, protocol state) in separate non-shielded ledger structures\n\n\n\n**Receive pattern:**\n\n * Accept `ShieldedCoinInfo` as a circuit parameter\n * Call `receiveShielded` on the disclosed coin\n * Merge into the balance map with `mergeCoinImmediate`\n\n\n\n**Send pattern:**\n\n * Lookup from the balance map\n * Call `sendShielded`, capture the result\n * Handle change or remove the entry\n\n\n\n**Split circuits wherever both sides touch the same`QualifiedShieldedCoinInfo`:**\n\n * Receive in one circuit, update state to pending\n * Send in a separate circuit, verify pending state, execute, finalize\n\n\n\n**Safe exceptions where you can combine in one circuit:**\n\n * Receiving a token + minting a different token (LP tokens, receipt tokens)\n * Receiving a token + `sendImmediate` to burn it (doesn't touch the balance map)\n\n\n\n## References\n\n * Compact Standard Library `receiveShielded`, `sendShielded`, `sendImmediateShielded`, `mintShieldedToken`, `mergeCoinImmediate`: docs.midnight.network/develop/reference/compact\n * Shielded token concepts and `QualifiedShieldedCoinInfo`: docs.midnight.network/develop/guides/shielded-tokens\n * Zswap protocol overview: docs.midnight.network/learn/zswap\n * Transaction balancing and proof construction: docs.midnight.network/develop/guides/transactions\n\n\n\nThese 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.\n\nIf you're building shielded DeFi on Midnight and running into something I didn't cover here, feel free to reach out.\n\nFollow Me on X: https://x.com/codebigint_01",
"title": "Shielded Token Contracts on Midnight: Real Errors, Real Fixes"
}