A private voting-address registry for holders of the ETHSecurity Badge NFT. Addresses are encrypted in the browser with ML-KEM-768 + AES-256-GCM, stored as ciphertext, and decrypted offline by the round administrator after voting closes.
The goal is unlinkability: a badge holder's on-chain voting and donation activity should not be trivially traceable back to their public, badge-holding wallet. A holder submits a separate voting address, which is encrypted client-side and never leaves the browser in plaintext. The backend only ever stores ciphertext.
The submitted address is later used to receive:
- A Voting NFT
- Gas money
- 46 FINN — worth 1 Finney (0.001 ETH) each if donated in the QF round
Every $1 donated from the submitted address counts as $4 toward directing the matching pool. The same address may be reused in future rounds.
When you open the app you pick one of two modes.
For badge holders on a regular internet-connected machine.
- Click Online.
- Connect Wallet → pick the wallet holding your badge.
- The app auto-detects your
tokenIdonchain. - Enter your voting address → Encrypt & Sign.
- Sign the EIP-712 message. The voting address is encrypted in-browser and posted with the signature.
One badge = one submission. Re-submissions are rejected.
For badge holders whose signing key lives on an airgapped machine.
- Click Offline.
- Fill in: holder wallet, badge tokenId, voting address.
- Click Encrypt & prepare message.
- Pick a signing path:
- Connect wallet — uses a local wallet extension (MetaMask, Rabby, Frame) including any hardware wallet plugged into this machine.
- Sign externally — copy the EIP-712 payload, sign with
cast wallet sign-typed-data,pnpm sign-offline, MyEtherWallet's offline signer, or any other EIP-712 signer; paste the0x…signature back. The page verifies the signature recovers to the declared holder wallet before producing the blob.
- Download the signed blob
ethsec-submission-badge-<id>.json. - On any online machine, open the app again, pick Offline, and use the Submit a signed blob section to upload.
What crosses the air gap: the signed JSON. What stays on the signing machine: the private key and the plaintext voting address.
| Package | Role |
|---|---|
apps/api |
Fastify server. Verifies EIP-712 sig + onchain ERC-721 ownership. |
apps/web |
Vite + React + RainbowKit frontend. Submission and admin-decrypt UI. |
packages/shared |
Hybrid encryption (ML-KEM-768 + AES-256-GCM). Browser- and Node-safe. |
scripts |
keygen, decrypt, sign-offline CLI tools. |
API routes: GET /config, GET /token-status/:id, POST /submit,
GET /admin/export (bearer-auth).
Admin power is split across two secrets held by the same person:
ADMIN_EXPORT_TOKEN— grants access to the encrypted CSV dump.- ML-KEM-768 private key — decrypts the ciphertexts.
Either alone is useless: the token yields ciphertext blobs you can't read; the private key yields nothing to fetch. Both secrets live only on the administrator's local machine. The browser admin page decrypts entirely client-side; the private key never reaches a server.
pnpm install
pnpm dev # run the web + api dev servers
pnpm test # full test suite
pnpm typecheck # tsc --noEmitEnvironment variables are documented in apps/api/.env.example and
apps/web/.env.example.
MIT.