Smart Contractsadvancedarticle55 min read

Building Your First Privacy dApp on Midnight — dApp Midnight Tutorial

A hands-on dApp midnight tutorial: build a shielded token dApp with Compact and Midnight.js — from local devnet to testnet and the Kūkolu mainnet. Includes the shielded token compact lifecycle, three contexts, provider architecture, and DUST fee economics.

Vietcardano teamTeam

Đội ngũ biên tập VietCardanoCommunity — các chuyên gia blockchain và Cardano, cung cấp nội dung giáo dục và nghiên cứu chuyên sâu cho cộng đồng.

Published August 31, 2026Updated August 30, 2026

Minh is a TypeScript engineer at a Hanoi startup that wants to reward customers with loyalty points on a public chain. Publicly, that means every customer's balance and purchase history becomes readable by anyone, forever. A receipt from Momo or ZaloPay is private between customer and shop; the same receipt on a public ledger is a headline. This dApp midnight tutorial shows exactly what Minh — and you — must learn to build it right: a shielded token dApp on Midnight, from a blank terminal to a tested contract running against a live network. By the end you will understand the three contexts of a Midnight smart contract, write a shielded token compact contract, wire it up with Midnight.js, and know exactly how testing, fees, and deployment work in 2026.

Why build a privacy dApp on Midnight?

A public blockchain is a glass house. Every transaction — sender, receiver, amount, date — is written permanently and readable by everyone. That transparency is what makes a ledger trustworthy for money. But for a loyalty program, a payroll run, or a customer KYC file, the glass walls are the problem, not the feature.

In Vietnam the problem is not theoretical. Nghị định 13/2023, in force since July 1, 2023, classifies financial data as sensitive personal data and demands consent and data minimization. The newer Law on Personal Data Protection (Law No. 91/2025/QH15), effective January 1, 2026, raises the stakes with fines up to 3 billion VND or 5% of the previous year's revenue. It also explicitly names blockchain among the high-tech domains it regulates, and Decree 356/2025 adds the concrete obligations. A fintech that puts transaction history on a public ledger today is publishing exactly the data the law tells it to protect.

Midnight exists to solve this mismatch. It is a blockchain built for data protection: a transparent ledger keeps the parts that must be verifiable, while a shielded ledger keeps everything sensitive private — locally, on the user's device. Between them, zero-knowledge proofs prove that a state change is valid without revealing the data underneath. That design is the whole reason a midnight dApp exists, and it is the reason a developer who knows TypeScript can build one without a cryptography PhD.

The Midnight dApp mental model — three contexts

Before any code, get the mental model right, because every Compact contract is actually three programs wearing one coat.

  • The ledger is the public, replicated state that lives on the blockchain. It is a typed state machine: export ledger state: State. Everyone can see it, and it must hold only what you are willing to publish.
  • The circuit is the zero-knowledge component. It receives inputs from the ledger and from private witnesses, and produces a proof that the transition is valid. The proof goes on-chain; the private inputs do not.
  • The witness is a local, off-chain component — arbitrary TypeScript that runs in the user's device or a backend. It holds private state (secrets, nonces, decryption keys) and hands the circuit exactly the values the circuit needs to prove something.

The three contexts explain every oddity in the rest of this tutorial. The ledger stores only public facts. The circuit is where "this user really owns this balance" is proven. The witness is where the private facts live — and the compiler makes you say explicitly when a private fact is allowed to cross into a public output.

Three contexts of a Midnight dApp: the ledger holds public replicated state visible to all, the circuit produces the ZK proof that proves the transition, and the witness holds private state running off-chain; public inputs come from the ledger and private inputs come from the witness, the proof goes on-chain while the data never does, and disclose() is the only door from private to public

Figure 2: The three contexts — ledger, circuit, witness — and the disclose() door

Set up your Midnight development environment

A dApp midnight tutorial needs a real environment, and Midnight's is pleasantly small. You need four pieces:

  1. The Compact toolchain. Install the compiler (compactc, current version 0.31.1) with the official one-line installer, then verify with compact --version. You also get a VS Code extension for syntax highlighting and the Fix-up tool that auto-repairs compile errors.
  2. A local network. midnight-local-dev starts a private Midnight network and an indexer on your machine. It is fast and free, and DUST — the fee resource — generates in about five minutes instead of hours.
  3. A proof server. Proofs are generated locally; a small Docker image (midnightntwrk/proof-server) provides the proving endpoint your Midnight.js client calls. For a first build, this is where most of the wait happens.
  4. A wallet. In a browser you use the Midnight Lace wallet extension, a fork of Cardano's Lace with ZK proof generation built in. In a terminal you build an in-process wallet from @midnight-ntwrk/midnight-js-wallet.

