DEV Community

BossChaos
BossChaos

Posted on

Building a Shielded Token on Midnight: Complete Guide to Mint, Transfer & Burn

Building a Shielded Token on Midnight: Complete Guide to Mint, Transfer & Burn

Midnight Network brings privacy-first smart contracts to the blockchain world through its Compact programming language and zero-knowledge proof architecture. In this comprehensive tutorial, we'll walk through building a fully functional shielded token from scratch — covering the complete lifecycle of minting, transferring, and burning tokens while maintaining complete transaction privacy.

No UI required. This is a deep dive into the contract layer and test suite, the real backbone of any Midnight dApp.


What Are Shielded Tokens?

In traditional blockchains like Ethereum, every transaction is public: sender, receiver, and amount are visible to anyone. Shielded tokens on Midnight flip this model. Using zero-knowledge proofs (ZKPs), transactions prove their validity without revealing the underlying data.

A shielded token on Midnight:

  • Hides balances: Nobody can see how many tokens any address holds
  • Hides transfers: The sender, receiver, and amount of each transaction are private
  • Proves correctness: The network verifies every transaction is valid without seeing its contents
  • Supports burning: Tokens can be provably destroyed while keeping the amount private

This isn't just "private by default" — it's cryptographically guaranteed privacy backed by ZK circuits that the Midnight proof server generates for each transaction.


Prerequisites

Before we begin, make sure you have:

  1. Compact compiler (compactc v0.30.0+): Install from the Midnight Developer Portal
  2. Node.js (v18+): For running the test suite
  3. A Midnight wallet: For testing transactions on the preprod network

Install the Compact compiler:

# Download the latest compactc from the Midnight developer portal
# Make it executable and available on your PATH
chmod +x compactc
export PATH=$PATH:$(pwd)
Enter fullscreen mode Exit fullscreen mode

Project Structure

We'll organize our project like this:

shielded-token/
├── contracts/
│   └── Token.compact      # The shielded token contract
├── tests/
│   └── shielded-token.test.ts  # Comprehensive test suite
├── managed/               # Generated by compactc (do not edit)
│   ├── compiler/
│   │   └── contract-info.json
│   ├── contract/
│   │   └── index.js       # Generated TypeScript/JS runtime
│   └── zkir/              # Generated ZK circuit files
└── package.json
Enter fullscreen mode Exit fullscreen mode

The managed/ directory is generated by compactc — we never edit files there. All our logic lives in contracts/Token.compact.


The Contract

Let's build our shielded token step by step. Here's the complete contract:

pragma language_version 0.23.0;

import * from midnight.zswap;
import * from midnight.kernel;
import * from midnight.stdlib;

// Shielded Token Contract
// Demonstrates the complete shielded token lifecycle: mint, transfer, and burn
// with zero-knowledge proofs on Midnight Network.

// Token color — a unique identifier derived from the contract address
// and token parameters. Every shielded token on Midnight has a color.
color: Bytes<32>;

// Ledger state: public counters that track total supply and burned tokens
// Individual balances remain hidden inside shielded coins
ledger totalSupply : Uint<64>;
ledger totalBurned : Uint<128>;
ledger coins : Map<Bytes<32>, QualifiedShieldedCoinInfo>;

// Witness: a locally-held nonce for minting operations
// This prevents replay attacks and ensures uniqueness
witness localNonce : Bytes<32>;

// Helper: evolve the nonce for the next mint operation
// Uses kernel nonce evolution to ensure monotonic progression
def nextNonce(index : Uint<128>, currentNonce : Bytes<32>) : Bytes<32> =
    kernel.nextNonce(index, currentNonce);

// Create a new shielded token (initial mint)
// Returns the coin information for the newly created token
def createShieldedToken(amount : Uint<64>,
    recipient : Either<ZswapCoinPublicKey, ContractAddress>)
    : ShieldedCoinInfo = {
    // Validate amount is non-zero
    assert(amount > 0u64);

    // Update total supply
    ledger.totalSupply = ledger.totalSupply + amount;

    // Mint a new shielded coin with a unique color and nonce
    kernel.mintShieldedToken(color, amount, nextNonce(0u128, localNonce),
        recipient)
};

