ShadowVote
Inspiration
We wanted to build something that actually uses zero-knowledge proofs for a real privacy problem — not just a toy counter contract. Online voting, identity verification, membership proofs — they all suffer from the same tension: you need to prove you're authorized without revealing who you are. Most "private" systems still hand over your email, phone number, or wallet address.
Midnight Network's dual-state architecture (public + private) and Compact language made this tractable: we could write a Merkle-tree-based membership contract where the user's identity commitment lives on-chain, but the secret that generates it never leaves their wallet.
What it does
ShadowVote is a browser dApp that lets users generate zero-knowledge proofs of identity without revealing their private data. Connect your 1AM wallet, register your identity commitment (one-time), then generate anonymous proofs that you're a registered member — all without disclosing your secret key or wallet address.
The contract uses a Merkle tree of identity commitments. The register circuit inserts your commitment (public). The proveIdentity circuit proves you know the secret for a leaf in the tree — and outputs only the Merkle root as public data. An observer learns: someone in the tree proved membership. That's it.
How we built it
- Contract: Written in Compact (
contracts/identity.compact), compiled with Midnight's toolchain (v0.31.0). Two circuits —registerinserts apersistentHash([domain_sep, secret])into aMerkleTree<16, Bytes<32>>;proveIdentityrecomputes the commitment from a witness secret, queries the on-chain tree viafindPath, and discloses the root. - Frontend: Next.js 16 (webpack), Tailwind v4, framer-motion. Three states: wallet connect, proof generation with step-by-step progress, result card with auto-polling confirmation.
- Wallet: 1AM wallet extension (
window.midnight['1am']). No DUST needed — Preview network sponsors fees via 1AM ProofStation. - SDK: midnight-js for
createUnprovenDeployTx/createUnprovenCallTx+submitTxAsync. Custom witness functions:callerSecretderives a deterministic SHA-256 secret from the user's coin public key;findPathqueries the ledger's Merkle tree directly. - Persistence: Contract address + verifier fingerprint in localStorage. Auto-redeploys when the contract recompiles.
- Confirmation: Polls the indexer GraphQL endpoint by transaction hash (not a global counter) — eliminates false positives from other users.
Challenges we ran into
The BMT root bug: The
proveIdentitycircuit originally calledregistrations.checkRoot()to verify the Merkle path — butpartitionTranscriptsin the WASM runtime crashed with "attempted to take root of non-rehashed BMT". Fix: removedcheckRootentirely. Membership is verified implicitly because thefindPathwitness queries the actual on-chain tree. The circuit just recomputes the root from the path and discloses it.Indexer timing: After submitting a
registertx, the indexer takes 2–30 seconds to reflect the new leaf. CallingproveIdentityimmediately fails with "identity not registered". Solution: a retry loop (up to 60s) that keeps retryingcreateUnprovenCallTxuntil the indexer catches up.The fake tx hash:
submitTxfallback usedtxHex.slice(0, 64)which always produced the same garbage (serialized txs start with identical bytes). Fix: usetx.transactionHash()from theTransactionobject — returns a real BLAKE2b-256 hash the indexer accepts."Failed to clone intent": The standard
signRecipeAPI hardcodes'pre-proof'but proven intents contain'proof'data. Workaround: manualsignTransactionIntents()with the correct attestation label. Cost us a day of debugging.Wallet sync: On first connection, the wallet sometimes reports "syncing" for up to 30s. Added
retryOnSync()— pollsgetUnshieldedAddress()up to 60s with 2s delays and a clear UI message.Verifier key fingerprinting: Originally only hashed
register.verifier— changes toproveIdentitycircuit weren't detected, causing stale contract reuse. Fixed by SHA-256 hashing all verifier keys combined.
Accomplishments that we're proud of
- It actually works end-to-end: Real ZK proofs, real on-chain transactions, real indexer confirmation. Not a simulation.
- Sub-60s UX from cold start: First-time visitor → install wallet → connect → register → prove. The progress bar and step-by-step status messages make the wait feel fast.
- No DUST friction: Preview network sponsorship means users never see "insufficient balance" errors. Just connect and prove.
- Auto-redeploy on recompile: Contract fingerprinting means we can iterate on the Compact contract without manual cleanup.
- Clean error handling: Every failure path has a user-friendly message — network errors, sync issues, identity-not-registered, Merkle tree full — no raw stack traces.
- The contract is minimal: 33 lines of Compact for register + proveIdentity. The Merkle tree membership proof pattern is reusable for any anonymous auth system.
What we learned
- Midnight's proving model is elegant but raw: The low-level
createUnprovenDeployTx/prove/balanceTx/signTransactionIntents/submitTxAsyncpipeline is powerful but poorly documented. The "Failed to clone intent" bug alone took hours of reading SDK source. - Indexer latency shapes the UX: You can't treat contract calls as synchronous. Build retry loops and pending states into the UI from day one.
- Witness functions are the right abstraction: Putting Merkle tree queries in witnesses (not circuits) avoids BMT bugs and keeps the circuit pure. The
findPathwitness queries live ledger state — the circuit just verifies the path is valid. persistentHashis deterministic cross-platform: The samepersistentHash([pad("shadowvote:identity:v1"), secret])in Compact and TypeScript means the commitment computed in the browser matches the contract — critical for UX (show the user their commitment before any transaction).- Compact's type system catches real bugs: The
MerkleTreePath<16, Bytes<32>>type propagates through the circuit; mismatches fail at compile time. The BMT bug was a runtime error we couldn't have caught statically, but the compiler caught everything else.
What's next for ShadowVote
- Persistent private state: Currently in-memory (lost on reload). LevelDB (or localStorage for simple cases) means users don't re-register.
- Multi-network support: Preview is hardcoded. Route to preprod/mainnet based on wallet network.
- Proof history: Store past proofs locally with their tx hashes and verification status.
- Anonymity set scaling: The
MerkleTree<16>supports 65,536 leaves. For real voting, we'd want deeper trees or incremental membership. - Nullifier pattern: Currently a user can prove membership multiple times. Adding a nullifier (revealed on first use) prevents double-voting while preserving anonymity.
- Real voting contract: Build on top of ShadowVote — submit encrypted ballots, tally with ZK, only registered members can vote.
Built With
- compact
- javascript
- typescript
Log in or sign up for Devpost to join the conversation.