惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

L
LINUX DO - 最新话题
C
Cyber Attacks, Cyber Crime and Cyber Security
G
GRAHAM CLULEY
T
Tenable Blog
T
Threatpost
C
CXSECURITY Database RSS Feed - CXSecurity.com
I
Intezer
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
D
Darknet – Hacking Tools, Hacker News & Cyber Security
K
Kaspersky official blog
Security Latest
Security Latest
P
Privacy & Cybersecurity Law Blog
Google Online Security Blog
Google Online Security Blog
SecWiki News
SecWiki News
P
Palo Alto Networks Blog
TaoSecurity Blog
TaoSecurity Blog
Webroot Blog
Webroot Blog
Spread Privacy
Spread Privacy
O
OpenAI News
The Last Watchdog
The Last Watchdog
P
Proofpoint News Feed
C
Check Point Blog
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
人人都是产品经理
人人都是产品经理
S
Security @ Cisco Blogs
Scott Helme
Scott Helme
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
月光博客
月光博客
S
Securelist
酷 壳 – CoolShell
酷 壳 – CoolShell
V
V2EX
T
Troy Hunt's Blog
W
WeLiveSecurity
GbyAI
GbyAI
N
News | PayPal Newsroom
Y
Y Combinator Blog
C
Cisco Blogs
H
Help Net Security
The GitHub Blog
The GitHub Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
博客园 - 【当耐特】
Jina AI
Jina AI
MongoDB | Blog
MongoDB | Blog
P
Proofpoint News Feed
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
云风的 BLOG
云风的 BLOG
小众软件
小众软件
N
News and Events Feed by Topic

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Building a Shielded Token dApp on Midnight: From Compact Contract to React UI
Ragrin Armstrong · 2026-06-17 · via DEV Community

When I first looked at Midnight's privacy model, I kept tripping over the same question. If the chain hides balances inside zero-knowledge proofs, how do you actually write a token contract? Where does the contract end and the wallet begin?

This tutorial answers that by building one. We write a Compact contract that mints, transfers, and burns shielded tokens, wire it through TypeScript, and drive it from a React frontend. By the end you will have a working dApp you can run locally, plus the mental model to take it to testnet.

The full source lives at the companion repo. Clone it, follow along, or just read the code.

What Midnight actually gives you

Midnight is a privacy first blockchain from the team behind Cardano. The important part for us is how it splits responsibility.

  • Ledger layer holds the shielded coins as UTXO commitments. The wallet SDK (@midnight-ntwrk/wallet-sdk-shielded) spends them. This is where the privacy actually lives. Coins are committed on chain, but their value, recipient, and linkability are hidden behind ZK proofs.
  • Contract layer is your Compact code. It runs as a circuit. The contract's job is authorization and bookkeeping, not hiding values. Privacy is the ledger's problem.

This split feels strange if you come from Solidity. In Ethereum the contract is the whole story. On Midnight the contract authorizes a value movement and the ledger hides it.

That means our shielded token contract does two things. It tracks a public total supply so anyone can audit the cap, and it gates who can mint or burn. The actual private coins are out of scope for the circuit. That is the wallet's job.

The keypair admin pattern

Before any tokens exist we need someone authorized to mint them. We could store the deployer's address on chain, but that ties the contract to a specific wallet identity and leaks who deployed it.

Midnight's official NFT example uses a better pattern. The deployer generates a private key locally, derives a public key from it, and stores only the public key on chain. Every mint call re-derives the public key from the local private key and checks it matches the committed value. The deployer's wallet identity never appears on chain.

Here is the contract entrypoint that sets this up.

pragma language_version >= 0.22.0;

import CompactStandardLibrary;
import "./modules/ShieldedToken";

export { totalSupply, balanceOf, mint, burn };

struct AdminSecretKey { bytes: Bytes<32>; }
struct AdminPublicKey { bytes: Bytes<32>; }

export ledger contractAdmin: AdminPublicKey;

witness localSecretKey(): AdminSecretKey;

constructor() {
  contractAdmin = disclose(deriveAdminPublicKey(localSecretKey()));
}

export circuit deriveAdminPublicKey(sk: AdminSecretKey): AdminPublicKey {
  return AdminPublicKey {
    bytes: persistentHash<Vector<2, Bytes<32>>>([pad(32, "shielded:admin:pk:v1"), sk.bytes])
  };
}

