DEV Community

mmoo9
mmoo9

Posted on

Building an Unshielded Token dApp with UI on Midnight

Building an Unshielded Token dApp with UI on Midnight

Midnight is best known for its privacy features, but not everything needs to be shielded. Unshielded tokens are perfect when you want on-chain verifiability without hiding transfer amounts—think public governance tokens, loyalty points, or demo applications. In this tutorial, we'll build a complete unshielded token dApp: a Compact smart contract, TypeScript integration, and a React frontend with wallet connection, mint, transfer, and balance display.

By the end you'll have a working dApp you can run against the local Docker stack or Midnight testnet.


Prerequisites

  • Node.js 18+
  • Docker Desktop (for local testing)
  • Midnight CLI tools: @midnight-ntwrk/compact-compiler, @midnight-ntwrk/midnight-js-*
  • A Midnight testnet wallet with DUST (from the faucet)

Understanding Unshielded vs Shielded Tokens

Unshielded Shielded
Balances Public on-chain Hidden — ZK proof only
Transfers Visible to all Sender/receiver/amount private
Use case Governance, public ledgers, demo DeFi, private payments
Complexity Simpler — no ZK circuit overhead Requires proof generation
Cost Lower DUST fees Higher (proof verification)

Unshielded tokens are the right choice when the transaction amounts don't need to be private and you want lower fees and faster confirmation.


Part 1: The Compact Contract

Midnight smart contracts are written in Compact, a domain-specific language that compiles to ZK circuits. Create contracts/unshielded_token.compact:

pragma language_version >= 0.14.0;

import CompactStandardLibrary;

// Unshielded token — balances are stored in public ledger state.
// Anyone can read balances; transfers are visible on-chain.

export ledger total_supply: Counter;

// Public balance map: address → token balance
export ledger balances: Map<Bytes<32>, Uint<128>>;

// Token metadata
export ledger name:     Bytes<32>;
export ledger symbol:   Bytes<8>;
export ledger decimals: Uint<8>;
export ledger owner:    Bytes<32>;

// Mint tokens — only the contract owner can call this
export circuit mintUnshielded(
  recipient: Bytes<32>,
  amount:    Uint<128>
): [] {
  assert own_public_key() == ledger.owner
    "Only the contract owner can mint";
  assert amount > 0 "Amount must be positive";

  const current_balance: Uint<128> =
    ledger.balances.lookup(recipient).value_or(0);
  ledger.balances.insert(recipient, current_balance + amount);
  ledger.total_supply.increment(amount);
}

// Transfer tokens between addresses
export circuit sendUnshielded(
  recipient: Bytes<32>,
  amount:    Uint<128>
): [] {
  const sender: Bytes<32> = own_public_key();
  assert amount > 0 "Amount must be positive";

  const sender_balance: Uint<128> =
    ledger.balances.lookup(sender).value_or(0);
  assert sender_balance >= amount "Insufficient balance";

  ledger.balances.insert(sender, sender_balance - amount);

  const recipient_balance: Uint<128> =
    ledger.balances.lookup(recipient).value_or(0);
  ledger.balances.insert(recipient, recipient_balance + amount);
}

// Read balance for any address
export circuit receiveUnshielded(
  address: Bytes<32>
): Uint<128> {
  return ledger.balances.lookup(address).value_or(0);
}

constructor(
  token_name:    Bytes<32>,
  token_symbol:  Bytes<8>,
  token_decimals: Uint<8>
) {
  ledger.name     = token_name;
  ledger.symbol   = token_symbol;
  ledger.decimals = token_decimals;
  ledger.owner    = own_public_key();
}
Enter fullscreen mode Exit fullscreen mode

Compile the contract:

npx compact-compiler contracts/unshielded_token.compact \
  --output src/generated/contract
Enter fullscreen mode Exit fullscreen mode

Part 2: TypeScript Contract Integration

Create src/contract.ts:

import { WalletBuilder }             from '@midnight-ntwrk/midnight-js-wallet';
import { NodeZkConfigProvider }      from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { ContractAddress }           from '@midnight-ntwrk/midnight-js-types';
import {
  UnshieldedTokenContract,
  UnshieldedTokenPrivateState,
  Contract as CompactContract,
} from './generated/contract/index.js';

export const NETWORK_CONFIG = {
  indexer:   process.env.INDEXER_URL  ?? 'http://localhost:8088/api/v1/graphql',
  prover:    process.env.PROVER_URL   ?? 'http://localhost:6300',
  node:      process.env.NODE_URL     ?? 'http://localhost:9944',
  networkId: (process.env.NETWORK_ID  ?? 'undeployed') as 'testnet' | 'undeployed',
};