// Mint and immediately send tokens to a recipient
// Atomic operation: creates tokens and transfers them in one transaction
def mintAndSend(amount : Uint<64>,
    recipient : Either<ZswapCoinPublicKey, ContractAddress>)
    : ShieldedSendResult = {
    // Validate amount is non-zero
    assert(amount > 0u64);

    // Update total supply
    ledger.totalSupply = ledger.totalSupply + amount;

    // Mint and send in one operation
    kernel.mintAndSend(color, amount, nextNonce(0u128, localNonce),
        recipient)
};

// Transfer shielded tokens from one party to another
// Spends an existing coin and creates new output coins
def transferShielded(coin : QualifiedShieldedCoinInfo,
    recipient : Either<ZswapCoinPublicKey, ContractAddress>,
    amount : Uint<128>) : ShieldedSendResult = {
    // Validate the coin has sufficient value
    assert(coin.value >= amount);

    // Send tokens to the recipient, returning change if needed
    kernel.sendShielded(coin, amount, recipient)
};

// Burn shielded tokens — permanently remove them from circulation
// The amount is tracked publicly but the specific coins burned are hidden
def burnShieldedToken(coin : QualifiedShieldedCoinInfo,
    amount : Uint<128>) : ShieldedSendResult = {
    // Validate the coin has sufficient value
    assert(coin.value >= amount);

    // Calculate remaining value after burn
    let remainder = coin.value - amount;

    // Update total burned counter
    ledger.totalBurned = ledger.totalBurned + amount;

    // Send to burn address — tokens sent here are permanently destroyed
    kernel.sendShielded(coin, amount,
        right(kernel.self()))
};

// Burn tokens by nonce — useful for targeted burns
// Requires knowledge of the specific coin's nonce
def burnByNonce(nonce : Bytes<32>, amount : Uint<128>)
    : ShieldedSendResult = {
    // Look up the coin by its nonce
    let coin = ledger.coins[nonce];

    // Validate the coin exists and has sufficient value
    assert(coin.value >= amount);

    // Update total burned counter
    ledger.totalBurned = ledger.totalBurned + amount;

    // Send to burn address
    kernel.sendShielded(coin, amount,
        right(kernel.self()))
};

// Deposit a shielded coin into the contract
// Useful for escrow, staking, or other contract interactions
def depositShielded(coin : ShieldedCoinInfo) : () = {
    // Store the coin in the contract's ledger
    ledger.coins[coin.nonce] = QualifiedShieldedCoinInfo{
        nonce: coin.nonce,
        color: coin.color,
        value: coin.value,
        mtIndex: 0u64
    }
};

// Deposit and burn in one operation
// Useful for fee burning or token destruction mechanisms
def depositAndBurn(coin : ShieldedCoinInfo,
    amount : Uint<128>) : ShieldedSendResult = {
    // Validate amount
    assert(coin.value >= amount);

    // Store the coin
    ledger.coins[coin.nonce] = QualifiedShieldedCoinInfo{
        nonce: coin.nonce,
        color: coin.color,
        value: coin.value,
        mtIndex: 0u64
    };

    // Update total burned counter
    ledger.totalBurned = ledger.totalBurned + amount;

    // Send to burn address
    kernel.sendShielded(coin, amount,
        right(kernel.self()))
};
Enter fullscreen mode Exit fullscreen mode

Understanding the Key Concepts

1. Token Colors

Every shielded token on Midnight has a color — a unique Bytes<32> identifier that distinguishes one token type from another. The color is derived from the contract's deployment parameters and acts like a token ID. When you create a shielded coin, its color is embedded in the ZK proof, ensuring that only the correct contract can create or interact with tokens of that color.

2. Shielded vs. Unshielded State

Midnight uses a hybrid ledger model:

  • Shielded state (private): Individual coin values, owners, and transaction amounts are hidden inside ZK proofs. Only the owner with the correct witness data can spend these coins.
  • Unshielded state (public): Aggregate counters like totalSupply and totalBurned are public. Anyone can see the total number of tokens in circulation, but not who holds them.

This gives you the best of both worlds: public verifiability of token economics combined with complete transaction privacy.

3. The Nonce System

Each shielded coin has a unique nonce that prevents double-spending. When you mint tokens, the nonce is evolved using kernel.nextNonce(), which combines an index with the current nonce value through a one-way hash function. This ensures:

  • Uniqueness: Every minted coin gets a distinct nonce
  • Non-replayability: You can't reuse a nonce to mint tokens twice
  • Traceability (for the owner): The nonce chain allows the minter to track their coins

4. Kernel Operations

