Fungible Token Standard with UTXO Conversion Extensions
| MIP | 0004 |
|---|---|
| Status | Draft |
| Category | Standards |
| Authors | Guido De Vita (dvgui) |
| Created | 2026-03-21 |
Abstract
This MIP defines a standard interface that extends the OpenZeppelin FungibleToken contract for Midnight — the canonical account-based (Map) token implementation for the Midnight ecosystem — with bidirectional conversion circuits for both shielded and unshielded UTXOs. Building directly on the OpenZeppelin Compact Contracts library, the proposal introduces four conversion circuits — shield, toUtxo, unshield, and fromUtxo — that allow contract token balances to be converted into Midnight-native ledger tokens (UTXOs) and back. This enables contracts built on top of the OpenZeppelin FungibleToken to participate in Zswap atomic swaps, payment channels, and other UTXO-native operations, while preserving the familiar account-based programming model for DeFi logic. The conversion is supply-neutral: moving tokens between the Map and UTXO representations does not change the contract’s totalSupply.
Motivation
The current FungibleToken standard on Midnight (as implemented in the OpenZeppelin Compact Contracts library) provides an account-based (Map) token that mirrors the ERC-20 interface. While this model is well suited for DeFi protocols, lending, and governance, it cannot directly participate in Midnight’s UTXO-native operations such as:
- Zswap atomic swaps: Confidential, non-interactive multi-asset exchanges require tokens in UTXO form.
- Payment channels and streaming payments: UTXO-based tokens can be transferred peer-to-peer without contract interaction, enabling low-latency payments.
- Cross-contract and cross-chain bridges: Bridge protocols often require discrete, individually-owned token units rather than entries in a shared balance map.
- Privacy-enhanced transfers: Shielded UTXOs hide the sender, receiver, and amount, providing stronger confidentiality than account-based transfers where balances are visible in the contract’s public ledger state.
Multiple projects building on Midnight — including decentralized exchanges, lending protocols, and payment applications — have independently identified the need for tokens that can move fluidly between the account model and the UTXO model. Without a standard interface for this conversion, each project will implement its own incompatible approach, fragmenting the ecosystem and increasing security risk.
This MIP addresses the gap by defining a minimal, composable set of conversion circuits that any fungible token contract can implement alongside the existing FungibleToken interface.
Specification
Terminology
- Map balance: A token balance stored in the contract’s
_balancesledger map, associated with an account identifier (Either<ZswapCoinPublicKey, ContractAddress>). See the FungibleToken contract for the full ledger state definition. - Shielded UTXO: A Zswap coin whose value and ownership are hidden using zero-knowledge commitments. Managed by the Midnight protocol layer, not by smart contract state.
- Unshielded UTXO: A protocol-level UTXO whose value and ownership are publicly visible, signed with Schnorr signatures.
- Token color: A deterministic identifier for a class of contract-minted UTXOs, computed as
SHA-256(domainSeparator, contractAddress)via thetokenType(domainSep, contract)function in the Compact Standard Library. - Domain separator: A 32-byte value chosen at deployment that, together with the contract address, uniquely identifies the token type for UTXO operations.
Required State Extensions
Contracts implementing this standard MUST add the following ledger variables:
export ledger domain: Bytes<32>;
export ledger utxoSupply: Uint<128>;
ledger mintCounter: Counter;
ledger mintNonce: Bytes<32>;
domain: A domain separator set at construction, used to compute the token color for UTXO operations. MUST be public so that any participant can read it and so that the conversion circuits can derive the token color at call time viatokenType(domain, kernel.self()).utxoSupply: A running total of tokens currently held as UTXOs (both shielded and unshielded). Incremented byshieldandtoUtxo, decremented byunshieldandfromUtxo. MUST be public (export ledger) for off-chain supply accounting.mintCounter: A monotonically increasing counter used together withmintNonceto guarantee uniqueness of shielded coin commitments. SHOULD be private to reduce information leakage.mintNonce: A nonce that evolves with eachshieldoperation to prevent commitment collisions. SHOULD be private to reduce information leakage.
Why not store color in the ledger? An earlier version stored the token color as export ledger color: Bytes<32>, precomputed in the constructor via tokenType(domainSep, kernel.self()). This was found to produce a different hash than what mintShieldedToken and mintUnshieldedToken stamp on minted coins. The Compact runtime resolves kernel.self() differently during constructor execution vs circuit execution, causing a mismatch that breaks unshield color validation and fromUtxo UTXO absorption. The fix is to compute tokenType(domain, kernel.self()) at call time in each circuit that needs it.
These MUST be initialized in the constructor:
constructor(..., domainSep: Bytes<32>, initNonce: Bytes<32>) {
// ... existing FungibleToken initialization ...
domain = disclose(domainSep);
mintNonce = disclose(initNonce);
}
Authorization State (for implementations exposing mint)
If a mint circuit is provided, the implementation MUST authenticate the caller via a hash-based commitment rather than ownPublicKey(). The reference implementation declares:
witness secretKey(): Bytes<32>;
export ledger admin: Bytes<32>;
export ledger minter: Bytes<32>;
secretKey(): A witness function returning the caller’s 32-byte authentication secret. The secret lives only in the caller’s private state and never appears on-chain or in any disclosed circuit value.admin: ABytes<32>commitment registered at construction. The deployer’ssecretKey()is hashed in-circuit and stored as the admin identity.minter: ABytes<32>commitment initially identical toadmin. Can be reassigned viasetMinterto delegate minting authority.
Initialization in the constructor uses the hash-based pattern:
constructor(..., domainSep: Bytes<32>, initNonce: Bytes<32>) {
// ... existing FungibleToken initialization ...
const adminPk = authPublicKey(secretKey());
admin = disclose(adminPk);
minter = disclose(adminPk);
domain = disclose(domainSep);
mintNonce = disclose(initNonce);
}
See Hash-Based Authentication below for the authPublicKey helper and its security rationale.
Required Circuits
shield(amount: Uint<64>) → ShieldedCoinInfo
Converts amount tokens from the caller’s Map balance into a shielded UTXO (Zswap coin) owned by the caller.
Behavior:
- MUST revert if
amountis zero. - Deduct
amountfrom the caller’s Map balance. MUST revert if the caller’s balance is insufficient. - Increment
utxoSupplybyamount. - MUST NOT modify
totalSupply. - Increment the
mintCounterand evolvemintNoncefor commitment uniqueness. - Call
mintShieldedToken(domain, amount, mintNonce, caller)from the Compact Standard Library to produce a protocol-level shielded coin. - Return the
ShieldedCoinInfoto the caller.
Note on amount type: The amount parameter is Uint<64> rather than Uint<128> because Midnight’s native mintShieldedToken operation accepts only Uint<64> values in practice, despite the Compact Standard Library documentation specifying Uint<128>. This limits the maximum amount convertible in a single shield operation to 2^64 - 1 base units. See Amount Type Constraints for details.
toUtxo(amount: Uint<64>, recipient: UserAddress) → Bytes<32>
Converts amount tokens from the caller’s Map balance into an unshielded UTXO owned by recipient.
Behavior:
- MUST revert if
amountis zero. - Deduct
amountfrom the caller’s Map balance. MUST revert if the caller’s balance is insufficient. - Increment
utxoSupplybyamount. - MUST NOT modify
totalSupply. - Call
mintUnshieldedToken(domain, amount, right(recipient))from the Compact Standard Library to produce a protocol-level unshielded UTXO. Thedomainvalue is read directly from the public ledger state. - Return the token color (
Bytes<32>).
Design note: The circuit reads domain from the public ledger state rather than accepting it as a parameter, eliminating the risk of domain separator misuse and simplifying the caller interface.
unshield(coin: ShieldedCoinInfo) → []
Converts a shielded UTXO back into Map balance for the caller.
Behavior:
- MUST revert if
coin.valueis zero. - Compute the expected token color as
tokenType(domain, kernel.self()). - MUST revert if
coin.colordoes not equal the computed color. This prevents crediting this token’s Map balance with a shielded coin from a different token contract. - Call
receiveShielded(coin)from the Compact Standard Library to absorb (nullify) the shielded coin from the transaction. - Credit
coin.valueto the caller’s Map balance. MUST revert on arithmetic overflow. - Decrement
utxoSupplybycoin.value. - MUST NOT modify
totalSupply.
Design note: The amount to credit is derived directly from coin.value (which is Uint<128>) rather than accepted as a separate parameter. Since ShieldedCoinInfo.value is the committed value of the shielded coin, this eliminates any possibility of an amount mismatch between the UTXO value and the Map balance credit.
Security note: The color validation (steps 2–3) is critical. Without it, a user could call unshield with a shielded coin from a different token contract and receive a Map balance credit for this token. The receiveShielded call itself does not validate the coin’s color — it only verifies the coin exists in the ZSwap Merkle tree.
fromUtxo(amount: Uint<128>) → []
Converts unshielded UTXOs back into Map balance for the caller.
Behavior:
- MUST revert if
amountis zero. - Compute the token color as
tokenType(domain, kernel.self()). - Call
receiveUnshielded(color, amount)to absorb (nullify) the unshielded UTXOs from the transaction. - Credit
amountto the caller’s Map balance. MUST revert on arithmetic overflow. - Decrement
utxoSupplybyamount. - MUST NOT modify
totalSupply.
Note on amount type: The amount parameter is Uint<128> because receiveUnshielded natively accepts Uint<128> values, allowing the full range for inbound conversions.
Optional Circuits
burn(value: Uint<128>) → []
Implementations MAY include a burn circuit that allows any user to burn their own Map balance.
Behavior:
- MUST revert if
valueis zero. - Deduct
valuefrom the caller’s Map balance. MUST revert if the caller’s balance is insufficient. - Decrement
totalSupplybyvalue.
Design note: This circuit delegates to the OpenZeppelin FT__burn internal function, which handles both the balance deduction and the supply adjustment. It is useful for bridge protocols, redemption mechanisms, and deflationary token models. Unlike conversion circuits, burn permanently removes tokens from the supply.
mint(account, value: Uint<128>) → []
Implementations MAY include a mint circuit for post-deployment token issuance. Alternatively, the entire supply may be minted in the constructor.
If a mint circuit is provided, it MUST be gated by an access control mechanism. The reference implementation uses a hash-based admin/minter pattern: roles are stored as Bytes<32> commitments derived from the role-holder’s secret, and mint / setMinter callers prove they hold the matching secret inside the ZK circuit. The deployer is both admin and minter by default; the admin can delegate minting via setMinter(newMinterCommitment: Bytes<32>). Implementations are free to choose any suitable authorization scheme, but MUST NOT use ownPublicKey() for caller verification — see Hash-Based Authentication.
Optional: Minting Delegation
setMinter(newMinterCommitment: Bytes<32>) → []
Admin-only circuit that delegates minting authority to a different key. The deployer starts as both admin and minter. Calling setMinter changes who can call mint without transferring admin control.
The new minter computes their commitment off-chain as authPublicKey(theirSecret) (see Hash-Based Authentication) and supplies the resulting Bytes<32> value. The contract never sees the raw secret — it only stores the commitment and verifies future mint callers against it in-circuit.
Supply Invariant
The fundamental invariant of this standard is:
\texttt{totalSupply} = \sum \text{Map balances} + \sum \text{shielded UTXOs} + \sum \text{unshielded UTXOs}
The contract tracks the UTXO portion explicitly via utxoSupply:
\texttt{utxoSupply} = \sum \text{shielded UTXOs} + \sum \text{unshielded UTXOs}
Therefore, the Map-held supply can be derived off-chain:
\texttt{mapSupply} = \texttt{totalSupply} - \texttt{utxoSupply}
Since totalSupply in the contract’s ledger only tracks the total tokens ever minted via _mint minus any burned via _burn, and conversion operations adjust Map balances and utxoSupply without touching totalSupply, the contract’s on-ledger totalSupply reflects the total tokens in existence across all representations.
Implementers MUST ensure that:
- Conversion circuits never increment or decrement
totalSupply. Onlymintandburnoperations may alter supply. - All four conversion circuits correctly maintain
utxoSupply: increment onshield/toUtxo, decrement onunshield/fromUtxo.
Internal Helpers
Compliant implementations SHOULD provide the following internal circuits:
_deductBalance
circuit _deductBalance(
account: Either<ZswapCoinPublicKey, ContractAddress>,
amount: Uint<128>
): []
Deducts amount from account’s Map balance without modifying totalSupply. Reverts on insufficient balance.
_creditBalance
circuit _creditBalance(
account: Either<ZswapCoinPublicKey, ContractAddress>,
amount: Uint<128>
): []
Credits amount to account’s Map balance without modifying totalSupply. Reverts on arithmetic overflow (MAX_UINT128 - balance < amount).
These helpers separate the accounting concern (balance adjustment) from the supply concern (the _update circuit in the base FungibleToken), making it explicit that conversions are supply-neutral.
Compatibility with FungibleToken
This standard is designed as an extension. Contracts MUST implement the core FungibleToken interface (name, symbol, decimals, totalSupply, balanceOf, transfer). The approve, allowance, and transferFrom circuits are deferred — contract-to-contract (C2C) calls are currently disabled on Midnight, making delegated spending unusable. These MAY be added when C2C support ships. The UTXO conversion circuits are additive and MUST NOT alter the behavior of existing FungibleToken circuits.
Rationale
Why extend the existing standard rather than create a new token type?
Many DeFi protocols on Midnight are already being built against the FungibleToken interface. By defining UTXO conversion as an extension, existing contracts can adopt the new functionality without breaking compatibility. This also avoids fragmenting the ecosystem between “Map-only” and “UTXO-only” token standards. Midnight’s dual-ledger architecture is explicitly designed to support both models, and this MIP reflects that design intent.
Why four circuits instead of two?
Midnight distinguishes between shielded (Zswap) and unshielded (Schnorr-signed) UTXOs at the protocol level. They have different privacy properties, different verification mechanisms, and different Compact Standard Library functions. A unified toUtxo/fromUtxo interface would hide this distinction and could lead to privacy accidents (e.g., a user intending to create a shielded UTXO accidentally creating an unshielded one). Explicit shield/unshield and toUtxo/fromUtxo pairs make the privacy choice unambiguous at the call site.
Amount type constraints in outbound operations
Midnight’s protocol-level UTXO minting functions (mintShieldedToken, mintUnshieldedToken) accept only Uint<64> values in practice, while the Map-based FungibleToken uses Uint<128>. The Compact Standard Library documentation specifies Uint<128> for mintShieldedToken, but the current compiler silently truncates values to 64 bits. This standard uses Uint<64> for outbound operations (shield, toUtxo) to make the constraint explicit and prevent silent data loss.
Inbound operations (unshield and fromUtxo) use Uint<128> where the protocol supports it, maximizing the range for conversions back to Map balances.
Tokens with balances exceeding 2^64 - 1 base units in a single position will need multiple outbound conversion operations. We recommend that the Midnight protocol team consider upgrading mintShieldedToken and mintUnshieldedToken to accept Uint<128> in a future release, which would allow this standard to be updated accordingly.
Why read domain from ledger state instead of accepting it as a parameter?
The domain separator determines the token color for unshielded UTXOs. An alternative design would declare domain as sealed (private) and pass it as an argument to toUtxo/fromUtxo. However, this creates several problems: users could pass the wrong value (creating non-recoverable UTXOs with a mismatched token color), deployers could selectively share different values with different users, and the value must be publicly known anyway for the contract to be usable. By making domain a public ledger variable and reading it directly in toUtxo/fromUtxo, these problems are eliminated. The correct domain separator is always used, and any participant can read it from the contract’s public state.
Why derive the unshield amount from the coin rather than accepting it as a parameter?
An alternative unshield(coin, amount) design would require the caller to specify the amount separately from the shielded coin. Since the coin’s value is hidden inside a Pedersen commitment, the contract cannot verify that the claimed amount matches the committed value — it would rely entirely on the protocol-level balance equation to catch mismatches. By using unshield(coin) and reading coin.value directly, the amount is always exactly the committed value, making a mismatch impossible by construction.
Why supply-neutral conversions?
Tokens moving between Map and UTXO form are not being created or destroyed — they are changing representation. If totalSupply were decremented on shield and incremented on unshield, it would misrepresent the total tokens in existence and could confuse indexers, block explorers, and DeFi protocols that rely on totalSupply as a measure of circulating supply. This is consistent with how the ERC-20 standard treats wrapping: wrapping ERC-20 tokens into a wrapped variant does not change the underlying token supply.
Why track utxoSupply explicitly?
Without an on-chain utxoSupply counter, there is no way for the contract (or off-chain observers) to know how many tokens are currently held as UTXOs versus Map balances. The totalSupply alone cannot distinguish between the two. By tracking utxoSupply explicitly, indexers and block explorers can compute mapSupply = totalSupply - utxoSupply and report accurate circulating supply breakdowns. The cost is minimal: four additional increment/decrement operations across the conversion circuits.
Path to Active
Acceptance Criteria
- At least one reference implementation deployed on Midnight testnet.
- Integration demonstrated with at least one Zswap-based DEX or atomic swap protocol.
- Review and endorsement by the Midnight developer community through workshop sessions (as described in MIP-1).
- Security audit of a reference implementation.
Reference Deployment (Preprod)
A reference implementation has been deployed on Midnight preprod and exercised through all conversion circuits. The deployment demonstrates the full token lifecycle: minting, Map → UTXO conversion (both shielded and unshielded), and UTXO → Map round-trip.
Contract: ef0ba484ee62fd8e31a7f84732d3b2934b3dcf441c6619b9ba503d8d113a7161
Network: preprod | Token: MIP42 | Decimals: 6
Token color: 532a67c05b9e3c890f6fe93df3c03644efa708a31a2ae2458d975130651345cf
| # | Circuit | Amount (MIP42) | Transaction |
|---|---|---|---|
| 1 | deploy (constructor) |
— | ef0ba484...3a7161 |
| 2 | mint |
1,000.000000 | 0060e08d...9f871d |
| 3 | toUtxo |
100.000000 | 0032c6a8...62d0a5 |
| 4 | toUtxo |
50.000000 | 00ecb30d...197161 |
| 5 | shield |
100.000000 | 0036b317...b5952a |
| 6 | shield |
50.000000 | 00207542...bce396 |
| 7 | fromUtxo |
100.000000 | 00b1bc05...27e4a9 |
| 8 | unshield |
100.000000 | 00f10b70...16bed3 |
Final state: 800.000000 MIP42 in Map, 50.000000 in unshielded UTXOs, 50.000000 in shielded UTXOs. totalSupply = 1,000.000000, utxoSupply = 100.000000.
All four conversion circuits (shield, toUtxo, unshield, fromUtxo) demonstrated bidirectional round-trips on a live network.
ZSwap Atomic Swap Demonstration (Preprod)
Two tokens deployed on preprod were used to demonstrate ZSwap atomic swaps in both unshielded and shielded modes between two independent wallets (Alice and Bob).
Contracts:
- MIP42:
ef0ba484...3a7161 - SWAP:
e92d621f...bbf94
Setup: 500 MIP42 minted to Alice, 500 SWAP minted to Bob. Each converted 100 to unshielded UTXOs and 100 to shielded UTXOs.
| # | Step | Transaction |
|---|---|---|
| 1 | Mint 500 MIP42 → Alice | 00af800b...52e098 |
| 2 | Mint 500 SWAP → Bob | 00e2d84d...10e43b |
| 3 | Alice toUtxo 100 MIP42 |
0025c0f1...3bc901 |
| 4 | Alice shield 100 MIP42 |
0024419b...8a70dd |
| 5 | Bob toUtxo 100 SWAP |
00ad0b30...20910 |
| 6 | Bob shield 100 SWAP |
0033325d...8e8428 |
Unshielded ZSwap — Alice offers 50 MIP42 for 50 SWAP (unshielded UTXOs):
| # | Step | Transaction |
|---|---|---|
| 7 | Atomic swap (unshielded) | 00a8ec41...fae939 |
| Wallet | MIP42 unshielded | SWAP unshielded |
|---|---|---|
| Alice (before) | 100.000000 | 0 |
| Alice (after) | 50.000000 | 50.000000 |
| Bob (before) | 0 | 100.000000 |
| Bob (after) | 50.000000 | 50.000000 |
Shielded ZSwap — Alice offers 50 MIP42 for 50 SWAP (shielded UTXOs):
| # | Step | Transaction |
|---|---|---|
| 8 | Atomic swap (shielded) | 00fd036f...67ce4d |
| Wallet | MIP42 shielded | SWAP shielded |
|---|---|---|
| Alice (before) | 100.000000 | 0 |
| Alice (after) | 50.000000 | 50.000000 |
| Bob (before) | 0 | 100.000000 |
| Bob (after) | 50.000000 | 50.000000 |
Both unshielded and shielded ZSwap atomic swaps completed successfully with correct balance accounting on both sides. The shielded swap preserves privacy: the transaction on-chain does not reveal which tokens were exchanged, the amounts, or the parties involved.
Full reference implementation and CLI tooling available at midnight-swap.
Implementation Plan
- Publish a reference implementation as an extension of the OpenZeppelin Compact Contracts
FungibleTokenlibrary. - Develop integration tests covering all conversion paths, edge cases, the supply invariant, and
utxoSupplytracking. - Collaborate with DEX and DeFi teams building on Midnight to validate the interface.
- Submit for formal review through the MIP process.
Backwards Compatibility Assessment
This MIP is fully backwards compatible with the existing FungibleToken standard. No changes to existing FungibleToken circuits are required. The new state variables (domain, utxoSupply, mintCounter, mintNonce) and circuits (shield, toUtxo, unshield, fromUtxo) are purely additive.
Existing contracts that do not implement UTXO conversion will continue to function without modification. Contracts wishing to adopt this standard will need to be deployed with the extended constructor parameters or upgraded if the contract system supports upgrades.
Token balances in the Map are not affected by UTXO operations on other users’ balances. The conversion circuits only modify the caller’s own balance.
Security Considerations
Nonce Uniqueness and Commitment Collisions
The shield circuit relies on a monotonically increasing counter and an evolving nonce to produce unique commitments for each shielded coin. If two shield operations were to produce identical commitments, it could lead to a double-spend vulnerability. Implementations MUST ensure that the counter-nonce evolution scheme produces unique outputs for every invocation. The reference implementation uses evolveNonce(mintCounter, mintNonce) from the Compact Standard Library, which is designed for this purpose. Making mintCounter and mintNonce private ledger state prevents external observation of the nonce evolution sequence, reducing potential information leakage.
Amount Integrity in unshield
The unshield circuit derives the credit amount directly from coin.value within the ShieldedCoinInfo struct, rather than accepting it as a separate parameter. This design ensures that the amount credited to the caller’s Map balance is always exactly the value committed in the shielded UTXO. The protocol-level receiveShielded function validates the coin against the Zswap commitment scheme, and the balance equation enforcement ensures that no value is created or destroyed. The combination of these two mechanisms — application-level derivation from coin.value and protocol-level commitment validation — provides defense in depth against amount manipulation.
UTXO Proliferation and State Bloat
Each shield or toUtxo call creates a new UTXO on the Midnight ledger. If users perform many small conversions, this can lead to UTXO set growth. While Midnight’s nullifier-based design handles spent UTXOs efficiently, a large active UTXO set can increase wallet synchronization time and state proof sizes. Applications should be designed to minimize unnecessary fragmentation.
Domain Separator Integrity
The domain value is set once at construction and stored as public ledger state. The toUtxo and fromUtxo circuits read domain directly from the ledger, ensuring that all unshielded UTXO operations use the correct and consistent token color. Since domain is immutable after construction (there is no circuit to modify it), the token color for a given contract is fixed for its lifetime. Users and wallets do not need to know or manage the domain separator — it is always read from the contract’s public state.
Hash-Based Authentication for admin and minter
For implementations that include a mint circuit, access control is critical to prevent unauthorized supply inflation.
ownPublicKey() MUST NOT be used to verify the caller. It is a witness function: its return value is supplied by the user’s frontend and is not bound to the ZK proof. A malicious dApp can return any value, so a check like assert(ownPublicKey() == admin, ...) is bypassable. The Midnight smart-contract security docs explicitly forbid this pattern. Storing role identities as ZswapCoinPublicKey and comparing them against ownPublicKey() was the original approach in earlier versions of this MIP, and it is now considered insecure.
Instead, roles MUST be stored as dApp-scoped commitments of the role-holder’s secret. The caller proves in-circuit that they know a secret whose hash matches the stored commitment. Because the hash is recomputed inside the proof from a witness value, the frontend cannot substitute a different identity without inverting the hash.
The reference implementation uses the following helper:
witness secretKey(): Bytes<32>;
circuit authPublicKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>(
[pad(32, "midnight-swap:token:auth"), sk]
);
}
circuit assertAdmin(): [] {
assert(admin == authPublicKey(secretKey()), "only admin");
}
circuit assertMinter(): [] {
assert(minter == authPublicKey(secretKey()), "only minter");
}
- The domain separator (
"midnight-swap:token:auth"in the reference; implementers SHOULD choose their own) prevents a secret from being replayed across unrelated dApps. authPublicKeyMUST hash a fixed 32-byte domain separator together with the secret. The first argument padding ensures different domains produce different commitments even when the secret is the same.assertAdminandassertMinterMUST be the only gates onsetMinterandmintrespectively.
Initialization. The constructor sets admin and minter to disclose(authPublicKey(secretKey())). The deployer’s secret is the deployer’s only credential; losing it makes the contract immutably minterless.
Supplying the secretKey witness. secretKey() is a witness, so its value is not stored on-chain — it is read from the caller’s private state at proving time and supplied per contract instance. In the reference TypeScript implementation the witness is wired as:
type TokenPrivateState = { readonly secretKey: Uint8Array }; // 32 bytes
const witnesses = {
secretKey: (ctx: { privateState: TokenPrivateState }): [TokenPrivateState, Uint8Array] =>
[ctx.privateState, ctx.privateState.secretKey],
};
The secret is provided when the caller constructs or joins the contract, via the deploy/join initialPrivateState (e.g. { secretKey }, derived from the wallet seed). Consequences for implementers and operators:
- A caller who deploys the contract, or who calls a privileged circuit (
mint,setMinter), MUST supply a private state whosesecretKeymatches the relevant commitment — the constructor andassertAdmin/assertMinterinvoke the witness. Supplying an empty or wrong private state fails proof generation (the witness returns no/invalid bytes, or the in-circuit equality assertion fails). - A caller who only invokes the non-privileged conversion/transfer circuits (
shield,unshield,toUtxo,fromUtxo,transfer,balanceOf) needs no secret in private state — those circuits never callsecretKey(). Any wallet can run them against its own balance. - The witness value is consumed only by
authPublicKeyand is neverdisclose()d, so it stays in the caller’s private state and never reaches the public ledger.
Delegation. setMinter(newMinterCommitment: Bytes<32>) accepts a pre-computed commitment from the new minter. Operationally:
- The new minter generates or chooses their own 32-byte secret locally.
- They compute
authPublicKey(theirSecret)off-chain using the same hash and domain separator. - They share only the resulting
Bytes<32>commitment with the admin. - The admin calls
setMinter(thatCommitment). The raw secret never leaves the new minter’s machine.
Single-key authorization. The reference pattern still authorizes a single key per role. Production deployments that need role separation, multi-party governance, or revocation SHOULD layer a richer scheme on top (e.g. a Merkle tree of authorized commitments + nullifiers). The Midnight devnet’s 15-verifier ceiling currently prevents OpenZeppelin AccessControl from being used directly with this token; OZ AccessControl itself currently authenticates via ownPublicKey() and SHOULD NOT be used until it migrates to the same hash-based pattern.
Residual risk. Secret leakage equals identity loss: anyone with the role-holder’s secretKey() value can impersonate them. Treat the secret the same way as a wallet signing key. Deriving the secret from a wallet seed (as the reference implementation does) ties on-chain identities to the same threat model as the wallet itself.
Zero-Amount Protection
All conversion circuits (shield, toUtxo, unshield, fromUtxo) MUST revert on zero-amount inputs. This prevents no-op transactions that consume DUST without performing meaningful work, and eliminates edge cases in supply accounting.
Implementation
Components to Modify or Create
- New Compact module:
FungibleTokenUTXO.compact— extends FungibleToken with the four conversion circuits and associated state. - Constructor extension: Additional parameters for
domainSepandinitNonce. Initialization ofutxoSupply. - Internal helpers:
_deductBalanceand_creditBalanceas supply-neutral balance adjustment circuits.
Dependencies
- OpenZeppelin Compact Contracts
FungibleTokenmodule (v0.0.1-alpha.1 or later). - OpenZeppelin Compact Contracts
Utilsmodule. - OpenZeppelin Compact Contracts
AccessControlmodule (v0.0.1-alpha.1 or later) — optional, for production deployments that need role-based access control (adds significant circuit count). - Compact Standard Library:
mintShieldedToken,mintUnshieldedToken,receiveShielded,receiveUnshielded,tokenType,evolveNonce,Counter. - Compact language version >= 0.22.0.
Reference Implementation
A reference implementation is provided as a Compact contract that imports and wraps the FungibleToken module. The reference implementation includes optional burn, mint (gated by the hash-based admin/minter pattern with setMinter delegation), and 13 circuits total (15 verifier keys — at the local devnet block limit). The full source is available in this repository at token-with-utxo and the full development tooling at midnight-swap.
Testing
Unit Tests
shield: Verify Map balance decreases byamount,totalSupplyunchanged,utxoSupplyincremented, shielded coin returned. Verify revert on zero amount.toUtxo: Verify Map balance decreases,totalSupplyunchanged,utxoSupplyincremented, correct token color returned.unshield: Verify Map balance increases bycoin.value,totalSupplyunchanged,utxoSupplydecremented.fromUtxo: Verify Map balance increases,totalSupplyunchanged,utxoSupplydecremented.- Revert on insufficient balance for
shieldandtoUtxo. - Revert on zero amount for all conversion circuits.
- Revert on overflow for
_creditBalance. mint: revert when the caller’ssecretKey()does not hash to the storedmintercommitment. Pass when it does.setMinter: revert when the caller is not the admin (secretKey()does not hash toadmin). After a successful delegation, the previous admin’ssecretKey()no longer hashes tominterand can no longermint.
Integration Tests
- Round-trip:
mint→shield→unshield→ verify balance restored,utxoSupplyreturns to zero. - Round-trip:
mint→toUtxo→fromUtxo→ verify balance restored,utxoSupplyreturns to zero. - Cross-user: User A calls
toUtxo→ UTXO transferred off-chain to User B → User B callsfromUtxo. - Zswap atomic swap between two token contracts both implementing this standard.
Invariant Tests
- After any sequence of operations, verify:
totalSupply == sum(all Map balances) + utxoSupply. - After any sequence of operations, verify:
utxoSupply == sum(all outstanding UTXOs). - Fuzz testing with randomized sequences of mint, burn, shield, unshield, toUtxo, fromUtxo, and transfer operations.
References (Optional)
- MIP-1: Midnight Improvement Proposal Process
- OpenZeppelin Compact Contracts — FungibleToken
- OpenZeppelin Compact Contracts — AccessControl
- OpenZeppelin Compact Contracts — Repository
- Midnight Zswap Documentation
- Midnight UTXO Model Documentation
- Midnight Ledgers Documentation
- The Compact Language
- ERC-20 Token Standard (Ethereum)
- Midnight Smart-Contract Security: caller authentication (forbids
ownPublicKey()for caller verification)
Acknowledgements
This proposal was developed by Sommet Labs, in collaboration with OpenZeppelin and informed by the existing work in the OpenZeppelin Compact Contracts library. Additional input was provided by ecosystem teams building DeFi and payment applications on Midnight, as well as the Midnight protocol documentation.
Copyright Waiver
All contributions (code and text) submitted in this MIP must be licensed under the Apache License, Version 2.0. Submission requires agreement to the Midnight Foundation Contributor License Agreement [Link to CLA], which includes the assignment of copyright for your contributions to the Foundation.