export async function buildWallet(seed: string) {
  const wallet = await WalletBuilder.buildFromSeed(
    seed,
    new NodeZkConfigProvider(NETWORK_CONFIG.prover),
    indexerPublicDataProvider(NETWORK_CONFIG.indexer),
    NETWORK_CONFIG.node,
    NETWORK_CONFIG.networkId
  );
  await wallet.start();
  return wallet;
}

export async function deployTokenContract(
  wallet: any,
  name: string,
  symbol: string,
  decimals = 6
): Promise<{ contract: UnshieldedTokenContract; address: ContractAddress }> {
  const encodeStr = (s: string, len: number) =>
    Buffer.from(s.padEnd(len, '\0').slice(0, len));

  const contract = new CompactContract.UnshieldedToken(
    encodeStr(name, 32),
    encodeStr(symbol, 8),
    BigInt(decimals)
  );

  const deployTx = await contract.deploy(wallet, {});
  await deployTx.submit();

  console.log(`Token contract deployed at: ${deployTx.contractAddress}`);
  return { contract, address: deployTx.contractAddress };
}

export async function mintTokens(
  contract: UnshieldedTokenContract,
  recipientAddress: string,
  amount: bigint
): Promise<string> {
  const tx = await contract.callTx.mintUnshielded(
    Buffer.from(recipientAddress, 'hex'), amount
  );
  const result = await tx.submit();
  return result.txHash;
}

export async function transferTokens(
  contract: UnshieldedTokenContract,
  recipientAddress: string,
  amount: bigint
): Promise<string> {
  const tx = await contract.callTx.sendUnshielded(
    Buffer.from(recipientAddress, 'hex'), amount
  );
  const result = await tx.submit();
  return result.txHash;
}

export async function getBalance(
  contract: UnshieldedTokenContract,
  address: string
): Promise<bigint> {
  const state = await contract.queryState();
  return state.balances.get(Buffer.from(address, 'hex').toString('hex')) ?? 0n;
}
Enter fullscreen mode Exit fullscreen mode

Part 3: React Frontend

Project Setup

npm create vite@latest midnight-token-ui -- --template react-ts
cd midnight-token-ui
npm install \
  @midnight-ntwrk/midnight-js-wallet \
  @midnight-ntwrk/midnight-js-types \
  @midnight-ntwrk/dapp-connector-api
Enter fullscreen mode Exit fullscreen mode

Wallet Context (src/context/WalletContext.tsx)

import React, { createContext, useContext, useState, useCallback } from 'react';

interface WalletContextType {
  address: string | null;
  connected: boolean;
  connecting: boolean;
  connect: () => Promise<void>;
  disconnect: () => void;
}

const WalletContext = createContext<WalletContextType | null>(null);

export function WalletProvider({ children }: { children: React.ReactNode }) {
  const [address,    setAddress]    = useState<string | null>(null);
  const [connected,  setConnected]  = useState(false);
  const [connecting, setConnecting] = useState(false);

  const connect = useCallback(async () => {
    setConnecting(true);
    try {
      const connector = (window as any).midnight?.mnLace;
      if (!connector) {
        alert('Please install the Midnight Lace wallet extension');
        return;
      }
      const api  = await connector.enable();
      const addr = await api.getAddress();
      setAddress(addr);
      setConnected(true);
    } catch (err) {
      console.error('Wallet connection failed:', err);
    } finally {
      setConnecting(false);
    }
  }, []);

  const disconnect = useCallback(() => {
    setAddress(null);
    setConnected(false);
  }, []);

  return (
    <WalletContext.Provider value={{ address, connected, connecting, connect, disconnect }}>
      {children}
    </WalletContext.Provider>
  );
}

export const useWallet = () => {
  const ctx = useContext(WalletContext);
  if (!ctx) throw new Error('useWallet must be used inside WalletProvider');
  return ctx;
};
Enter fullscreen mode Exit fullscreen mode

Main App Component (src/App.tsx)

import { useState } from 'react';
import { WalletProvider, useWallet } from './context/WalletContext';
import './App.css';