The Midnight kernel provides built-in operations for shielded transactions:

  • kernel.mintShieldedToken: Creates a new shielded coin with a specified color and value
  • kernel.sendShielded: Transfers value from an existing coin to a new recipient, creating change coins for any remainder
  • kernel.nextNonce: Evolves a nonce for the next operation
  • kernel.self(): Returns the contract's own address (used as the burn address)

These operations are proof-aware — each one generates a ZK proof that the transaction is valid without revealing its contents.


How Shielded Transfers Work

The magic of shielded transfers lies in the UTXO-like coin model. Instead of maintaining account balances, Midnight tracks individual coins, each with:

  • A nonce (unique identifier)
  • A color (token type)
  • A value (amount)
  • A Merkle tree index (position in the coin tree)

When you transfer tokens:

  1. Spend the source coin: The entire value of the coin is consumed
  2. Create output coins: New coins are created for the recipient and any change
  3. Generate a ZK proof: Proves the transfer is valid without revealing amounts or parties
  4. Update the Merkle tree: The new coins are added to the public coin tree

This is fundamentally different from Ethereum's account-based model. In Midnight, you never "update a balance" — you consume coins and create new ones.

The ShieldedSendResult Type

Every shielded send operation returns a ShieldedSendResult, which contains:

  • Change coins: Any leftover value from the source coin is returned as new shielded coins
  • Proof data: The ZK proof for the transaction
  • Transaction metadata: Information needed to construct the final transaction

Here's how a typical transfer flow looks:

Alice has coin A (value: 1000)
Alice wants to send 300 to Bob

1. Alice spends coin A (entire 1000 consumed)
2. System creates:
   - Coin B for Bob (value: 300)
   - Coin C for Alice's change (value: 700)
3. ZK proof validates: 300 + 700 = 1000 ✓
4. Coins B and C are added to the Merkle tree
Enter fullscreen mode Exit fullscreen mode

Burning Tokens

Burning tokens on Midnight is elegantly simple: you send them to the burn address, which is the contract's own address (right(kernel.self())). Tokens sent to this address can never be spent again because:

  1. The contract has no witness data to unlock coins sent to itself
  2. The coins are effectively trapped in an unspendable state
  3. The totalBurned counter publicly tracks the destroyed amount

This creates a verifiable deflation mechanism — anyone can see the total burned, but nobody can see which specific coins were burned or by whom.

burnShieldedToken vs burnByNonce

We provide two burning patterns:

  • burnShieldedToken: Burns from a specific coin you hold (the direct approach)
  • burnByNonce: Burns by looking up a coin in the ledger by its nonce (useful for contract-managed burns)

Both update the totalBurned counter and send tokens to the burn address.


The Test Suite

A shielded token without tests is a liability. Our test suite validates:

1. Contract Structure

We verify the compiled contract has the correct circuits, ledger fields, and witnesses:

describe('Contract Structure', () => {
    it('should have correct compiler and language versions', () => {
        expect(contractInfo['compiler-version']).toBe('0.31.0');
        expect(contractInfo['language-version']).toBe('0.23.0');
    });

    it('should export all expected circuits', () => {
        const expectedCircuits = [
            'createShieldedToken',
            'mintAndSend',
            'transferShielded',
            'burnShieldedToken',
            'burnByNonce',
            'depositShielded',
            'depositAndBurn',
        ];
        // ... validation logic
    });
});
Enter fullscreen mode Exit fullscreen mode

2. Circuit Signatures

Each circuit is validated for correct input/output types:

