Fetching latest headlines…
[Tutorial] Building an Unshielded Token dApp with UI
NORTH AMERICA
🇺🇸 United StatesApril 18, 2026

[Tutorial] Building an Unshielded Token dApp with UI

3 views0 likes0 comments
Originally published byDev.to

Building an unshielded token DApp with a working frontend.

📁 Full Source Code: midnight-apps/unshielded-token

We will keep it simple and use React VITE Ts as it is compatible with most of midnight packages, so the first thing we do is setup a basic wallet connection and for this we will also make sure to use the latest version of Dapp Connector V4

Be sure to have a package.json that's compatible

You can follow this official midnight network wallet connection guide on how to setup a basic wallet connection for reference check useWallet.ts how i handled the edge cases (I'll do a deep dive on wallet in a later post)

With the frontend ready to connect, i had some work to do on the contract side. Here are three core circuits i used to handle the native mint for the unshielded token vault lifecycle.

Natively Minting a Stablecoin into the vault with mintUnshieldedToken

We 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 : We have to cast amount Uint<64> when updating totalSupply

Transferring with sendUnshielded from vault

To move tokens, sendTouser requires us to reconstruct the color using the same domain and 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 receiveTokens circuit you need to be careful with bit sizes unlike the mint function receiveUnshielded strictly requires a Uint<128> for amount

export circuit receiveTokens(amount: Uint<128>): [] {
    const domain = pad(32, "stablecoin:usd");
    const color = tokenType(disclose(domain), kernel.self());
    receiveUnshielded(color, disclose(amount));
}

See full contract code Contract.compact

Compiling the Contract

Now we will have to compile the contract so we can use its artifacts in the frontend [Verifier/Prover, ZKir..].

First install 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 my repo because if you generate new keys you would need to redeploy again, as old keys in this path would no longer be usable for frontend unless you download them from github again (In case you wanted to use my contract) i have already deployed my own contract on Prepod network db5d7cb3ed5ab23217abedb86831f6f5b23a9179e91e48dab88d819ef41b8e6d

If you do decide to recompile and redeploy run:

MNEMONIC="24 secret seed phrase from lace or 1am" npx tsx scripts/go.ts

Frontend Integration

So now we have a ready contract deployed on Prepod, the next steps is to integrate to the frontend and as you can see in the screenshot the features we want to cover are:

  1. Contract operations : Mint Tokens into vault, Send tokens from vault to an address, Deposit tokens into the vault.
  2. Statistics : Total Supply, Contract (Vault) Balance, Wallet Balance
  3. User Wallet : Basic OP such as doing basic transfer from your own wallet to another wallet and displaying your receiving address and balance.

1. Contract operations

Before we do anything, we need to make sure that Contract Providers are fully setup

  • privateStateProvider has levelPrivateStateProvider for persistent localstorage
  • publicDataProvider Used for onchain read state on the indexer
  • zkConfigProvider Loads FetchZkConfigProvider <- Compiled Verifiers..
  • proofProvider responsible for generating Zero-Knowledge proofs on your proof server
  • walletProvider handles balanceTx via connectedApi.balanceUnsealedTransaction
  • midnightProvider Submits tx via connectedApi.submitTransaction

Note : in this tutorial i rebuilt the providers in every function, in a production env initialize them once and reuse them across all operations.

The function below covers the full lifecycle minting into the vault contract, we can call await mintToContract(BigInt(amount)) in our UI to execute it.

export async function mintToContract(
  connectedApi: ConnectedAPI,
  coinPublicKey: string,
  shieldedAddresses: { shieldedEncryptionPublicKey: string },
  amount: bigint,
  onSuccess: (txId: string) => void,
  onError: (err: string) => void
): Promise<void> {
  try {
    console.log('[Mint] === Starting mintToContract ===');
    console.log('[Mint] Amount:', amount.toString());
    console.log('[Mint] Contract:', CONTRACT_ADDRESS);

    const mods = await getModules();
    const { indexerModule, FetchZkConfigProvider, levelModule, CompiledContract, ledger, proofModule } = mods;

    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());
          console.log('[Mint] Calling submitTransaction, hex length:', serialized.length);
          await connectedApi.submitTransaction(serialized);
          return tx.identifiers()[0];
        },
      },
    };

    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: {},
    });

    const currentState = contract?.state?.();
    console.log('[Mint] Current contract state ledger:', JSON.stringify(currentState?.ledger, null, 2));

    console.log('[Mint] Calling contract.callTx.mintToContract...');
    const txData = await contract.callTx.mintToContract(amount);
    console.log('[Mint] SUCCESS, txId:', txData.public.txId);
    console.log('[Mint] Color returned:', txData.private.result);
    onSuccess(txData.public.txId);
  } catch (err) {
    console.error('[Mint] Error:', err);
    onError(err instanceof Error ? err.message : String(err));
  }
}

When you call

const txData = await contract.callTx.mintToContract(amount);

