A Midnight Network DApp that privately verifies academic degrees using Zero-Knowledge proofs. Issuers set an approved degree threshold, and verifiers prove a certificate’s degree matches — without revealing the certificate values on-chain.
Issue: An issuer sets an acceptedDegree threshold on the ledger. This writes the issuer name and degree to the contract, making the threshold publicly known but keeping certificate details private.
Verify: A prover submits a certificate (certId, holderName, degree) for on-chain verification. The circuit only checks that degree == acceptedDegree. The result (verified) is disclosed; certificate identity fields are not committed to the ledger.
Result: The verification outcome is visible on-chain. certId and holderName are accepted as private witness inputs but are not cryptographically bound to the verification result — anyone can supply any values, and the on-chain check only validates the degree.
Midnight enables Zero-Knowledge proof verification on a private blockchain. The contract circuit compares the prover’s degree against the issuer-approved threshold without revealing the degree itself, while still providing on-chain auditability of the result.
setIssuer circuit: The issuer name and accepted degree are disclosed on-ledger. These become the reference values for all future verifications.
verifyCertificate circuit: The circuit asserts the issuer has been set, checks isValid == true, and compares the prover’s degree against the stored acceptedDegree. It discloses only the Boolean result verified.
Basic logic: verified = disclose(degree == acceptedDegree)
Certificate fields are supplied as private witness inputs to the ZK circuit:
degree is compared against the issuer-approved acceptedDegree that was set during issuer setupverified) is disclosed on-chaincertId and holderName are accepted as private witness inputs but are not cryptographically bound to the verification resultThis is intentional Option #2 architecture: the circuit verifies the supplied private degree against the issuer-approved acceptedDegree, but does not commit certId or holderName to the ledger in a way that can be later verified against. Anyone can supply any certId or holderName value — the on-chain verification only checks that degree == acceptedDegree (when issuerSet == true and isValid == true).
The UI/documentation must NOT claim: “certId, holderName, degree and isValid are cryptographically committed together.”
Instead, accurately describe the current mechanism:
degree is compared against the issuer-approved acceptedDegreecertId and holderName are currently not cryptographically bound to the resultThis limitation exists because the Compact 0.31.1 compiler circuit does not currently support circuit-compatible commitment primitives for all four fields. A future upgrade to a later Compact version that exposes such a primitive would be the upgrade path to Option #1 (full commitment).
npm install
npm run setup # starts devnet, compiles, deploys
npm run compile # compiles contracts/hello-world.compact → contracts/managed/hello-world/
npm run deploy # deploys the contract (requires devnet up + compiled)
The project’s local devnet uses Docker Compose:
| Service | Port | Purpose |
|---|---|---|
node |
9944 | Midnight node, dev chain preset |
indexer |
8088 | GraphQL indexer for chain state |
proof-server |
6300 | Generates ZK proofs for contract transactions |
Tear down with: docker compose down -v
npm run test:e2e reconnects to the deployed contract and reads its ledger state. Exits 0 if the contract is live and indexable.
The project ships its own devnet via docker-compose.yml:
State lives in container-managed volumes. Tear everything down with:
docker compose down -v
That removes all containers, networks, and volumes. The next npm run setup starts from a clean slate.
This DApp supports three networks:
| Network | When to use | Default? |
|---|---|---|
undeployed |
Local devnet bundled in docker-compose.yml. Genesis seed is hardcoded; no funding needed. |
yes |
preview |
Public preview testnet. Faucet at https://midnight-tmnight-preview.nethermind.dev. |
|
preprod |
Public preprod testnet. Faucet at https://midnight-tmnight-preprod.nethermind.dev. |
The active network is sticky: whichever network you last interacted with stays active until you switch. Any command run with --network <name> also sets that network active for subsequent commands. The default on a fresh project is undeployed (local devnet).
npm run setup -- --network preview # runs on preview AND makes it active
npm run cli # still uses preview
npm run check-balance # still uses preview
You can also switch without running anything else:
npm run network preview # active network is now preview
npm run network # prints current active network
npm run network undeployed # switch back to local devnet
undeployed uses a hardcoded genesis seed. Local devnet pre-funds it.preview and preprod generate a fresh wallet on first use: a 24-word BIP-39 recovery phrase (printed once) plus its derived seed, both stored in .midnight-state.json (gitignored). The wallet survives switching networks — switch back later and your funded wallet returns..midnight-state.json under wallets.<network>.mnemonic. Anyone holding the phrase controls the wallet.seed; they just have no phrase to import into Lace.Seeds are derived with the standard BIP-39 mnemonicToSeed step — the same convention Lace uses — so identity is portable in both directions:
Bring your Lace wallet here: pass your recovery phrase via the MIDNIGHT_WALLET_MNEMONIC env var — the derived addresses match Lace. To keep the phrase out of your shell history, enter it with a hidden prompt instead of typing it inline:
read -s MIDNIGHT_WALLET_MNEMONIC && export MIDNIGHT_WALLET_MNEMONIC
npm run deploy
Take a scaffold wallet to Lace: restore Lace from the 24-word phrase in .midnight-state.json.
On the first run with --network preview (or preprod):
setup will print your wallet address and the faucet URL.setup polls the wallet balance every 10 s and continues automatically once funds arrive.MIDNIGHT_FAUCET_TIMEOUT_MS=1800000 (30 min) for unattended runs.If the faucet is slow or the script times out, your seed is preserved. Re-run npm run setup -- --network preview once the funds land.
These env vars override the active network’s config (no per-network suffix — they apply to whichever network is active for the run):
| Variable | Effect |
|---|---|
MIDNIGHT_WALLET_SEED |
Use this hex seed (32-128 hex chars; a Lace-compatible BIP-39 seed is 128) instead of generating/persisting one. Useful for CI with a pre-funded wallet. |
MIDNIGHT_WALLET_MNEMONIC |
Use this BIP-39 recovery phrase instead of generating a wallet — e.g. your Lace phrase, for the same addresses as Lace. Not persisted. Set only one of seed/mnemonic. |
MIDNIGHT_INDEXER_URL |
Override the indexer GraphQL URL. |
MIDNIGHT_INDEXER_WS_URL |
Override the indexer WS URL. |
MIDNIGHT_NODE_URL |
Override the node RPC URL. |
MIDNIGHT_FAUCET_URL |
Override the faucet URL printed during setup. |
MIDNIGHT_PROOF_SERVER_URL |
Override the proof server URL — set to a public proof server (e.g. https://lace-proof-pub.preview.midnight.network) to skip running one locally. |
MIDNIGHT_FAUCET_TIMEOUT_MS |
Faucet poll budget in milliseconds (default 600000 = 10 min). |
By default all networks use the local proof server. Public proof servers exist (see the env override above) but the local default keeps your witness data on your machine and avoids depending on a remote service for the deploy hot path.
npm run network undeployed # or: npm run setup -- --network undeployed
Your preview/preprod wallet seeds and deploy addresses stay in .midnight-state.json. Switch back later, and they’re still there.
After each deploy, cli, or check-balance run, the scripts serialize the wallet’s synced state to .midnight-wallet-state/<network>/ (gitignored). The next run on the same network restores from that snapshot and only catches up to the latest block instead of replaying from genesis — meaningful on preview / preprod where a from-seed sync takes minutes.
If the cache is stale or corrupt (e.g. after an SDK upgrade with an incompatible state format) the wallet falls back to a fresh from-seed sync with a one-line warning. npm run clean removes the cache along with other generated state.
The contract maintains the following ledger state:
| Key | Type | Description |
|---|---|---|
issuerSet |
Boolean | Has the issuer been configured? |
issuer |
Opaque<”string”> | Issuer name (as disclosed during setup) |
acceptedDegree |
Bytes<32> | The degree value approved by the issuer |
verified |
Boolean | Result of the last verification (degree == acceptedDegree) |
When verifying a certificate, the following private values are supplied as witness inputs to the ZK circuit:
certId (Bytes<32>): Certificate identifier — not cryptographically bound to the verification resultholderName (Bytes<32>): Holder name — not cryptographically bound to the verification resultdegree (Bytes<32>): The degree value being verified — cryptographically compared against acceptedDegreeisValid (Boolean): Explicit validity flag supplied by the proverWhen setting an issuer, the following private values are supplied:
issuerName (Opaque<”string”>): The issuer namedegree (Bytes<32>): The accepted degree threshold| Script | Description |
|---|---|
npm run setup |
One-shot: start devnet, compile, deploy. |
npm run compile |
Compile the Compact contract. |
npm run deploy |
Deploy the compiled contract (requires devnet up + compiled). |
npm run cli |
Interactive CLI to call circuits on the deployed contract. |
npm run check-balance |
Print the genesis-seed wallet’s NIGHT and DUST balances. |
npm run test:e2e |
Smoke + read-back check against the deployed contract. |
npm run clean |
Remove contracts/managed/, .midnight-state.json, and .midnight-wallet-state/. |
npm run proof-server:start / :stop |
Compose lifecycle for just the proof-server service. |
private-certificate-verifier/
├ contracts/
│ └── hello-world.compact # Compact source
├ scripts/
│ └── e2e-check.ts # smoke + read-back
├ src/
│ ├── network.ts # network selection + state file management
│ ├── wallet.ts # wallet construction + sync-state cache
│ ├── setup.ts # orchestrator for `npm run setup`
│ ├── deploy.ts # deploy the contract
│ ├── cli.ts # interact with deployed contract
│ └── check-balance.ts # NIGHT / DUST balance
├ docker-compose.yml # node + indexer + proof-server
├ .midnight-state.json # written by deploy (gitignored)
├ .midnight-wallet-state/ # serialized sync state per network (gitignored)
├ package.json
└ tsconfig.json
.compact-version at the create-mn-app repo root pinned the compiler version this project was scaffolded against. To upgrade your local compiler to that version:
compact update <version>
compact use <version>