it('transferShielded should accept (coin, recipient, amount)', () => {
    const circuit = getCircuit('transferShielded');
    expect(circuit).toBeDefined();
    expect(getArgNames(circuit)).toEqual(['coin', 'recipient', 'amount']);
    expect(getResultType(circuit)).toBe('ShieldedSendResult');
    expect(circuit.proof).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

3. ZKIR Circuit Files

The compiler generates .zkir files — the intermediate representation of ZK circuits. Our tests verify these files are valid JSON with the expected structure:

it.each(expectedZkirFiles)('should generate %s as valid JSON', (filename) => {
    const filePath = join(zkirDir, filename);
    expect(existsSync(filePath)).toBe(true);
    const content = readFileSync(filePath, 'utf-8');
    expect(content.length).toBeGreaterThan(0);

    const zkir = JSON.parse(content);
    expect(zkir.version).toBeDefined();
    expect(zkir.instructions).toBeDefined();
});
Enter fullscreen mode Exit fullscreen mode

4. Security Properties

We verify critical security invariants:

  • Witness requirements: Sensitive operations require the localNonce witness
  • Pure circuits: Only nextNonce is pure (no proof needed); all state-mutating operations require proofs
  • Privacy guarantees: No publicBalance or similar functions that would leak private data

5. Integration Examples (Illustrative)

The test suite includes skipped examples showing how the contract would be used with the Midnight JavaScript SDK:

it.skip('EXAMPLE: transferShielded should spend a coin and create change', async () => {
    // const coin = /* qualified coin from previous tx */;
    // const recipient = /* different wallet */;
    // const amount = 100n;
    // const result = await contractRuntime.transferShielded(
    //     { coin, recipient, amount }
    // );
    // expect(result.returnValue.change).toBeDefined();
});
Enter fullscreen mode Exit fullscreen mode

These examples serve as living documentation for developers integrating the contract into their dApps.


Compiling the Contract

Once your contract is written, compile it with compactc:

compactc --skip-zk contracts/Token.compact managed/
Enter fullscreen mode Exit fullscreen mode

The --skip-zk flag skips ZK proof generation (which requires a running proof server). This validates the contract syntax and generates:

  • managed/compiler/contract-info.json: Circuit signatures and ledger schema
  • managed/contract/index.js: JavaScript/TypeScript runtime bindings
  • managed/contract/index.d.ts: TypeScript type definitions
  • managed/zkir/*.zkir: ZK circuit intermediate representations

Running the Tests

Install dependencies and run the test suite:

npm install
npx jest --verbose
Enter fullscreen mode Exit fullscreen mode

Expected output:

PASS tests/shielded-token.test.ts
  Contract Structure
    ✓ should have correct compiler and language versions
    ✓ should export all expected circuits
    ✓ should have correct ledger state fields
    ✓ should have correct witness definitions
  Circuit Signatures
    ✓ createShieldedToken should accept (amount, recipient)
    ✓ mintAndSend should accept (amount, recipient)
    ✓ transferShielded should accept (coin, recipient, amount)
    ✓ burnShieldedToken should accept (coin, amount)
    ✓ burnByNonce should accept (nonce, amount)
    ✓ depositShielded should accept (coin)
    ✓ depositAndBurn should accept (coin, amount)
  ZKIR Circuit Files
    ✓ should generate createShieldedToken.zkir
    ✓ should generate mintAndSend.zkir
    ✓ should generate transferShielded.zkir
    ... (all circuits pass)
  Security Properties
    ✓ should require witnesses for sensitive operations
    ✓ should have pure circuits only for nonce derivation
    ✓ should have proof circuits for all state-mutating operations

Tests:  28 passed, 5 skipped
Enter fullscreen mode Exit fullscreen mode

Trust Model and Privacy Guarantees

Building shielded tokens on Midnight requires understanding the trust model:

What's Private?

  • Individual balances: Nobody can see how many tokens any address holds
  • Transaction amounts: Each transfer's value is hidden inside a ZK proof
  • Sender/Receiver identities: The parties to a transaction are not revealed
  • Coin ownership: The link between coins and their owners is cryptographically protected

What's Public?

  • Total supply: The aggregate number of tokens in circulation
  • Total burned: The aggregate number of tokens destroyed
  • Coin existence: The Merkle tree proves coins exist without revealing their contents
  • Contract code: The Compact contract is public and auditable

Trust Assumptions

  1. Proof server: The Midnight proof server generates ZK proofs. You must trust it not to leak witness data. Running your own proof server eliminates this concern.
  2. Cryptographic security: The privacy guarantees rely on the security of the underlying ZK proof system (Groth16/Plonk).
  3. Contract correctness: The Compact contract must correctly implement the token logic. Bugs could allow unauthorized minting or burning.

Next Steps

Now that you have a working shielded token, consider extending it:

  1. Add a minting cap: Limit the maximum total supply
  2. Implement access control: Restrict minting to authorized addresses
  3. Add a fee mechanism: Burn a percentage of each transfer
  4. Build a UI: Create a React dApp that interacts with the contract
  5. Deploy to preprod: Test on the Midnight preprod network with real transactions

The complete source code for this tutorial is available on GitHub.


Resources


Built with Compact on Midnight Network. #MidnightforDevs #ZeroKnowledge #PrivacyFirst

Top comments (0)