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. SincejubjubSchnorrVerifyisn't in Compact's standard library yet, we vendored and adapted our owncontracts/schnorr.compactpolyfill from Midnight's officialexample-zkloanapp, generalizing its 4-field signature verification down to the 2 fields (birthTimestamp, hasheduserId) our contract needs. The circuit calls this viaSchnorr_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/removeProvideradmin registry, gated by a derived admin public key (neverownPublicKey()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-pwafor 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 theqrcodelibrary. The guest's phone scans it live (jsQRagainst camera frames), signs it with a per-device ECDSA (P-256) key held inlocalStorage(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 claimeduserId(catching identity mismatches); only then does it call the backend (GET /api/verified?userId=...) to check the real on-chainverifiedUsersmap. - 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 realverifyAgetransaction — including real ~20-60 second zero-knowledge proof generation. The signing key never leaves the server, and the endpoint only accepts requests fromlocalhost, 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
- compact
- docker
- midnight
- typescript
- vite
Log in or sign up for Devpost to join the conversation.