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.
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:
- The Compact toolchain. Install the compiler (
compactc, current version 0.31.1) with the official one-line installer, then verify withcompact --version. You also get a VS Code extension for syntax highlighting and the Fix-up tool that auto-repairs compile errors. - A local network.
midnight-local-devstarts 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. - 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. - 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.
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 withsendImmediateShielded(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 withsendShielded(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.
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 —
mintShieldedTokencreates a fresh shielded coin owned by the contract. In the official tutorial, the mint destination is the contract itself (right), and the same transaction immediately sends it onward.(kernel.self()) - Transfer (fresh) —
sendImmediateShielded(coin, recipient, value)spends a coin minted in the same transaction. This is whatmint_and_senduses 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 issendImmediateShielded(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.
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
Contractclass 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 thatmint_and_sendmoves 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.tsas 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:
- Local devnet — fast iteration, DUST in ~5 minutes.
- Public testnet — free tDUST for developers; proves your dApp against a live network before real assets exist.
- 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.
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, thedisclose()calls are a map of exactly what it leaks. - Nonce reuse.
evolveNonceexists 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
sendShieldedreturns 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
sendShieldeda fresh coin — it has no Merkle position yet — and you should notsendImmediateShieldeda 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
sendShieldeddoes 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 becauseownPublicKey()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 withdisclose(). 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, becausesendShielded 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
| Question | Answer |
|---|---|
| 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 contexts | Ledger (public state) · Circuit (ZK proof) · Witness (private state, off-chain) |
| Shielded token core | No public balance; value in immutable ShieldedCoinInfo coins; fresh vs committed (has Merkle index) |
| Main primitives | mintShieldedToken, sendImmediateShielded (fresh), sendShielded (committed), shieldedBurnAddress (burn), evolveNonce |
| Wiring | Midnight.js providers: private state, public data, ZK config, proof, wallet, midnight, logger |
| Testing | Simulator unit tests (Vitest, generated runtime) + integration tests on midnight-local-dev |
| Deployment ladder | Local devnet (~5 min DUST) → testnet (free tDUST) → Kūkolu mainnet (cNIGHT ~12h, DUST battery) |
| DUST | Non-transferable fee resource; cannot be bought; developers can subsidize user fees |
| Vietnam fit | ND 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 |
