VaxZK is a decentralized application (DApp) built on the Midnight Network that lets health authorities issue vaccine certificates and lets users prove their vaccination status — without ever exposing sensitive personal data.
Certificates are signed off-chain using Schnorr signatures and stored in the user's encrypted private state. When a verifier requests proof, the user submits a zero-knowledge proof on-chain that confirms the certificate is valid and was signed by a registered issuer — without revealing the underlying data.
- What Is VaxZK?
- Features
- Technology Stack
- Deployment & Running the Application
- Running the Tests
- How Certificates and Proofs Work
VaxZK solves a real-world problem: how can a person prove they are vaccinated without handing over a document that reveals their identity, the exact issuer, or other sensitive metadata?
The application uses three roles:
- Admin — manages the registry of authorized organizations and vaccine types.
- Clinic / Authorized Verifier — issues vaccine certificates and requests proof of vaccination from users.
- User — receives certificates, stores them privately, and submits ZK proofs on demand.
All vaccine records remain in the user's local encrypted private state. The public ledger only ever sees the result of a ZK verification — never the raw certificate data.
- Invite and revoke other admins using secure one-time invite codes.
- Invite and revoke authorized organizations (clinics, agencies, etc.).
- Manage the global vaccine catalog — add or remove vaccine types.
- Register certificate issuers (e.g. WHO, national health authorities) with their cryptographic public key.
- View platform metrics: total admins, registered clinics, and pending invites.
The "Clinic" role is not limited to traditional health facilities. Any organization with a legitimate need to issue or verify vaccination records can hold this role, including:
- Clinics, pharmacies, and hospitals
- Government immigration agencies (visa processing, entry health screening)
- Border control and customs health units
- Port health authorities and quarantine stations
- Travel health centers and international vaccination registries
Capabilities:
- Create and manage an organization profile (name, location, coordinates).
- Issue Schnorr-signed vaccine certificates to users and deliver the signed proof to their private state.
- Create vaccine proof requests on-chain — specifying the required vaccine type, the user's personal/passport ID, and a minimum certificate validity date.
- View proofs that users have submitted in response to requests.
- Connect via the Lace or 1AM browser wallet.
- Browse authorized clinics on an interactive map, filterable by vaccine type and location.
- View private vaccine certificates stored in local encrypted state.
- Submit zero-knowledge vaccine proofs in response to clinic or agency proof requests.
- Multi-language interface: English, Portuguese, and Spanish.
| Layer | Technologies |
|---|---|
| Frontend | React 19, Vite, TypeScript, Tailwind CSS, React Router 7 |
| Maps & QR | Leaflet / react-leaflet, qrcode.react |
| Reactive state | RxJS |
| Blockchain | Midnight Network (preprod testnet) |
| Smart contracts | Compact language, ZK circuits (10 circuit pairs) |
| Wallet | Lace (Midnight-enabled) or 1AM browser extension |
| Build & lint | Vite, ESLint, Vitest |
You can either:
- Use the published application at https://preprod.vaxzk.com.br, or
- Clone this repository and run it locally — follow the steps in the next section to build and start the app.
- Node.js LTS
- Midnight Compact compiler (
compact) — required to compile the smart contract - Lace wallet (with Midnight support enabled) or 1AM browser extension
npm install
npm run contract:compile # compile the Compact contract and copy ZK assets to public/
npm run dev # start the Vite dev serverOpen the URL printed by Vite (typically http://localhost:5173) and connect your wallet.
Whenever any .compact file under contract/ is modified, you must recompile before running or building:
npm run contract:compileThis compiles the Compact source to contract/managed/ and copies the verifier keys and ZK IR files into public/keys/ and public/zkir/, where Vite serves them as static assets. Skipping this step after a contract change causes a ContractConfigurationError at runtime when the app tries to deploy or call circuits.
| Script | Description |
|---|---|
npm run dev |
Start the Vite development server with hot reload |
npm run build |
Type-check and build for production (output: dist/) |
npm run contract:compile |
Compile the Compact contract and copy ZK assets to public/ |
npm run contract:copy-assets |
Copy already-compiled ZK assets to public/ without recompiling |
npm run lint |
Lint the codebase with ESLint |
npm run format |
Auto-fix lint issues |
npm run preview |
Preview the production build locally |
npm run buildThe output in dist/ is a fully static site. Deploy it to any static host — Vercel, Netlify, AWS S3 + CloudFront, GitHub Pages, etc. No server is required; VaxZK is a client-side DApp.
The app connects to the Midnight preprod testnet by default. You can override the network ID using the VITE_NETWORK_ID environment variable. Create a .env file in the project root (see .env.example) or set the variable inline:
# Use the default preprod network
npm run dev
# Use a local/undeployed network
VITE_NETWORK_ID=undeployed npm run dev
# Or persist it in a .env file
echo "VITE_NETWORK_ID=undeployed" > .env
npm run devThe variable is read at build time by Vite. Any value accepted by the Midnight SDK (e.g. preprod, undeployed) is valid.
The test suite covers all smart contract circuits using a local simulator — no live Midnight network connection is needed.
npm run test # run all tests once
npm run test:watch # run in watch mode (re-runs on file changes)Tests live in contract/src/test/. They use Vitest with a VaxZkSimulator that exercises:
- Admin flows (invite, accept, revoke, vaccine catalog)
- Clinic flows (register clinic, issue certificate, create proof request)
- User flows (submit ZK proof, Schnorr signature verification)
Each test has a 15-second timeout because ZK proof generation is compute-intensive even in the simulator.
Certificate Issuer (Clinic / Authorized Organization)
Any admin-registered organization that holds an on-chain CertIssuerInfo entry containing a JubJub public key (JubjubPoint). The organization keeps the corresponding secret key off-chain and uses it to sign vaccine certificates.
Proof Requester (Clinic / Authorized Verifier)
An authorized organization that creates a VaccineProofRequest on-chain via the requestVaccineProof circuit, specifying the required vaccine type, the user's personal/passport ID, and a minimum validity date. Requesters include traditional clinics as well as government immigration agencies, border control and customs health units, port health authorities, quarantine stations, or any body with a legitimate verification need.
Proof Provider (User)
The individual who holds a private VaxZkProof in their local encrypted state and submits it on-chain via submitVaccineProof in response to a proof request.
Admin
└─ registers Clinic + CertIssuerInfo (with issuer JubJub public key) on-chain
Clinic
└─ calls signVaxZkCertificate() off-chain (TypeScript)
│ signs: (vaccine, personalId, expirationDate, userShieldedPubKey)
│ using: issuer Schnorr secret key
└─ delivers VaxZkProof to the user's local private state
(never written to the public ledger)
Certificates are signed using a Schnorr signature over the JubJub elliptic curve — a twisted Edwards curve defined over the BLS12-381 scalar field, designed for efficient ZK-circuit arithmetic. JubJub is the same curve used by the Midnight ZK proofs.
Signing (off-chain, by the issuer)
Compute challenge = H(R, issuerPK, vaccine, personalId, expirationDate, userShieldedPubKey) mod 2^248
The challenge is truncated to 248 bits because Midnight's transientHash returns a value in the BLS12-381 scalar field (~2²⁵⁵), which can exceed the JubJub subgroup order (~2²⁵²·⁴). The truncation is performed with a witness-assisted division inside the ZK circuit (getSchnorrReduction), where the circuit proves q·2^248 + r = cFull with r < 2^248.
Verification (inside the ZK circuit, on-chain)
The circuit (schnorrVerifyVaxZk in contract/Schnorr.compact) performs this check natively using Compact's ecMulGenerator, ecMul, and ecAdd built-ins. If the equation holds, the certificate is valid.
Clinic
└─ requestVaccineProof(vaccine, personalId, validUntil)
└─ writes VaccineProofRequest to public ledger → proofReqId
User
└─ submitVaccineProof(proofReqId)
│ reads VaxZkProof from local private state (via witness)
│ ZK circuit checks:
│ 1. proof.issuerId is registered in issuers map
│ 2. proof.vaccine == request.vaccine
│ 3. proof.personalId == request.personalId
│ 4. proof.expirationDate >= request.validUntil
│ 5. schnorrVerifyVaxZk(…) — Schnorr equation holds
└─ writes VaxZkProof to vaccineProofs[proofReqId] on public ledger
(raw certificate data never exposed)
| Data | Where it lives | Visible to verifier? |
|---|---|---|
| Raw vaccine certificate (vaccine type, issuer, expiration, personal ID) | User's local encrypted private state | No |
| Issuer public key | Public ledger (issuers map) |
Yes |
| Proof request parameters | Public ledger (vaccineProofReqs map) |
Yes |
| ZK proof result | Public ledger (vaccineProofs map) |
Yes (proof only) |
| User identity / wallet address | Shielded via persistentHash |
No |
Shielded IDs are derived as persistentHash("registered-entity-id:" ‖ publicKey), so on-chain identifiers cannot be traced back to raw public keys without the preimage.