There is no faucet step in local dev — you register a wallet, the network gives it tNIGHT, and DUST appears in about five minutes. The same flow works against the public testnet, where free tDUST is available for developers, so you can run this exact tutorial with zero cost.

Midnight dev environment setup: compactc compiler 0.31.1 with VS Code and Fix-up, midnight-local-dev private network plus indexer, local Docker proof server, and the Lace wallet for browser signing with ZK proofs, feeding a zero-cost local loop with tNIGHT and DUST in about five minutes, with the same stack running against testnet with free tDUST

Figure 1: Four pieces make up the Midnight dev environment — a zero-cost local loop

Your first Compact contract — a shielded token

Now the actual build: a shielded token compact contract. We are deliberately building the shielded version, not the unshielded one, because it is the version that makes privacy real — and it is where the mental model pays off.

A shielded token has no public balance. On Midnight, the chain records commitments and nullifiers, not amounts. Its value lives in immutable coins: a ShieldedCoinInfo is a nonce, a color, and a value, and you cannot change a coin's value in place. There is no unshieldedBalance(color) call, because that number does not exist on-chain. To know what it holds, the contract tracks its own coins; to prove a transfer worked, you read the wallet's shielded balance, not the contract's.

Here is a minimal but real-shaped contract that mints a shielded coin and privately delivers it to the caller in a single transaction. It follows the pattern of Midnight's official shielded-token tutorial, current as of compactc 0.31.1:

pragma language_version 0.23;

import Zswap;

export ledger initialized: Bool;
export ledger total_supply: Uint<64>;
export ledger mint_count: Counter;
export ledger token_color: Opaque<Bytes<32>>;
export ledger minter: Bytes<32>;

export circuit constructor(owner: Bytes<32>): [] {
  // Record the deployer's derived key as the authorized minter.
  minter = owner;
}

export circuit mint_and_send(amount: Uint<64>, nonceIndex: Uint<128>): [] {
  // Only the holder of the deploy secret may mint.
  require(minter == ownPublicKey());

  // Derive a unique coin nonce from a private seed.
  let nonce: Uint<128> = evolveNonce(disclose(nonceIndex), localNonceSeed());

  // Mint a fresh shielded coin owned by the contract...
  let minted: Zswap.ShieldedCoinInfo = Zswap.mintShieldedToken(
    kernel.self(), amount, nonce
  );

  // ...and spend it in this same transaction, delivered to the caller.
  let result: Zswap.ShieldedSendResult = Zswap.sendImmediateShielded(
    minted, ownPublicKey(), amount
  );

  // Record public facts: color, initialization, supply, count.
  if (!initialized) {
    token_color = result.sent.color;
    initialized = true;
  }
  total_supply = (total_supply + disclose(amount)) as Uint<64>;
  mint_count.increment();
}

Read what each part is doing against the three-context model. ledger entries are the public state — supply, count, color, minter. mintShieldedToken mints a fresh coin into the contract's ownership (kernel.self()), and sendImmediateShielded spends that fresh coin in the same transaction and delivers it to the signer (ownPublicKey()). The mint amount is Uint<64> because the protocol caps a single mint at that width; coin values are Uint<128>, which is why the result carries the wider type.

Two details are easy to get wrong. First, ownPublicKey() returns a prover-claimed value with no binding to the signer — the official docs warn that anyone can bypass a check on it, so authorization here rests on the deploy secret, not on ownPublicKey() alone. Second, evolveNonce(disclose(nonceIndex), localNonceSeed()) derives a unique nonce from a private seed. If you reuse a nonce, the chain can link your commitments — the classic shielded-token footgun.

The shielded token lifecycle

The shield of the contract lives in the distinction between a fresh coin and a committed one — and this is the part every new builder trips on.

  • A fresh coin is a ShieldedCoinInfo: nonce, color, value. It exists inside the transaction that created it, and it has no Merkle position yet. You spend it with sendImmediateShielded(coin, recipient, value).
  • A committed coin is a QualifiedShieldedCoinInfo: the same coin, plus a Merkle tree index (mt_index) that the ledger assigned once the coin was committed. You spend it with sendShielded(input, recipient, value).

