DEV Community

Syv
Syv

Posted on • Edited on

[Tutorial] Building an Unshielded Token DApp with UI on Midnight network

📁 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.json with 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

Wallet connection UI

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Note: You have to cast amount to Uint<64> when updating totalSupply

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))
    );
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then compile

compact compile contracts/Contract.compact contracts/managed/stablecoin
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Smart contract operations: mint tokens into the vault, send tokens from the vault to an address, and deposit tokens into the vault.
  2. Statistics: total supply, smart contract (vault) balance, wallet balance.
  3. User wallet: basic operations such as transferring from your own wallet to another wallet and displaying your receiving address and balance.

Token operations dashboard


1. Smart contract operations

Set up the smart contract providers

  • privateStateProvider: uses levelPrivateStateProvider for persistent localStorage
  • publicDataProvider: reads on-chain state from the indexer
  • zkConfigProvider: loads FetchZkConfigProvider — compiled verifiers...
  • proofProvider: generates zero-knowledge proofs on your proof server
  • walletProvider: handles balanceTx via connectedApi.balanceUnsealedTransaction
  • midnightProvider: submits transactions via connectedApi.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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

2. Build providers

  • privateStateProvider: uses levelPrivateStateProvider for persistent localStorage
  • publicDataProvider: reads on-chain state from the indexer
  • zkConfigProvider: loads FetchZkConfigProvider — compiled verifiers...
  • proofProvider: generates zero-knowledge proofs on your proof server
  • walletProvider: handles balanceTx via connectedApi.balanceUnsealedTransaction
  • midnightProvider: submits transactions via connectedApi.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];
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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: {},
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.

Mint transaction success

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
      }
    );
  };
Enter fullscreen mode Exit fullscreen mode

Send tokens from vault

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);
      }
    );
  };
Enter fullscreen mode Exit fullscreen mode

Note: Use getShieldedAddresses() because it retrieves both keys in one call. It returns shieldedAddress, shieldedCoinPublicKey, and shieldedEncryptionPublicKey.


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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

3. Return the values

return {
  totalSupply: ledgerState.totalSupply,
  totalBurned: ledgerState.totalBurned,
};
Enter fullscreen mode Exit fullscreen mode

3. Wallet operations

For displaying user receiving addresses and stablecoin balances, see section 2.

const unshieldedAddress = await connectedApi.getUnshieldedAddress();
const unshieldedBalances = await connectedApi.getUnshieldedBalances();
Enter fullscreen mode Exit fullscreen mode
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;
  };
Enter fullscreen mode Exit fullscreen mode

Wallet info and balance

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));
  };
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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

Wallet-to-wallet transfer

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

  1. Regulatory compliance: Stablecoin issuers typically need to demonstrate full traceability of supply and transfers due to AML (anti-money laundering) regulations.

  2. Verifiability: The vault demonstrates native mint functionality for this stablecoin. It contains a public state totalSupply that is publicly readable so regulators can monitor it.

  3. 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

  1. Private tokenized securities: Transfers are confidential while specific properties like voting rights remain verifiable.

  2. 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.

  3. 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)