Inspiration

Age verification online is broken. Every "are you 18+?" checkbox either does nothing, or forces you to hand over your actual birthdate to a stranger's server just to prove a single yes/no fact. Physical age checks (bouncers, event staff) have the opposite problem: a screenshot of "verified" is trivially reusable by anyone. We wanted both problems solved at once — prove you're old enough, live, right now, without ever revealing your birthdate to anyone.

What it does

Midnight Bouncer is two things working together: a zero-knowledge age verification contract, and a live door-check PWA that actually uses it.

The contract's verifyAge circuit only ever returns one of two states — Verified or Not verified. No birthdate, no exact age, nothing else about the user ever touches the chain. It requires a Schnorr-signed attestation from a registered, trusted provider before accepting any age claim at all, so a user can't simply assert their own age — modeled on Midnight's example-zkloan reference pattern.

The demo app puts that contract behind a real physical workflow: a bouncer generates a fresh QR challenge, a guest's phone signs it live and generates a response QR, the bouncer scans and verifies the signature, the identity binding, and the real on-chain proof result — all in seconds, all client-side except the final on-chain check.

How we built it

Contract layer:

  • Compact circuit verifyAge, taking a private, Schnorr-signed birthdate attestation. Since jubjubSchnorrVerify isn't in Compact's standard library yet, we vendored and adapted our own contracts/schnorr.compact polyfill from Midnight's official example-zkloan app, generalizing its 4-field signature verification down to the 2 fields (birthTimestamp, hashed userId) our contract needs. The circuit calls this via Schnorr_schnorrVerify<2>(...).
  • Age itself is checked against the chain's own trusted block time (blockTimeGte), never a user-supplied "today's date" — closing off a spoofing vector entirely.
  • A registerProvider/removeProvider admin registry, gated by a derived admin public key (never ownPublicKey() directly, since that's claimable by the prover with no binding to a real signer).
  • Two test layers: a fast in-memory simulator suite covering adversarial edge cases (forged signatures, cross-user signature replay, unregistered providers, wrong admin), and a full end-to-end suite proving and deploying against a real local Midnight devnet.

Demo app (apps/demo/):

  • A two-role PWA (guest / bouncer) plus an admin tool standing in for the trusted attestation provider, built with Vite and vite-plugin-pwa for installability (manifest, service worker, "Add to Home Screen").
  • Live challenge-response instead of a static code: the bouncer generates 16 random bytes per check (crypto.getRandomValues), rendered as a QR via the qrcode library. The guest's phone scans it live (jsQR against camera frames), signs it with a per-device ECDSA (P-256) key held in localStorage (crypto.subtle.sign, native WebCrypto — no external crypto library), and renders a response QR containing { userId, publicKey, challenge, signature }.
  • The bouncer verifies, client-side, in order: the signature is valid for that challenge (catching replayed/stale scans); SHA-256(publicKey) matches the claimed userId (catching identity mismatches); only then does it call the backend (GET /api/verified?userId=...) to check the real on-chain verifiedUsers map.
  • Because the challenge is fresh, random bytes every single check, a captured signature from an earlier scan is cryptographically useless against the next one — solving the "screenshot a verified badge" problem a static QR would have.
  • An admin page (?role=admin) stands in for a real-world trusted attestation provider: enter a guest's ID and birthdate, and a backend endpoint (POST /api/verify-guest) performs the actual Schnorr signing and submits a real verifyAge transaction — including real ~20-60 second zero-knowledge proof generation. The signing key never leaves the server, and the endpoint only accepts requests from localhost, since anyone who can call it could mark any ID as verified for any birthdate — a real deployment would need this behind real authentication.
  • A small Node backend (apps/demo/server.ts) queries the indexer for the read path and holds a cached wallet connection for the admin write path.

Challenges we ran into

Early on, just getting a working Compact development environment stood up on Windows was its own fight: PowerShell vs. WSL2 shell confusion, a Compact compiler install that silently failed to extract its own binary, and Docker Desktop's WSL integration failing with a cryptic "no exec-capable directory" error that took real digging to resolve.

On the contract itself, Compact's privacy-leak checker refused to compile code that indirectly disclosed information derived from a private witness value — comparing a private birthdate-derived cutoff against the public chain clock — until we explicitly acknowledged it with disclose(). Frustrating in the moment, genuinely correct behavior: it's exactly the kind of protection that makes a real private contract possible, rather than one that leaks accidentally.

On the demo side, the core challenge was making "liveness" mean something. A single static "I'm verified" QR is just a photo waiting to be replayed — the fresh-challenge-per-check design exists specifically to make replay cryptographically impossible, not just visually implausible.

Accomplishments that we're proud of

A real, working proof of the whole idea end to end — not a mockup. A guest's phone generates a live signature no one could have precomputed, and a bouncer's phone checks it against the on-chain result of a real zero-knowledge proof on an actual (local) Midnight chain — the only thing anyone ever sees is ✅ or ❌. 13 passing tests cover the contract's happy path and its adversarial edge cases alike.

What we learned

That "privacy-preserving" is mostly a discipline of deliberate, explicit disclosure, not just data-hiding — Compact's compiler forcing us to mark every leak, however small, was the sharpest lesson of the whole build. Close behind: that a convincing physical demo (a QR someone actually scans with their own phone) exposes design gaps — like replay vulnerability — that never show up when you're only testing a contract in isolation.

What's next for Midnight Bouncer

  • Replace our test attestation provider with a real identity/KYC integration, and move the admin signing endpoint behind real authentication instead of a localhost-only restriction
  • Address the known liveness gap: the ECDSA signature proves "this specific phone, right now" controls a verified userId — it does not prove the current holder is the original verified person (e.g. a friend borrowing an unlocked, already-verified phone)
  • Deploy to a public Midnight testnet, beyond our local devnet
  • A full security review pass before any real-world deployment

Built With

Share this project:

Updates

Submission history