A few things to notice if this is your first Compact code.

  • ledger declares on-chain state. It is public and readable by anyone querying the contract.
  • witness declares a function the wallet provides. The contract calls it, but the wallet decides what it returns. This is how private data enters a circuit without leaking.
  • disclose marks a value as safe to reveal on chain. The constructor stores the derived public key, not the private key.
  • persistentHash is a domain-separated hash. We pad a namespace string (shielded:admin:pk:v1) so this key cannot collide with one derived for some other purpose. Domain separation is a small habit that prevents a whole class of cross-protocol attacks.

Minting and burning

With admin authority in place, the mint and burn circuits are short.

export circuit mintShieldedToken(to: ZswapCoinPublicKey, amount: Uint<64>): [] {
  assert(contractAdmin == deriveAdminPublicKey(localSecretKey()), "Not authorized to mint.");
  assert(disclose(amount) > 0, "Amount must be positive.");
  mint(to, amount);
}

export circuit burnShieldedToken(amount: Uint<64>): [] {
  assert(disclose(amount) > 0, "Amount must be positive.");
  const holder = ownPublicKey();
  burn(holder, amount);
}

ZswapCoinPublicKey is the 32-byte public key a wallet derives for receiving shielded coins. It is not the same as an account address. When you ask a wallet for its shielded address, you get back this coin public key plus an encryption key.

ownPublicKey() is a built-in that returns the coin public key of whoever signed the current transaction. For burn, that is the holder proving they own the tokens being destroyed.

The actual balance arithmetic lives in a module, which keeps the entrypoint readable and matches how the official examples are structured.

module ShieldedToken {

  import CompactStandardLibrary;

  export ledger totalSupply: Uint<64>;
  export ledger balances: Map<ZswapCoinPublicKey, Uint<64>>;

  export circuit balanceOf(holder: ZswapCoinPublicKey): Uint<64> {
    assert(holder != default<ZswapCoinPublicKey>, "Holder cannot be empty.");
    if (balances.member(disclose(holder))) {
      return balances.lookup(disclose(holder));
    } else {
      return 0;
    }
  }

  export circuit mint(to: ZswapCoinPublicKey, amount: Uint<64>): [] {
    assert(to != default<ZswapCoinPublicKey>, "Recipient cannot be empty.");
    assert(disclose(amount) > 0, "Amount must be positive.");
    if (!balances.member(disclose(to))) {
      balances.insert(disclose(to), 0);
    }
    const prev: Uint<64> = balances.lookup(disclose(to));
    const next: Uint<64> = (prev + disclose(amount)) as Uint<64>;
    balances.insert(disclose(to), next);
    totalSupply = (totalSupply + disclose(amount)) as Uint<64>;
  }

  export circuit burn(holder: ZswapCoinPublicKey, amount: Uint<64>): [] {
    assert(holder != default<ZswapCoinPublicKey>, "Holder cannot be empty.");
    assert(balances.member(disclose(holder)), "Holder has no balance.");
    assert(balances.lookup(disclose(holder)) >= disclose(amount), "Insufficient balance.");
    const prev: Uint<64> = balances.lookup(disclose(holder));
    const next: Uint<64> = (prev - disclose(amount)) as Uint<64>;
    balances.insert(disclose(holder), next);
    totalSupply = (totalSupply - disclose(amount)) as Uint<64>;
  }
}

This is the part where Compact differs from Solidity the most, so it is worth slowing down.

Why all the disclose() calls

In a ZK circuit, values that depend on private inputs stay private by default. The compiler refuses to write them to the public ledger unless you explicitly opt in. When you write amount > 0, the result of that comparison is fine to leak (it is just a boolean), but amount itself is a private input until you disclose it.

When you then do prev + amount and want to store the result on chain, the compiler says wait, that arithmetic result depends on a private value, and writing it to the ledger would disclose information derived from it. So you call disclose(amount) to tell the compiler yes, I know this reveals the amount, and that is the point. Mint amounts are public in this design.

If you wanted the amounts to stay private, you would keep the bookkeeping off chain entirely and let the wallet's shielded coin layer handle it. That is a more advanced pattern and out of scope here, but it is worth knowing the trade-off exists.

This default-private behavior is the single biggest difference between Compact and Solidity. Solidity has no concept of private inputs to a function. Every parameter is visible to anyone reading the transaction calldata. You can hash values before storing them, but the inputs themselves are public by construction. Compact inverts this. Privacy is the default, and disclosure is an explicit, auditable decision you make at each ledger write. When you read a Compact contract, the disclose() calls are the points where data crosses from private to public. Scan for them and you have a map of exactly what the contract leaks.