function TokenDashboard() {
  const { address, connected, connecting, connect, disconnect } = useWallet();
  const [balance,  setBalance]  = useState('0');
  const [mintTo,   setMintTo]   = useState('');
  const [mintAmt,  setMintAmt]  = useState('');
  const [sendTo,   setSendTo]   = useState('');
  const [sendAmt,  setSendAmt]  = useState('');
  const [loading,  setLoading]  = useState(false);
  const [txHash,   setTxHash]   = useState<string | null>(null);

  const handleMint = async () => {
    setLoading(true);
    try {
      const res = await fetch('/api/mint', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ recipient: mintTo, amount: mintAmt }),
      });
      const data = await res.json();
      setTxHash(data.txHash);
    } finally { setLoading(false); }
  };

  const handleTransfer = async () => {
    setLoading(true);
    try {
      const res = await fetch('/api/transfer', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ from: address, recipient: sendTo, amount: sendAmt }),
      });
      const data = await res.json();
      setTxHash(data.txHash);
    } finally { setLoading(false); }
  };

  return (
    <div className="app">
      <header>
        <h1>Midnight Token</h1>
        <p>Unshielded Token dApp</p>
        {connected ? (
          <div>
            <span>{address?.slice(0,8)}{address?.slice(-6)}</span>
            <button onClick={disconnect}>Disconnect</button>
          </div>
        ) : (
          <button onClick={connect} disabled={connecting}>
            {connecting ? 'Connecting…' : 'Connect Wallet'}
          </button>
        )}
      </header>

      {connected && (
        <main>
          <section>
            <h2>Your Balance: {balance} MTK</h2>
          </section>

          <section>
            <h2>Mint Tokens</h2>
            <p>Only the contract owner can mint.</p>
            <input placeholder="Recipient address (hex)" value={mintTo} onChange={e => setMintTo(e.target.value)} />
            <input type="number" placeholder="Amount" value={mintAmt} onChange={e => setMintAmt(e.target.value)} />
            <button disabled={loading || !mintTo || !mintAmt} onClick={handleMint}>
              {loading ? 'Submitting…' : 'Mint'}
            </button>
          </section>

          <section>
            <h2>Transfer Tokens</h2>
            <input placeholder="Recipient address (hex)" value={sendTo} onChange={e => setSendTo(e.target.value)} />
            <input type="number" placeholder="Amount" value={sendAmt} onChange={e => setSendAmt(e.target.value)} />
            <button disabled={loading || !sendTo || !sendAmt} onClick={handleTransfer}>
              {loading ? 'Submitting…' : 'Transfer'}
            </button>
          </section>

          {txHash && (
            <div>
              <label>Last Transaction</label>
              <code>{txHash}</code>
            </div>
          )}
        </main>
      )}
    </div>
  );
}

export default function App() {
  return (
    <WalletProvider>
      <TokenDashboard />
    </WalletProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Part 4: Running Against the Local Docker Stack

# Pull and start the local stack
docker compose -f docker-compose.yml up -d

# Services started:
# - Midnight node:     localhost:9944
# - Indexer:           localhost:8088
# - Proof server:      localhost:6300
# - Block explorer:    localhost:3000
Enter fullscreen mode Exit fullscreen mode

Set environment variables:

INDEXER_URL=http://localhost:8088/api/v1/graphql
PROVER_URL=http://localhost:6300
NODE_URL=http://localhost:9944
NETWORK_ID=undeployed
WALLET_SEED=your_test_seed_phrase_here
Enter fullscreen mode Exit fullscreen mode

Deploy and run:

npx compact-compiler contracts/unshielded_token.compact --output src/generated/contract
npx ts-node src/deploy.ts
npm run dev
Enter fullscreen mode Exit fullscreen mode

When to Use Unshielded vs Shielded Tokens

Use unshielded when:

  • Transfer amounts are deliberately public (governance votes, public grants)
  • You're building a demo or prototype
  • Lower fees and faster proof times matter
  • Regulatory requirements mandate auditability

Use shielded when:

  • Financial privacy is the core value proposition
  • Handling payments between individuals
  • Token amounts would reveal sensitive business information

You can mix both in the same application—unshielded for governance, shielded for payments.


Summary

We built a complete unshielded token dApp on Midnight:

  1. Compact contract with mintUnshielded, sendUnshielded, and receiveUnshielded circuits storing public balances in ledger state
  2. TypeScript integration using @midnight-ntwrk/midnight-js-* packages for deployment, minting, and transfers
  3. React frontend with Lace wallet connection, live balance display, mint panel, and transfer panel
  4. Local Docker stack setup for testnet-free development

Join the Midnight community on Discord and Forum for questions and support.

Top comments (0)