Building an Unshielded Token dApp on Midnight with React
Most Midnight tutorials focus on zero-knowledge proofs and private state. That's the headline feature, and it earns the attention. But Midnight also supports unshielded tokens — tokens with publicly visible balances and transactions — and building a dApp around them is a lot simpler than the private version. No ZK circuits, no proof generation overhead, and the full state is readable from the indexer.
This tutorial builds a working unshielded token dApp with a React frontend. You'll mint tokens, transfer them between addresses, and display balances — all using the Midnight TypeScript SDK and the DApp Connector.
By the end you'll have a working UI and a clear picture of when unshielded tokens are the right tool versus when you'd reach for shielded ones instead.
Unshielded vs. Shielded: The Tradeoff
Before writing any code, it's worth being clear on what you're choosing.
Unshielded tokens exist as transparent UTXOs on the Midnight ledger. Anyone can see the sender, recipient, amount, and token type. The trade-off: they're simpler to work with, faster to generate proofs for (there are no private witness computations), and easier to audit.
Shielded tokens use zero-knowledge proofs to hide sender, amount, and sometimes recipient from the public ledger. The trade-off: more complexity, proof generation time on the client, and larger transactions.
Midnight's hybrid architecture — one of its core design choices — lets you pick per-transaction, not per-account. A single wallet can hold both types simultaneously.
When unshielded makes sense:
- Publicly auditable assets: Corporate treasury tokens, regulated securities, NFT-style ownership records where transparency is a feature, not a bug
- High-throughput applications: Unshielded transactions don't require ZK proof generation, so they're faster for user-facing apps where latency matters
- Interoperability with off-chain systems: When your contract state needs to be indexable and queryable without special decryption
When to use shielded instead:
- Financial privacy: Users sending each other money don't want the world to see amounts
- Sealed bid auctions: Where bid amounts must stay hidden until reveal
- Healthcare or identity data: When the token carries sensitive metadata
For this tutorial, we're building something meant to be transparent: a public token registry where minting and transfers are fully visible.
Project Setup
Start with the Midnight scaffold:
npx create-mn-app unshielded-token-dapp
cd unshielded-token-dapp
npm run setup
This sets up the project structure with a Compact contract, TypeScript service layer, and a wallet integration skeleton. You'll need Node.js 22+, the Compact compiler, and Docker Desktop running for the local node.
Install the frontend dependencies:
npm install react react-dom @midnight-ntwrk/dapp-connector-api
npm install --save-dev @types/react @types/react-dom vite @vitejs/plugin-react
The key SDK package for unshielded token operations is @midnight-ntwrk/ledger. Make sure it's in your dependencies.
The Contract
The Compact contract manages token balances and authorization for minting:
import CompactStandardLibrary;
// Token state
export ledger balances: Map<Bytes<32>, Uint<64>>;
export ledger total_supply: Uint<64>;
export ledger minter: Bytes<32>;
export ledger token_name: Bytes<32>;
export constructor(
initial_minter: Bytes<32>,
name: Bytes<32>
): [] {
minter = initial_minter;
token_name = name;
total_supply = 0 as Uint<64>;
}
export circuit mint(recipient: Bytes<32>, amount: Uint<64>): [] {
assert own_public_key() == minter "Only minter can mint";
balances[recipient] = balances[recipient] + amount;
total_supply = total_supply + amount;
}
export circuit transfer(recipient: Bytes<32>, amount: Uint<64>): [] {
const sender = own_public_key();
assert balances[sender] >= amount "Insufficient balance";
balances[sender] = balances[sender] - amount;
balances[recipient] = balances[recipient] + amount;
}
export circuit balance_of(holder: Bytes<32>): Uint<64> {
return balances[holder];
}
The contract is deliberately minimal. The own_public_key() built-in returns the verifiable public key of the transaction submitter, so we can enforce that only the minter can call mint.
TypeScript Service Layer
The service layer wraps the contract calls and handles wallet interactions. Create src/token-service.ts:
import {
type MidnightProvider,
type WalletProvider,
mintUnshieldedToken,
sendUnshielded,
receiveUnshielded,
} from '@midnight-ntwrk/midnight-js-contracts';
import { type ContractAddress } from '@midnight-ntwrk/ledger';
import { Contract, type TokenContract } from '../midnight-managed/token/contract/index.cjs';
interface TokenProviders {
midnight: MidnightProvider;
wallet: WalletProvider;
}
export class TokenService {
private readonly contract: TokenContract;
private readonly providers: TokenProviders;
constructor(
providers: TokenProviders,
private readonly contractAddress: ContractAddress
) {
this.providers = providers;
this.contract = new Contract(providers, contractAddress);
}
async mint(recipientAddress: string, amount: bigint): Promise<string> {
const result = await mintUnshieldedToken(
this.providers,
this.contractAddress,
recipientAddress,
amount
);
return result.txHash;
}
async transfer(recipientAddress: string, amount: bigint): Promise<string> {
const result = await sendUnshielded(
this.providers,
this.contractAddress,
recipientAddress,
amount
);
return result.txHash;
}
async receive(txHash: string): Promise<void> {
await receiveUnshielded(
this.providers,
txHash
);
}
async getBalance(holderAddress: string): Promise<bigint> {
const state = await this.contract.queryState();
return state.balances.get(holderAddress) ?? 0n;
}
async getAddress(): Promise<string> {
return this.providers.wallet.unshieldedAddress();
}
}
The three core SDK functions handle the heavy lifting:
-
mintUnshieldedToken— submits a mint transaction. The token appears in the recipient's unshielded UTXO set. The transaction is fully public. -
sendUnshielded— transfers tokens from the caller's address to a recipient. Constructs the unshielded coin outputs and submits the transaction. -
receiveUnshielded— processes an incoming unshielded transfer. Scans the transaction output for UTXOs addressed to the current wallet and adds them to the wallet's tracked balance.
The receiveUnshielded call is worth explaining. Unlike in account-based systems where a transfer immediately updates your balance, Midnight uses UTXOs. The receiving wallet needs to explicitly scan and claim incoming UTXOs to have them counted in its local state. Typically you'd call this after seeing a new block that contains a transfer to you.
Wallet Connection
Create src/wallet-connector.ts:
import {
type ServiceUriConfig,
type DAppConnectorWalletAPI,
type DAppConnectorAPI,
} from '@midnight-ntwrk/dapp-connector-api';
const SERVICE_CONFIG: ServiceUriConfig = {
proverServerUri: 'http://localhost:6300',
indexerUri: 'http://localhost:8088/api/v1/graphql',
indexerWsUri: 'ws://localhost:8088/api/v1/graphql',
node: 'http://localhost:9944',
};
export async function connectWallet(): Promise<DAppConnectorWalletAPI> {
const midnight = (window as any).midnight;
if (!midnight) {
throw new Error('Midnight wallet extension not found. Install it from the browser store.');
}
// Try Lace first (the primary Midnight wallet)
const connector: DAppConnectorAPI = midnight.lace ?? midnight.mnLite;
if (!connector) {
throw new Error('No compatible Midnight wallet found.');
}
const isEnabled = await connector.isEnabled();
if (!isEnabled) {
await connector.enable({ networkId: 'testnet-02' });
}
const walletApi = await connector.apiVersion('1.0');
return walletApi;
}
export async function buildProviders(walletApi: DAppConnectorWalletAPI): Promise<{
midnight: any;
wallet: any;
}> {
const state = await walletApi.state();
return {
midnight: {
submitTx: walletApi.submitTx.bind(walletApi),
proverUri: SERVICE_CONFIG.proverServerUri,
},
wallet: {
coinPublicKey: () => state.coinPublicKey,
encryptionPublicKey: () => state.encryptionPublicKey,
unshieldedAddress: () => state.unshieldedAddress,
sign: walletApi.sign.bind(walletApi),
},
};
}
React Frontend
The frontend has three panels: wallet connection, mint (minter only), and transfer. Create src/App.tsx:
import React, { useState, useEffect, useCallback } from 'react';
import { connectWallet, buildProviders } from './wallet-connector';
import { TokenService } from './token-service';
import type { DAppConnectorWalletAPI } from '@midnight-ntwrk/dapp-connector-api';
const CONTRACT_ADDRESS = process.env.VITE_CONTRACT_ADDRESS ?? '';
interface AppState {
connected: boolean;
address: string | null;
balance: bigint;
isMinter: boolean;
walletApi: DAppConnectorWalletAPI | null;
service: TokenService | null;
}
export function App() {
const [state, setState] = useState<AppState>({
connected: false,
address: null,
balance: 0n,
isMinter: false,
walletApi: null,
service: null,
});
const [status, setStatus] = useState<string>('');
const [mintRecipient, setMintRecipient] = useState('');
const [mintAmount, setMintAmount] = useState('');
const [transferRecipient, setTransferRecipient] = useState('');
const [transferAmount, setTransferAmount] = useState('');
const connect = useCallback(async () => {
try {
setStatus('Connecting wallet...');
const walletApi = await connectWallet();
const providers = await buildProviders(walletApi);
const service = new TokenService(providers, CONTRACT_ADDRESS);
const address = await service.getAddress();
const balance = await service.getBalance(address);
setState({
connected: true,
address,
balance,
isMinter: false, // check against contract minter field
walletApi,
service,
});
setStatus('Connected');
} catch (err) {
setStatus(`Connection failed: ${(err as Error).message}`);
}
}, []);
const refreshBalance = useCallback(async () => {
if (!state.service || !state.address) return;
const balance = await state.service.getBalance(state.address);
setState(prev => ({ ...prev, balance }));
}, [state.service, state.address]);
const handleMint = useCallback(async () => {
if (!state.service) return;
try {
setStatus('Minting...');
const txHash = await state.service.mint(
mintRecipient,
BigInt(mintAmount)
);
setStatus(`Minted. Tx: ${txHash.slice(0, 12)}...`);
await refreshBalance();
} catch (err) {
setStatus(`Mint failed: ${(err as Error).message}`);
}
}, [state.service, mintRecipient, mintAmount, refreshBalance]);
const handleTransfer = useCallback(async () => {
if (!state.service) return;
try {
setStatus('Transferring...');
const txHash = await state.service.transfer(
transferRecipient,
BigInt(transferAmount)
);
setStatus(`Transferred. Tx: ${txHash.slice(0, 12)}...`);
await refreshBalance();
} catch (err) {
setStatus(`Transfer failed: ${(err as Error).message}`);
}
}, [state.service, transferRecipient, transferAmount, refreshBalance]);
if (!state.connected) {
return (
<div style={styles.container}>
<h1>Unshielded Token dApp</h1>
<button style={styles.button} onClick={connect}>
Connect Wallet
</button>
{status && <p>{status}</p>}
</div>
);
}
return (
<div style={styles.container}>
<h1>Unshielded Token dApp</h1>
<section style={styles.card}>
<h2>Wallet</h2>
<p><strong>Address:</strong> {state.address?.slice(0, 20)}...</p>
<p><strong>Balance:</strong> {state.balance.toString()} tokens</p>
<button style={styles.buttonSmall} onClick={refreshBalance}>
Refresh Balance
</button>
</section>
{state.isMinter && (
<section style={styles.card}>
<h2>Mint Tokens</h2>
<input
style={styles.input}
placeholder="Recipient address"
value={mintRecipient}
onChange={e => setMintRecipient(e.target.value)}
/>
<input
style={styles.input}
placeholder="Amount"
type="number"
value={mintAmount}
onChange={e => setMintAmount(e.target.value)}
/>
<button style={styles.button} onClick={handleMint}>
Mint
</button>
</section>
)}
<section style={styles.card}>
<h2>Transfer</h2>
<input
style={styles.input}
placeholder="Recipient address"
value={transferRecipient}
onChange={e => setTransferRecipient(e.target.value)}
/>
<input
style={styles.input}
placeholder="Amount"
type="number"
value={transferAmount}
onChange={e => setTransferAmount(e.target.value)}
/>
<button style={styles.button} onClick={handleTransfer}>
Transfer
</button>
</section>
{status && <p style={styles.status}>{status}</p>}
</div>
);
}
const styles = {
container: {
maxWidth: '640px',
margin: '0 auto',
padding: '24px',
fontFamily: 'system-ui, sans-serif',
},
card: {
border: '1px solid #e1e4e8',
borderRadius: '8px',
padding: '16px',
marginBottom: '16px',
},
input: {
display: 'block',
width: '100%',
padding: '8px',
marginBottom: '8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px',
boxSizing: 'border-box' as const,
},
button: {
background: '#0070f3',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
},
buttonSmall: {
background: '#6b7280',
color: 'white',
border: 'none',
padding: '6px 12px',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
},
status: {
color: '#6b7280',
fontSize: '13px',
marginTop: '8px',
},
};
Handling Incoming Transfers
When someone sends tokens to your address, you need to call receiveUnshielded to scan the transaction and register the incoming UTXOs. A clean pattern is to poll for new blocks and check for incoming transfers:
import { subscribeToBlockHeight } from './indexer';
export function startReceiveWatcher(service: TokenService, txHashes: string[]) {
const seen = new Set<string>();
const unsubscribe = subscribeToBlockHeight(async () => {
for (const txHash of txHashes) {
if (!seen.has(txHash)) {
try {
await service.receive(txHash);
seen.add(txHash);
} catch {
// not yet confirmed or not addressed to us
}
}
}
});
return unsubscribe;
}
In a real application, you'd get the list of pending tx hashes from a backend or from an indexer subscription that filters for your address. The unshielded nature of the tokens helps here: the indexer can tell you about incoming transfers without any decryption, which is a meaningful convenience compared to shielded tokens where the recipient has to scan every transaction with their viewing key.
Deploying the Contract
Once you're ready to deploy to Midnight's testnet:
npx ts-node src/deploy.ts
The output includes the contract address. Set it in your environment:
echo "VITE_CONTRACT_ADDRESS=<your-contract-address>" > .env
npm run build
The deploy script uses create-mn-app's scaffolding to connect to the node, compile the Compact contract to ZK circuits, and submit the deployment transaction. The contract address is deterministic based on the initial state and the deployer's key.
What's Fully Visible On-Chain
Since you're using unshielded tokens, this is worth stating explicitly: everything is public.
| Field | Visible? |
|---|---|
| Sender address | Yes |
| Recipient address | Yes |
| Amount | Yes |
| Token type | Yes |
| Balance of any address | Yes |
| Transaction history | Yes |
The Midnight indexer can serve all of this without authentication. If you query balances[address] from your contract state, anyone with the indexer URL can do the same.
For the use case this tutorial builds — a public token registry — that's the intended behavior. If your use case requires hiding any of these fields, you need shielded tokens and ZK proofs for the private data. Midnight's architecture supports both in the same wallet; the switch is in how you structure your contract and which SDK functions you call.
Running Locally
Start the local Midnight node and indexer (Docker required):
docker compose up -d
npm run setup
npm run dev
Navigate to http://localhost:5173. Connect your Midnight wallet extension, switch it to the local network (http://localhost:9944), and you should see your wallet address and a zero balance.
Call the mint function from the CLI first to give your address some tokens:
npx ts-node src/cli.ts mint <your-address> 1000
Refresh the balance in the UI — it should now show 1000.
Transfer works immediately from the UI. After clicking Transfer, the balance refreshes automatically via the refreshBalance call in handleTransfer. The receiveUnshielded call in the watcher updates the recipient's local state once the transaction confirms.
Next Steps
From here you can extend this into a full token management dashboard — adding a transaction history panel (pull from the indexer), a token info display (name, total supply, minter address), and multi-token support if your contract manages more than one token type.
The unshielded token path is the fastest way to get a functional Midnight dApp running. Once you've built it, the path to adding private state is clear: swap the unshielded contract calls for their shielded counterparts, add the ZK witness generation, and decide which fields belong in sealed ledger state. The SDK makes those boundaries explicit, which makes the migration mechanical rather than mysterious.
Top comments (0)