This also changes how you reason about a contract audit. In Solidity you ask, what can an attacker learn by watching the chain? In Compact you ask, did the author disclose more than they needed to? The answer for this tutorial contract is yes, intentionally, because we want the supply and balances to be auditable. A production privacy coin would disclose almost nothing from the contract layer.

Why the explicit as Uint<64> casts

Compact's type system widens arithmetic results to the full field range, roughly 254 bits. Adding two Uint<64> values does not give you another Uint<64>, it gives you a field element that could be larger. The compiler will not silently narrow it back, because doing so could hide an overflow.

So after every arithmetic expression we cast explicitly. (prev + disclose(amount)) as Uint<64> tells the compiler we have considered overflow and accept the narrowing. For a tutorial contract this is fine. For production you would add an explicit overflow check first.

I hit this the hard way while building this. My first version used Counter, the standard library type from the official counter example. Counter has .increment() and .decrement() methods that take Uint<16> deltas, capped at 65,535. Fine for a click counter, useless for token amounts. The lesson is that Counter is for counting, Uint is for amounts, and the two do not mix.

The witness provider

The contract declares witness localSecretKey(): AdminSecretKey but does not implement it. The implementation lives in TypeScript, because the witness runs in the wallet, not on chain.

import { Ledger } from "../managed/shielded-token/contract/index.js";
import { WitnessContext } from "@midnight-ntwrk/compact-runtime";

export type ShieldedTokenPrivateState = {
  readonly adminSecretKey: Uint8Array;
};

export const createShieldedTokenPrivateState = (
  adminSecretKey: Uint8Array
): ShieldedTokenPrivateState => ({
  adminSecretKey
});

export const generateAdminSecretKey = (): Uint8Array => {
  const key = new Uint8Array(32);
  crypto.getRandomValues(key);
  return key;
};

export const witnesses = {
  localSecretKey: ({
    privateState
  }: WitnessContext<Ledger, ShieldedTokenPrivateState>): [
    ShieldedTokenPrivateState,
    { bytes: Uint8Array }
  ] => [privateState, { bytes: privateState.adminSecretKey }]
};

The witness receives the current private state and returns a tuple. The first element is the updated private state (unchanged here, we just read the key). The second is the value the contract sees as the witness result.

The private state is a plain object the wallet holds locally. It never goes on chain. The admin key lives in it, generated once at deploy time with crypto.getRandomValues and persisted in the wallet from then on.

Compiling and testing

The Compact compiler is not an npm package. Midnight ships it through their own installer.

curl --proto '=https' --tlsv1.2 -LsSf \
  https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh

That puts a compact binary at ~/.local/bin. The binary is a version manager. To get the specific compiler version the official examples target, run compact update 0.30.0.

Compiling the contract generates TypeScript under src/managed/.

cd contract
npm install
npm run compact

The generated index.js exports a Contract class, a ledger function for reading state, and typed wrappers for every circuit. This is what the rest of your TypeScript code imports.

Testing without a node

You do not need a running Midnight node to test contract logic. The compact-runtime package ships an in-memory simulator that runs your circuits directly.

The pattern, lifted from the official counter example, is a small wrapper class.

import {
  type CircuitContext,
  sampleContractAddress,
  createConstructorContext,
  createCircuitContext
} from "@midnight-ntwrk/compact-runtime";
import { Contract, type Ledger, ledger } from "../managed/shielded-token/contract/index.js";
import { type ShieldedTokenPrivateState, witnesses } from "../witnesses.js";

export type Recipient = { bytes: Uint8Array };

export class ShieldedTokenSimulator {
  readonly contract: Contract<ShieldedTokenPrivateState>;
  circuitContext: CircuitContext<ShieldedTokenPrivateState>;

  constructor(adminSecretKey: Uint8Array) {
    this.contract = new Contract<ShieldedTokenPrivateState>(witnesses);
    const {
      currentPrivateState,
      currentContractState,
      currentZswapLocalState
    } = this.contract.initialState(
      createConstructorContext({ adminSecretKey }, "0".repeat(64))
    );
    this.circuitContext = createCircuitContext(
      sampleContractAddress(),
      currentZswapLocalState,
      currentContractState,
      currentPrivateState
    );
  }