Both calls return a ShieldedSendResult containing sent and a Maybe change coin. A partial transfer — sending less than the full coin value — hands back change you must persist and track until it gains its own Merkle position.

Shielded token lifecycle: mintShieldedToken creates a fresh coin owned by the contract, sendImmediateShielded spends a fresh coin in the same transaction to the caller, the ledger assigns a Merkle position to commit the coin, sendShielded spends a committed coin and returns change, and sending to shieldedBurnAddress destroys value; a fresh coin has no Merkle position while a committed coin is qualified with an mt_index

Figure 3: The shielded token lifecycle — fresh coins become committed coins before they move again

The lifecycle has four movements, all built from the same primitives:

  • Mint — mintShieldedToken creates a fresh shielded coin owned by the contract. In the official tutorial, the mint destination is the contract itself (right(kernel.self())), and the same transaction immediately sends it onward.
  • Transfer (fresh) — sendImmediateShielded(coin, recipient, value) spends a coin minted in the same transaction. This is what mint_and_send uses to get the coin from the contract to the caller.
  • Transfer (committed) — sendShielded(input, recipient, value) spends a coin that already has a Merkle position. Partial sends return a change coin that must be persisted.
  • Burn — send to shieldedBurnAddress(). For a coin minted in the same transaction, that is sendImmediateShielded(coin, shieldedBurnAddress(), value); for a committed coin, sendShielded(coin, shieldedBurnAddress(), value). Partial burns return change, exactly like a transfer.

One constraint shapes what a contract-mediated transfer can do today: sendShielded does not currently create coin ciphertexts. In practice that means a contract can reliably deliver shielded value to the caller — the person signing the transaction — but sending to a third party will not notify that user of the coin. Plan your dApp around this: private value moves to the person who asked for it.

Wiring the dApp with Midnight.js

The contract is the hard part; the rest is TypeScript you already know. Midnight.js is the application framework, and it uses a modular provider architecture — seven pluggable components that together make up your client:

  • privateStateProvider — stores encrypted private state locally (the default LevelDB store encrypts at rest with AES-256-GCM).
  • publicDataProvider — queries the blockchain through the indexer, including WebSocket subscriptions to state changes.
  • zkConfigProvider — fetches the ZK artifacts (compiled circuits, proving keys) the client needs to make a call.
  • proofProvider — generates proofs, normally by talking to the proof server.
  • walletProvider — signs and balances transactions.
  • midnightProvider — submits transactions to the network.
  • loggerProvider — optional observability.
Midnight.js provider architecture: the dApp with React UI and state hooks sits above six pluggable providers — private state stored with AES-256-GCM encryption at rest, public data from the indexer and WebSocket, ZK config with circuits and keys, proof provider generating proofs via the proof server, wallet signing and balancing transactions, and midnight submitting transactions — with Lace wallet in browsers or an in-process wallet in Node underneath, keeping private state local while proofs go to the chain

Figure 4: Midnight.js modular provider architecture — six pluggable components

With providers configured, the interaction surface is three functions: deployContract (publishes a new contract instance), findDeployedContract (resumes an existing one), and callTx (calls a circuit and submits the transaction). The witnesses we declared in the contract are implemented as TypeScript functions that return the private inputs the circuit needs — in the shielded-token example, a localNonceSeed() witness that reads the seed from the private state store and hands it to the circuit. This is where the "witness context" matters: your witness gets the ledger state, the private state, and the contract address, and returns the exact values the circuit expects.

A clean pattern is to wrap the raw Midnight.js calls in a small API layer (a class that exposes mintAndSend, getBalance, getSupply), then let your UI talk to that class and never touch circuits or providers directly.

Testing and deploying — local → testnet → mainnet

A serious dApp midnight tutorial includes the testing story, and Midnight's is actually good: the Compact compiler emits a JS runtime plus a TypeScript declaration file (.d.ts), so you test the generated contract runtime, not a re-implementation of your logic.

Two layers of tests are the norm:

  • Simulator unit tests. Instantiate the compiled Contract class locally with an in-memory ledger, call circuits through it, and assert on ledger state with Vitest. The simulator is deterministic, fast, and needs no network. This is where you test that mint_and_send moves the coin, that supply grows by the right amount, and that only the minter can mint. The rule of thumb from the Midnight contributor community: never reimplement contract logic in your test file — read the generated .d.ts as the source of truth.
  • Integration tests against the local network. Spin up midnight-local-dev, deploy the contract for real, and run the same calls through Midnight.js. This catches everything the simulator cannot: real proofs, real DUST, real transaction submission.

