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();
}
Compile the contract:
npx compact-compiler contracts/unshielded_token.compact \
--output src/generated/contract
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;
}
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
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;
};
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>
);
}
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
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
Deploy and run:
npx compact-compiler contracts/unshielded_token.compact --output src/generated/contract
npx ts-node src/deploy.ts
npm run dev
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:
-
Compact contract with
mintUnshielded,sendUnshielded, andreceiveUnshieldedcircuits storing public balances in ledger state -
TypeScript integration using
@midnight-ntwrk/midnight-js-*packages for deployment, minting, and transfers - React frontend with Lace wallet connection, live balance display, mint panel, and transfer panel
- Local Docker stack setup for testnet-free development
Join the Midnight community on Discord and Forum for questions and support.
Top comments (0)