  public mintShieldedToken(to: Recipient, amount: bigint): void {
    this.circuitContext = this.contract.impureCircuits.mintShieldedToken(
      this.circuitContext, to, amount
    ).context;
  }

  public balanceOf(holder: Recipient): bigint {
    const result = this.contract.impureCircuits.balanceOf(
      this.circuitContext, holder
    );
    this.circuitContext = result.context;
    return result.result;
  }
}

Each circuit call returns a new context. You thread that context into the next call. The simulator is pure in the sense that it does not touch the network, but it mutates its own context as circuits run, so you treat it as stateful.

With that wrapper, tests read like specs.

it("rejects mint from a caller without the admin key", () => {
  const sim = new ShieldedTokenSimulator(freshAdminKey());
  const wrongKey = freshAdminKey();
  sim.replacePrivateState(createShieldedTokenPrivateState(wrongKey));
  expect(() => sim.mintShieldedToken(sampleRecipient, 100n)).toThrow(/Not authorized/);
});

it("rejects burn larger than the holder's balance", () => {
  const sim = new ShieldedTokenSimulator(freshAdminKey());
  sim.mintShieldedToken(sampleRecipient, 100n);
  sim.setCaller(sampleRecipient);
  expect(() => sim.burnShieldedToken(101n)).toThrow(/Insufficient balance/);
});

Run them with npm test. Ten tests cover the happy path plus the authorization and bounds checks. If a test fails, you see the exact assert message from the contract.

The setCaller method on the simulator is worth a note. The contract calls ownPublicKey() to find out who is transacting. In the simulator, that reads from the circuit context's Zswap local state. setCaller swaps the coin public key there, which is how a test pretends to be a different wallet signing the transaction.

Wiring a React frontend

The contract is the hard part. The frontend is plumbing. We use Vite with React and TypeScript, and a thin API layer that talks to the simulator. On a real deploy you would swap the simulator for the wallet SDK, but the function signatures stay the same.

The API layer is one file.

export function deployContract(adminKey: Uint8Array): DappState {
  try {
    contract = new Contract<ShieldedTokenPrivateState>(witnesses);
    const privateState = createShieldedTokenPrivateState(adminKey);
    const constructorCtx = createConstructorContext(privateState, "0".repeat(64));
    const init = contract.initialState(constructorCtx);
    ctx = createCircuitContext(
      sampleContractAddress(),
      init.currentZswapLocalState,
      init.currentContractState,
      init.currentPrivateState
    );
    return makeState("ready", { address: "simulator" });
  } catch (err) {
    console.error("[deployContract]", err);
    return makeState("error", { error: String(err) });
  }
}

export function mintTokens(recipient: Uint8Array, amount: bigint): DappState {
  try {
    requireContract();
    const result = contract!.impureCircuits.mintShieldedToken(
      ctx!, { bytes: recipient }, amount
    );
    ctx = result.context;
    return makeState("ready", { totalSupply: readTotalSupply() });
  } catch (err) {
    console.error("[mintTokens]", err);
    return makeState("error", { totalSupply: safeTotalSupply(), error: String(err) });
  }
}

Two things here matter more than they look.

First, errors get logged before they get caught. A common mistake in this kind of wrapper is to swallow every exception and return a default. I did that in the first draft, and balanceOfHolder silently returned 0n on any failure. A corrupted context looked identical to an empty wallet. Now errors throw, callers catch at the boundary, and the console shows what actually broke.

Second, mintTokens preserves the known supply on error instead of zeroing it. If a mint fails after the contract already holds tokens, the UI should still show the real supply, not flash to zero. The safeTotalSupply helper reads the supply if it can and falls back to zero only as a last resort.

The React side is straightforward. State holds the contract status, total supply, and the recipient balance. Buttons call the API layer. One detail worth flagging is input handling.

const handleMint = useCallback(() => {
  let amount: bigint;
  try {
    amount = BigInt(mintAmount);
  } catch {
    setState((prev) => ({ ...prev, error: "Invalid amount. Enter a whole number." }));
    return;
  }
  if (amount <= 0n) return;
  // ...
}, [mintAmount]);

BigInt("") throws. So does BigInt("1.5"). An unhandled throw inside a React event handler leaves the UI in a stale state with no feedback. Wrapping the conversion and showing a friendly error is the fix. It is a small thing, but it is the kind of thing a reviewer notices.

One build wrinkle: WebAssembly