When the integration suite is green, deployment is a ladder, not a leap:

  1. Local devnet — fast iteration, DUST in ~5 minutes.
  2. Public testnet — free tDUST for developers; proves your dApp against a live network before real assets exist.
  3. Kūkolu mainnet — live since March 31, 2026, permanent state with no resets, currently validated by a federated set of institutional operators (Blockdaemon, Shielded, Worldpay, Bullish, Google Cloud, Vodafone). The next phase, Mōhalu, opens validation to Cardano stake pool operators and activates the DUST capacity exchange.

Plan the mainnet move carefully. On a fresh mainnet wallet, NIGHT reaches Midnight through the cross-chain path (cNIGHT on Cardano), and registration takes about 12 hours — not minutes. DUST is a battery: holding NIGHT charges capacity, every transaction drains it, and a fully depleted battery recharges in roughly seven days. DUST cannot be bought, transferred, or sold; if someone offers you DUST, it is a scam. A developer can hold NIGHT and subsidize fees for users, which is a genuinely useful business property — a Vietnamese startup can onboard customers who never touch crypto at all.

Deployment pipeline from local devnet to testnet to Kūkolu mainnet: the local devnet gives a fast loop with DUST in about five minutes, the testnet offers free tDUST on a real network, and Kūkolu mainnet is permanent with cNIGHT registration around twelve hours and a DUST battery that recharges; the Mōhalu phase brings Cardano SPO validation and the DUST capacity exchange, and DUST cannot be bought because developers subsidize fees for users

Figure 5: The deployment ladder — local devnet, testnet, then Kūkolu mainnet

Privacy pitfalls every Midnight dApp developer hits

These are the mistakes the community sees most often, each one already covered by a constraint in the toolchain or a rule you just read:

  • Missing disclose(). The compiler rejects undeclared disclosure of witness values with an error along the lines of "potential witness-value disclosure must be declared but is not." Treat it as a privacy linter: the compiler is forcing you to say, explicitly, "I intend to publish this." When you audit a Compact contract, the disclose() calls are a map of exactly what it leaks.
  • Nonce reuse. evolveNonce exists precisely so each coin has a unique commitment. Reusing a seed or an index links commitments and lets observers connect otherwise-private coins. Always derive nonces from a properly managed private seed.
  • Dropping change coins. Every partial sendShielded returns a change coin. If your dApp ignores it, users lose value silently. Persist change and track it until it is committed.
  • Forgetting the fresh-vs-committed rule. You cannot sendShielded a fresh coin — it has no Merkle position yet — and you should not sendImmediateShielded a committed one. The wrong primitive is a compile or runtime error that confuses beginners; the lifecycle diagram is the quick reference.
  • Expecting third-party delivery. Because sendShielded does not create coin ciphertexts today, a contract cannot reliably notify a third party of a shielded coin. Deliver to the caller, or design around the limit.
  • Trusting ownPublicKey() for authorization. It is a prover-claimed value, not a signer binding. The shielded-token contract stores the deployer's derived key as the minter precisely because ownPublicKey() alone is bypassable.

Vietnam — why this midnight dApp matters here

The Vietnam angle is not decoration; it changes what you build. Every Momo or ZaloPay transaction is a piece of financial personal data under ND 13/2023. Publishing it to a public ledger is a compliance event. A shielded loyalty dApp is the difference between publishing "someone earned 1,000 points at a shop in Hanoi" (a commitment) and publishing "Minh, 32, phone +84..., earned 1,000 points at this branch" (a disclosure).

The PDPL regime sharpens the point. Law No. 91/2025/QH15, in force since January 1, 2026, imposes fines up to 3 billion VND or 5% of the prior year's revenue, and explicitly lists blockchain among the high-tech domains it regulates. Decree 356/2025/NĐ-CP names blockchain, big data, and AI explicitly, and requires data-processing service providers to be Vietnamese entities with MPS certificates. Midnight's model — prove facts without exposing data — is the cryptographic counterpart of the law's data-minimization duty. A fintech can prove it holds a valid customer base to a partner without exposing the customers; a loyalty program can prove redemptions match liabilities without exposing who redeemed what.

VNeID is the natural on-ramp. Vietnam's national digital identity app already enables eKYC at Momo and ZaloPay. On a midnight dApp, a VNeID-verified identity can live as a shielded identity with selective disclosure: prove "over 18," "resident," or "verified by MPS" without copying the underlying identity file to yet another database — exactly the selective disclosure pattern this article's three-context model is built on. And because a developer can subsidize DUST, the compliance win is not traded away against onboarding friction.