Each key on callTx corresponds to an exported circuit in our compact circuit which in this case is

export circuit mintToContract(amount: Uint<64>): Bytes<32>

Which uses mintUnshieldedToken called from the inside

Note : The proof generation might take some time before popup appears, in my case im using local proof server at port 6300 so it's fast.

So now we've successfully minted some tokens into our vault contract, we now need to send the tokens from the vault into our own address.

First things first, we must handle how user addresses are encoded so we created a helper function for this it parses Bech32m string and decodes to unshielded address and then returns Raw Bytes because sendTouser circuit expects 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');
  }
}

Now this function takes user input and runs it through our helper function encodeUserAddress(recipient) and most importantly calls await store.contractSend(params..) calls our circuit sendToUser which has sendUnshielded inside.

  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 we can try to deposit Our stablecoin token into the vault using receiveUnshielded.

In our frontend we have handleReceive, it functions in a similar way to handleSend .. store.receiveTokens(params..) calls our exported circuit receiveTokens(amount: Uint<128>) which has receiveUnshielded(color, disclose(amount)) inside

   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 : I know you are seeing me use getShieldedAddresses() Let me explain why. It is the most convenient way to retrieve both keys in one call as it returns shieldedAddress, shieldedCoinPublicKey, and shieldedEncryptionPublicKey.

2. Statistics

The vault contract has a state called Balance, it returns a set of Tokens Balances and what we did here was to iterate through balances array to find how many tokens with our 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 we need to get User's stablecoin balance, we first get connectedApi.getUnshieldedBalances() to get all user wallet balances and 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;
  }
}

In order to retrieve totalSupply we created a function getContractState() it runs through provider.queryContractState(CONTRACT_ADDRESS); to return the data we need such as totalSupply.

export async function getContractState(): Promise<ContractState> {
  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);
    if (!contractState) {
      console.log('[ContractState] No contract state found');
      return { totalSupply: 0n, totalBurned: 0n };
    }

    const contractModule = await import(CONTRACT_PATH + '/contract/index.js');
    const ledgerState = contractModule.ledger(contractState.data);

    console.log('[ContractState] Ledger totalSupply:', ledgerState.totalSupply.toString());
    console.log('[ContractState] Ledger totalBurned:', ledgerState.totalBurned.toString());

    return {
      totalSupply: ledgerState.totalSupply,
      totalBurned: ledgerState.totalBurned,
    };
  } catch (err) {
    console.error('[ContractState] Error:', err);
    console.error('[ContractState] Error message:', err instanceof Error ? err.message : String(err));
    console.error('[ContractState] Error stack:', err instanceof Error ? err.stack : '');
    return { totalSupply: 0n, totalBurned: 0n };
  }
}

3. Wallet operations

For displaying user receiving addresses and Stablecoin balance (Read Section 2 for in depth details on how to get user Stablecoin balance)

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;
  };

Now we move to sending the stablecoin token between user wallets, handlesend (different from contractSend one) looks like

const handleSend = async () => {
    if (!amount || !recipient) return;
    await sendStablecoin(recipient, BigInt(amount));
  };

This is much simpler than contract operations because sendStablecoin is available at the wallet level not a circuit call, it uses Dapp Connector V4 API to initiate the transfer and prompt the user.

      await makeTransfer(
        connectedApi,
        recipient,
        amount,
        async () => {
          setTransactionHash('transfer-success');
          await loadWalletState();
        },
        (err) => {
          if (err === 'Disconnected') {
            useWalletStore.getState().resetConnection();
            setError('Wallet disconnected. Please reconnect.');
          } else {
            setError(err);
          }
        }
      );

Key differences from contractSend

contractSend makeTransfer
Funds source Vault funds User funds
Mechanism handleSend Dapp Connector makeTransfer
Address Encode 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 tradeoffs

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 did I choose UnShielded for my 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 : Vault demonstrate a native mint functionality of this Stablecoin and it contains a public state totalSupply thats publicly readable in which regulators can monitor.

  3. Exchange Listings : Many exchanges like Binance.. have decided to delist XMR for this reason, Regulators put legal pressure on Binance to force a delist while tokens like NIGHT did manage to get listed because it is Unshielded precisely

When would i choose Shielded over UnShielded?

  1. Private tokenized securities : where transfers are confidential While specific properties like voting rights remain verifiable.

  2. Regulated industries requiring data minimization : in healthcare industries, frameworks like GDPR, CCPA, and HIPAA requires minimal data disclosure and shielded tokens ensure sensitive information stays in local storage while Zero-knowledge proofs can still confirm eligibility and compliance.

  3. forward secrecy : Even if encryptions keys are compromised in the future, shielded transactions remains private and this is something unshielded transactions cannot offer.

The bottom line : Midnight is multi-modal it does not enforce anything like XMR enforces privacy and BTC enforces transparency. It is permissionless, You can use whatever works for your project.

Comments (0)

Sign in to join the discussion

Be the first to comment!