Midnight's runtime ships a WebAssembly binary. Vite's production bundler cannot import .wasm files directly without a plugin. The dev server handles it natively, but vite build fails with a confusing error about ESM integration for Wasm.

The fix is two plugins.

npm install -D vite-plugin-wasm vite-plugin-top-level-await

import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";

export default defineConfig({
  plugins: [react(), wasm(), topLevelAwait()],
  build: { target: "esnext" }
});

After that, vite build bundles the runtime into a single output. The WASM file lands at around 1.4 MB uncompressed, 412 KB gzipped, which is the bulk of the bundle. For a tutorial that is fine. For production you would lazy-load it.

Connecting a real wallet

The frontend above runs against the simulator. To connect a real wallet, Midnight defines a DApp connector API. A wallet extension injects itself at window.midnight keyed by a UUID. Your code detects it, calls connect, and gets back an API for balances, addresses, and transaction signing.

declare global {
  interface Window {
    midnight?: Record<string, InitialAPI>;
  }
}

export function detectWallets(): DetectedWallet[] {
  if (!window.midnight) return [];
  return Object.entries(window.midnight).map(([uuid, api]) => ({
    uuid,
    name: api.name,
    icon: api.icon,
    rdns: api.rdns,
    apiVersion: api.apiVersion
  }));
}

export async function connectWallet(uuid: string, networkId: string) {
  if (!window.midnight?.[uuid]) throw new Error("Wallet not found");
  return window.midnight[uuid].connect(networkId);
}

The connected API gives you shielded and unshielded balances, transaction history, and addresses. To submit a transaction that calls your contract, you build the circuit invocation, hand it to the wallet's balanceTransaction method, and the wallet handles coin selection, fee payment, and proof generation. Your code stays focused on what the transaction should do, not how it gets balanced.

This is the piece I left as future work in the companion repo. The simulator proves the contract is correct. The wallet integration is mechanical once you have a deployed contract address on testnet.

The reason the integration is mechanical is the API contract between your code and the wallet is narrow. Your TypeScript calls the same impureCircuits methods on the same Contract object. The difference is where the circuit context comes from. In the simulator you build it yourself with createCircuitContext. On a real deploy, the wallet SDK hands you a context backed by the actual ledger state, real coin selection, and a live proof server. The Contract class does not know or care which one it is running against.

That means the work of going from simulator to testnet is mostly infrastructure. You need a wallet connected, a funded account for fees, and a proof server endpoint. None of that changes your circuit logic or your witness implementations. If the contract is correct in the simulator, it is correct on chain. The simulator is not a toy, it runs the same compiled bytecode the chain would run. It just skips the consensus and networking layers.

The mental model, restated

If you take one thing from this, take the split.

The contract authorizes. The ledger hides.

When you write a shielded token contract on Midnight, you are not reimplementing privacy. You are writing the rules for who can move value, and the ledger enforces those rules behind a ZK proof. Your circuit runs, the wallet generates a proof that it ran correctly, and the chain verifies the proof without ever seeing the private inputs.

That is why the contract is short. There is no Merkle tree management, no nullifier set, no commitment scheme. Those exist, but they live in the ledger, maintained by the wallet SDK. Your job is the policy layer on top.

This is also why the testing story is so clean. Because the contract does not own the privacy primitives, you can test every circuit path in memory with no node, no wallet, and no network. The simulator catches logic bugs. Privacy correctness is guaranteed by the ledger, which is battle-tested and shared across every contract on the chain. You inherit its security for free.

Where to go next

A few directions worth exploring once you have this working.

  • Deploy to testnet. Get test NIGHT tokens from the faucet, deploy the contract through the wallet SDK, and mint real shielded coins. The simulator code path and the deploy code path share the same contract and witnesses, so the only new code is the wallet connection.
  • Add a transfer circuit. The current contract tracks balances but does not move them between holders. A transfer circuit would debit the sender and credit the recipient in one atomic call, which is the pattern the bounty description calls mint_and_send.
  • Keep amounts private. Right now mint amounts are public because we disclose() them. To hide them, drop the on-chain balance tracking entirely and let the wallet's shielded coin layer own the accounting. The contract becomes a pure authorization gate. This is closer to how Midnight's own native tokens work.

The Compact docs at docs.midnight.network go deeper on the ledger primitives. The example-nft-contracts and example-counter repos on GitHub are the best reference for real patterns, since the generated TypeScript types they produce are what you actually import.

Thanks for following along. If you build something with this, tag it #MidnightforDevs so the team sees it.