Frequently asked questions

What does a dApp midnight tutorial cover?

Building a real Midnight application end to end: the Compact contract, the witness layer, Midnight.js providers, testing with the simulator and a local network, and deploying through testnet to the Kūkolu mainnet — including the DUST fee mechanics a new builder must plan around.

How hard is a shielded token compact contract?

The shielded token compact contract you just saw is about 40 lines. The hard parts are not the language — it is TypeScript-shaped — but the model: no public balance, immutable coins, fresh-versus-committed states, and nonce discipline. The three-context mental model covers most of it.

Is building a midnight dApp like building an Ethereum dApp?

Structurally yes — deploy a contract, call functions, read state — but the privacy model is inverted. Solidity has no private inputs; everything is visible in calldata. Compact is private by default, and every crossing into the public ledger must be declared with disclose(). Balance reads happen in the wallet, not on-chain.

Do users have to pay for a midnight dApp?

Every Midnight transaction costs DUST, a shielded, non-transferable fee resource that charges like a battery while you hold NIGHT. Developers and DApp operators can subsidize fees for their users, so a business can onboard customers who never touch crypto. DUST cannot be bought — any offer to sell it is a scam.

Can my Midnight dApp send shielded tokens to any user today?

Today, a contract can reliably deliver shielded value to the caller of the transaction, because sendShielded does not yet create coin ciphertexts for third parties. Design private transfers to flow to the person who requested them, and track change coins from partial sends.

Is Midnight ready for production development?

The Kūkolu mainnet is live (March 31, 2026) and permanent, but validation is still federated and the ecosystem is young; Mōhalu (Q2 2026) opens validation to Cardano stake pool operators. For a serious build, the safe path is simulator → local network → testnet with tDUST → mainnet last.

Do I need a cryptography background?

No. Compact compiles to zero-knowledge circuits for you; the proof machinery is handled by the compiler, the proof server, and the network. What you do need is TypeScript and the discipline the article's pitfalls section lists.

How does a shielded token help Vietnamese businesses?

A shielded token lets a business prove balances, transfers, and redemptions without publishing who owns what — the cryptographic equivalent of ND 13/2023's data-minimization duty, aligned with the PDPL regime that explicitly names blockchain.

Conclusion

Minh's problem — private loyalty points on a public chain — turns out to be a design problem with a known shape: keep the public ledger small, keep the private facts in the witness, and let the circuit prove the difference. This dApp midnight tutorial walked the whole path: the three contexts, a shielded token compact contract with the full fresh-to-committed lifecycle, the Midnight.js provider stack, a testing ladder from simulator to testnet to mainnet, and the DUST economy that makes private transactions payable. What separates this from an ordinary midnight dApp is that privacy is not a feature bolted on — it is the default, and the compiler makes you own every decision to reveal. For a Vietnamese team, that property is also a compliance strategy: proof without exposure, rewards without headlines, and a ledger that never leaks what the law protects. The toolchain is a few installs away; the mental model is in this article. Your first privacy dApp on Midnight is closer than it looks.


Quick Reference

QuestionAnswer
What is a Midnight dApp?A dApp whose contract has public ledger state + zero-knowledge circuits + private witnesses; private data never goes on-chain
Three contextsLedger (public state) · Circuit (ZK proof) · Witness (private state, off-chain)
Shielded token coreNo public balance; value in immutable ShieldedCoinInfo coins; fresh vs committed (has Merkle index)
Main primitivesmintShieldedToken, sendImmediateShielded (fresh), sendShielded (committed), shieldedBurnAddress (burn), evolveNonce
WiringMidnight.js providers: private state, public data, ZK config, proof, wallet, midnight, logger
TestingSimulator unit tests (Vitest, generated runtime) + integration tests on midnight-local-dev
Deployment ladderLocal devnet (~5 min DUST) → testnet (free tDUST) → Kūkolu mainnet (cNIGHT ~12h, DUST battery)
DUSTNon-transferable fee resource; cannot be bought; developers can subsidize user fees
Vietnam fitND 13/2023 + PDPL (Law 91/2025) data minimization; VNeID selective disclosure; DUST subsidy onboarding
Status (2026)Kūkolu mainnet live Mar 31, 2026 (federated); Mōhalu (SPOs + DUST capacity exchange) next

Tags:

dapp midnight tutorialshielded token compactmidnight dapp