midnight-hackathon-private-certificate-verifier

Private Certificate Verifier

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.

What it does

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.

Why Midnight?

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.

How it works

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)

Privacy model

Certificate fields are supplied as private witness inputs to the ZK circuit:

This 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).

Important limitation

The UI/documentation must NOT claim: “certId, holderName, degree and isValid are cryptographically committed together.”

Instead, accurately describe the current mechanism:

This 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).

Quick start

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.


Local devnet

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.

Networks

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

How wallets work across networks

Using the same wallet as Lace

Seeds are derived with the standard BIP-39 mnemonicToSeed step — the same convention Lace uses — so identity is portable in both directions:

Funding a public-network wallet

On the first run with --network preview (or preprod):

  1. setup will print your wallet address and the faucet URL.
  2. Open the faucet URL, paste the address, request tNIGHT.
  3. setup polls the wallet balance every 10 s and continues automatically once funds arrive.
  4. The default poll budget is 10 minutes. Override with 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.

Environment overrides

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.

Switching back to local devnet

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.

Wallet sync cache

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.

Public ledger 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)

Private witness inputs

When verifying a certificate, the following private values are supplied as witness inputs to the ZK circuit:

When setting an issuer, the following private values are supplied:

Available scripts

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.

Project structure

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 compiler version

.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>