📁 Full Source Code: midnight-apps/unshielded-token
Target audience: Developers
Prerequisites
- Node.js installed (v20+)
- A Midnight Wallet (e.g., 1AM or Lace)
- Some Preprod faucet NIGHT tokens
- A
package.jsonwith the needed packages@midnight-ntwrk/compact-runtime@midnight-ntwrk/dapp-connector-api@midnight-ntwrk/ledger-v8@midnight-ntwrk/midnight-js-contracts@midnight-ntwrk/midnight-js-dapp-connector-proof-provider@midnight-ntwrk/midnight-js-fetch-zk-config-provider@midnight-ntwrk/midnight-js-http-client-proof-provider@midnight-ntwrk/midnight-js-indexer-public-data-provider@midnight-ntwrk/midnight-js-level-private-state-provider@midnight-ntwrk/midnight-js-network-id@midnight-ntwrk/midnight-js-node-zk-config-provider@midnight-ntwrk/midnight-js-types@midnight-ntwrk/wallet-sdk-dust-wallet@midnight-ntwrk/wallet-sdk-facade@midnight-ntwrk/wallet-sdk-hd@midnight-ntwrk/wallet-sdk-shielded@midnight-ntwrk/wallet-sdk-unshielded-wallet-
@scure/bip39,react,react-dom,react-router-dom,semver,vite-plugin-node-polyfills,vite-plugin-top-level-await,vite-plugin-wasm,ws,zustand
Clone the dapp-connect project as a starting point. It includes wallet detection, connection, state polling, and the account modal — everything you need before adding smart contract operations.
git clone https://github.com/0xfdbu/midnight-apps.git
cd midnight-apps/dapp-connect
npm install
npm run dev
With the frontend ready to connect, the next step is the smart contract side. Here are three core circuits that handle the native mint for the unshielded token vault lifecycle:
Natively minting a stablecoin into the vault with mintUnshieldedToken
Use a padded string for the domain to define the token standard — in this case, "stablecoin:usd"
export circuit mintToContract(amount: Uint<64>): Bytes<32> {
const domain = pad(32, "stablecoin:usd");
const color = mintUnshieldedToken(
disclose(domain),
disclose(amount),
left<ContractAddress, UserAddress>(kernel.self())
);
totalSupply = totalSupply + disclose(amount) as Uint<64>;
return color;
}
Note: You have to cast
amounttoUint<64>when updatingtotalSupply
Transferring with sendUnshielded from vault
To move tokens, sendToUser requires you to reconstruct the color using the same domain and the smart contract's address (kernel.self())
export circuit sendToUser(amount: Uint<64>, userAddr: UserAddress): [] {
const domain = pad(32, "stablecoin:usd");
const color = tokenType(disclose(domain), kernel.self());
sendUnshielded(
color,
disclose(amount) as Uint<128>,
right<ContractAddress, UserAddress>(disclose(userAddr))
);
}
Depositing into vault with receiveUnshielded
For the receiveTokens circuit, you need to be careful with bit sizes. Unlike the mint function, receiveUnshielded strictly requires a Uint<128> for the amount
export circuit receiveTokens(amount: Uint<128>): [] {
const domain = pad(32, "stablecoin:usd");
const color = tokenType(disclose(domain), kernel.self());
receiveUnshielded(color, disclose(amount));
}
View the full smart contract code in Contract.compact on GitHub.
Compiling the smart contract
Now compile the smart contract so you can use its artifacts in the frontend (verifiers, provers, ZKIR...).
First, install the Compact dev tools
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh
Then compile
compact compile contracts/Contract.compact contracts/managed/stablecoin
Note: Skip this step if you want to clone the repo. If you generate new keys, you need to redeploy because the old keys in this path would no longer be usable by the frontend. A smart contract is already deployed on Preprod:
0c0ad6d96daa1b983751db2149a093c34ea73714c33fbad40d291d9e887f8084
If you do decide to recompile and redeploy, run:
MNEMONIC="24 secret seed phrase from Lace or 1AM" npx tsx scripts/go.ts
A simple approach to quickly deploy and save time of wallet syncing by using your existing wallet extension state, View Deploy.tsx (Highly recommended)
Disclaimer: This demonstration uses a smart contract where anybody can mint so do not use for production without proper authentication.
Frontend integration
Now that the smart contract is deployed on Preprod, the next step is to integrate it with the frontend. The features to cover are shown in the screenshot below:
- Smart contract operations: mint tokens into the vault, send tokens from the vault to an address, and deposit tokens into the vault.
- Statistics: total supply, smart contract (vault) balance, wallet balance.
- User wallet: basic operations such as transferring from your own wallet to another wallet and displaying your receiving address and balance.
1. Smart contract operations
Set up the smart contract providers
-
privateStateProvider: useslevelPrivateStateProviderfor persistent localStorage -
publicDataProvider: reads on-chain state from the indexer -
zkConfigProvider: loadsFetchZkConfigProvider— compiled verifiers... -
proofProvider: generates zero-knowledge proofs on your proof server -
walletProvider: handlesbalanceTxviaconnectedApi.balanceUnsealedTransaction -
midnightProvider: submits transactions viaconnectedApi.submitTransaction
Note: In this tutorial, the providers are rebuilt in every function. In a production environment, initialize them once and reuse them across all operations.
The function below covers the full lifecycle of minting into the vault smart contract. Call await mintToContract(BigInt(amount)) in the UI to execute it.
It runs through four stages inside a try/catch:
export async function mintToContract(
connectedApi: ConnectedAPI,
coinPublicKey: string,
shieldedAddresses: { shieldedEncryptionPublicKey: string },
amount: bigint,
onSuccess: (txId: string) => void,
onError: (err: string) => void
): Promise<void> {
try {
// stages below
} catch (err) {
console.error('[Mint] Error:', err);
onError(err instanceof Error ? err.message : String(err));
}
}
1. Load dependencies
Define mods by awaiting getModules(), which imports the compiled contract dependencies. These are cached on the first call.
const mods = await getModules();
const { indexerModule, FetchZkConfigProvider, levelModule, CompiledContract, ledger, proofModule } = mods;
2. Build providers
-
privateStateProvider: useslevelPrivateStateProviderfor persistent localStorage -
publicDataProvider: reads on-chain state from the indexer -
zkConfigProvider: loadsFetchZkConfigProvider— compiled verifiers... -
proofProvider: generates zero-knowledge proofs on your proof server -
walletProvider: handlesbalanceTxviaconnectedApi.balanceUnsealedTransaction -
midnightProvider: submits transactions viaconnectedApi.submitTransaction
const indexerPublicDataProvider = indexerModule.indexerPublicDataProvider;
const levelPrivateStateProvider = levelModule.levelPrivateStateProvider;
const zkConfigProvider = new FetchZkConfigProvider(window.location.origin + CONTRACT_PATH, fetch.bind(window));
const proofProvider = proofModule.httpClientProofProvider(PROOF_SERVER, zkConfigProvider);
const providers: any = {
privateStateProvider: levelPrivateStateProvider({
midnightDbName: 'midnight-stablecoin-db',
privateStateStoreName: STORE_NAME,
accountId: coinPublicKey,
privateStoragePasswordProvider: () => STORAGE_PASSWORD,
}),
publicDataProvider: indexerPublicDataProvider(INDEXER_HTTP, INDEXER_WS),
zkConfigProvider,
proofProvider,
walletProvider: {
getCoinPublicKey: () => coinPublicKey,
getEncryptionPublicKey: () => shieldedAddresses.shieldedEncryptionPublicKey,
async balanceTx(tx: any) {
const serialized = uint8ArrayToHex(tx.serialize());
const result = await connectedApi.balanceUnsealedTransaction(serialized);
const bytes = hexToUint8Array(result.tx);
return ledger.Transaction.deserialize('signature', 'proof', 'binding', bytes);
},
},
midnightProvider: {
submitTx: async (tx: any): Promise<string> => {
const serialized = uint8ArrayToHex(tx.serialize());
await connectedApi.submitTransaction(serialized);
return tx.identifiers()[0];
},
},
};
3. Connect to the contract
Import the contract module and attach it to the live instance on Preprod. callTx maps directly to your Compact circuits.
const [{ findDeployedContract }] = await Promise.all([
import('@midnight-ntwrk/midnight-js-contracts'),
]);
const contractModule = await import(CONTRACT_PATH + '/contract/index.js');
const compiledContract = CompiledContract.make('stablecoin', contractModule.Contract).pipe(
CompiledContract.withVacantWitnesses,
CompiledContract.withCompiledFileAssets(CONTRACT_PATH)
);
const contract: any = await findDeployedContract(providers, {
contractAddress: CONTRACT_ADDRESS,
compiledContract,
privateStateId: 'stablecoinState',
initialPrivateState: {},
});
4. Execute the mint
Generate the zero-knowledge proof, then submit the transaction and await the hash
const txData = await contract.callTx.mintToContract(amount);
onSuccess(txData.public.txId);
Note: The proof generation might take some time before the popup appears. This example uses a local proof server at port 6300, so it is fast.
Now that tokens are minted into the vault, the next step is to send them from the vault to an address.
First, handle how user addresses are encoded. The helper function parses a Bech32m string, decodes it to an unshielded address, and returns raw bytes because the sendToUser circuit expects a Bytes<32> field.
export async function encodeUserAddress(bech32Address: string): Promise<Uint8Array> {
const mods = await getModules();
const { addressModule } = mods;
const { MidnightBech32m, UnshieldedAddress } = addressModule;
try {
const parsed = MidnightBech32m.parse(bech32Address);
const decoded: any = parsed.decode(UnshieldedAddress, 'preprod');
return decoded.data;
} catch (e) {
console.error('[encodeUserAddress] Error:', e);
throw new Error('Invalid address format');
}
}
This function takes user input and runs it through encodeUserAddress(recipient). It then calls store.contractSend(params...), which invokes the sendToUser circuit containing sendUnshielded.
const handleSend = async () => {
if (!amount || !recipient || !connectedApi) return;
const recipientBytes = await encodeUserAddress(recipient);
const store = useWalletStore.getState();
const shieldedAddresses = await connectedApi.getShieldedAddresses();
const coinPublicKey = shieldedAddresses.shieldedCoinPublicKey;
await store.contractSend(
connectedApi,
coinPublicKey,
shieldedAddresses,
BigInt(amount),
recipientBytes,
(txId: string) => {
useWalletStore.getState().setTransactionHash(txId);
useWalletStore.getState().loadWalletState();
},
(errMsg: string) => {
useWalletStore.getState().setError(errMsg);
}
);
};
Now deposit the stablecoin token into the vault using receiveUnshielded.
The frontend has handleReceive. It functions similarly to handleSend: store.receiveTokens(params...) calls the exported receiveTokens(amount: Uint<128>) circuit, which contains receiveUnshielded(color, disclose(amount)).
const handleReceive = async () => {
if (!amount || !connectedApi) return;
const store = useWalletStore.getState();
const shieldedAddresses = await connectedApi.getShieldedAddresses();
const coinPublicKey = shieldedAddresses.shieldedCoinPublicKey;
await store.receiveTokens(
connectedApi,
coinPublicKey,
shieldedAddresses,
BigInt(amount),
(txId: string) => {
useWalletStore.getState().setTransactionHash(txId);
useWalletStore.getState().loadWalletState();
},
(errMsg: string) => {
useWalletStore.getState().setError(errMsg);
}
);
};
Note: Use
getShieldedAddresses()because it retrieves both keys in one call. It returnsshieldedAddress,shieldedCoinPublicKey, andshieldedEncryptionPublicKey.
2. Statistics
The vault smart contract has a state called balance, which returns a set of token balances. The approach here is to iterate through the balances array to find how many tokens match the token ID. For a token ID to appear, you need to execute a mint operation.
export async function getContractBalance(): Promise<bigint> {
try {
const mods = await getModules();
const { indexerModule } = mods;
const indexerPublicDataProvider = indexerModule.indexerPublicDataProvider;
const provider = indexerPublicDataProvider(INDEXER_HTTP, INDEXER_WS);
const contractState = await provider.queryContractState(CONTRACT_ADDRESS);
console.log('[getContractBalance] Contract state balance:', contractState?.balance);
if (!contractState?.balance) return 0n;
for (const [key, value] of contractState.balance.entries()) {
console.log('[getContractBalance] Key:', key, 'Value:', value.toString());
if (key && typeof key === 'object' && 'raw' in key && key.raw === STABLECOIN_TOKEN) {
console.log('[getContractBalance] Found balance:', value.toString());
return value;
}
}
return 0n;
} catch (err) {
console.error('[getContractBalance] Error:', err);
return 0n;
}
}
Now get the user's stablecoin balance. First call connectedApi.getUnshieldedBalances() to get all user wallet balances, then filter the results with balances[STABLECOIN_TOKEN].
export async function getUserStablecoinBalance(connectedApi: ConnectedAPI): Promise<bigint> {
try {
const balances = await connectedApi.getUnshieldedBalances();
const stablecoinBalance = balances[STABLECOIN_TOKEN];
return stablecoinBalance || 0n;
} catch (err) {
console.error('[getUserStablecoinBalance] Error:', err);
return 0n;
}
}
To retrieve totalSupply, create a function getContractState(). It works in three stages:
export async function getContractState(): Promise<ContractState> {
try {
// stages below
} catch (err) {
console.error('[ContractState] Error:', err);
return { totalSupply: 0n, totalBurned: 0n };
}
}
1. Query the indexer
Fetch the raw contract state from the Preprod indexer.
const mods = await getModules();
const { indexerModule } = mods;
const indexerPublicDataProvider = indexerModule.indexerPublicDataProvider;
const provider = indexerPublicDataProvider(INDEXER_HTTP, INDEXER_WS);
const contractState = await provider.queryContractState(CONTRACT_ADDRESS);
if (!contractState) {
return { totalSupply: 0n, totalBurned: 0n };
}
2. Deserialize into typed ledger state
The indexer returns raw bytes. Import the contract module and pass the raw data through ledger() to get typed fields like totalSupply and totalBurned.
const contractModule = await import(CONTRACT_PATH + '/contract/index.js');
const ledgerState = contractModule.ledger(contractState.data);
3. Return the values
return {
totalSupply: ledgerState.totalSupply,
totalBurned: ledgerState.totalBurned,
};
3. Wallet operations
For displaying user receiving addresses and stablecoin balances, see section 2.
const unshieldedAddress = await connectedApi.getUnshieldedAddress();
const unshieldedBalances = await connectedApi.getUnshieldedBalances();
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useWalletStore } from '../hooks/useWallet';
import { getUserStablecoinBalance } from '../hooks/wallet/services/contractCalls';
export function WalletInfoPage() {
const { connectedApi, addresses } = useWalletStore();
const [balance, setBalance] = useState<bigint | null>(null);
const [copied, setCopied] = useState<string | null>(null);
useEffect(() => {
if (!connectedApi) return;
const fetchBalance = async () => {
const bal = await getUserStablecoinBalance(connectedApi);
setBalance(bal);
};
fetchBalance();
const interval = setInterval(fetchBalance, 15000);
return () => clearInterval(interval);
}, [connectedApi]);
const handleCopy = async (text: string, field: string) => {
await navigator.clipboard.writeText(text);
setCopied(field);
setTimeout(() => setCopied(null), 2000);
};
const formatBalance = (bal: bigint | null): string => {
if (bal === null) return '—';
return bal.toLocaleString();
};
const formatAddress = (addr: string): string => {
if (!addr) return '—';
return addr.length > 24 ? `${addr.slice(0, 12)}...${addr.slice(-12)}` : addr;
};
Next, send the stablecoin token between user wallets. The handleSend function — different from contractSend — looks like this:
const handleSend = async () => {
if (!amount || !recipient) return;
await sendStablecoin(recipient, BigInt(amount));
};
sendStablecoin wraps connectedApi.makeTransfer. Instead of sending nativeToken, it passes the stablecoin token ID as the type, so the wallet knows which asset to transfer. The makeTransfer call below is what actually happens inside sendStablecoin: it constructs the output we need, balances the transaction, which is then submitted with connectedApi.submitTransaction(result.tx).
export async function sendStablecoin(
connectedApi: ConnectedAPI,
recipient: string,
amount: bigint,
onSuccess: () => void,
onError: (err: string) => void
): Promise<void> {
try {
const desiredOutput: DesiredOutput = {
kind: 'unshielded',
type: STABLECOIN_TOKEN,
value: amount,
recipient,
};
const result = await connectedApi.makeTransfer([desiredOutput]);
if (result.tx) {
// wallet returned an unsigned tx — submit it
await connectedApi.submitTransaction(result.tx);
}
onSuccess();
} catch (err) {
if ((err as any)?.type === 'DAppConnectorAPIError' && (err as any)?.code === 'Disconnected') {
throw err;
}
onError(handleWalletError(err));
}
}
Key differences from contractSend
contractSend |
makeTransfer |
|
|---|---|---|
| Funds source | Vault funds | User funds |
| Mechanism | handleSend |
DApp connector makeTransfer
|
| Address encoding | Requires encoding → Bytes<32>
|
Passes Bech32m directly |
| ZK proofs | Required for circuit execution | Handled by wallet |
When to use unshielded vs shielded tokens and the privacy trade-offs
| Unshielded | Shielded | |
|---|---|---|
| Privacy mechanism | None — completely transparent blockchain transactions | Zero-knowledge proofs (Zswap) |
| Legal Compliance | Can be audited for AML | Requires keys for selective disclosure |
| Use cases | Compliant stablecoins... as required by regulators | Confidential transfers... |
Why choose unshielded for the stablecoin
Regulatory compliance: Stablecoin issuers typically need to demonstrate full traceability of supply and transfers due to AML (anti-money laundering) regulations.
Verifiability: The vault demonstrates native mint functionality for this stablecoin. It contains a public state
totalSupplythat is publicly readable so regulators can monitor it.Exchange listings: Many exchanges have delisted privacy coins due to regulatory pressure, while unshielded tokens such as NIGHT have been listed because they offer full transparency.
When to choose shielded over unshielded
Private tokenized securities: Transfers are confidential while specific properties like voting rights remain verifiable.
Regulated industries requiring data minimization: In healthcare, frameworks like GDPR, CCPA, and HIPAA require minimal data disclosure. Shielded tokens ensure sensitive information stays in local storage while zero-knowledge proofs can still confirm eligibility and compliance.
Forward secrecy: Even if encryption keys are compromised in the future, shielded transactions remain private. This is something unshielded transactions cannot offer.
Conclusion
Midnight's multi-modal design is different from other networks that enforce a single model. You are not forced into shielded transactions only, like XMR, or fully transparent ones, like Bitcoin. Instead, you can use whatever fits your use case at the circuit level.
Next steps
Now that you have finished this tutorial, here are a few things you can do next:
- Check the full repository source code on GitHub
- Read the Midnight Compact language docs
- Add authentication / whitelist for mint
Troubleshooting
- "Wallet not detected" → Make sure 1AM or Lace browser extensions are installed.
- Transactions failing → Make sure you have tDUST and that the wallet is fully synced.







Top comments (0)