Inspiration
I'm an Indian student graduated this May and just started with OPT. The first time I filed for an F-1 visa I had to prove I could cover a year of tuition and living expenses. I uploaded my full bank statement. The officer needed one number "is this person above $50K?" and instead got my entire financial life: every paycheck, every Zelle to my mom, my rent, my last DoorDash order, my account number. Same thing happens when you sign a lease in a new city, or apply to a graduate program, or even ask for an apartment guarantor.
The mismatch always bothered me. The verifier wants a yes/no. We hand them a memoir.
When the Midnight hackathon dropped and I read the Compact docs, it clicked. This is exactly what zero-knowledge proofs were invented for. You can prove the predicate "balance is at least X" and reveal absolutely nothing else. The verifier checks a single hash on-chain and gets their yes.
So we built it.
What it does
ProofVault gives a student a shareable URL that says "this person has at least $X in their bank account, can only be redeemed by this specific university, expires on this date." Nothing about the actual balance, account, bank, name, or transaction history is ever revealed.
The flow takes about thirty seconds:
- Student picks a tier $30K, $50K, or $80K.
- Picks the recipient university (we ship Indiana Tech, Northeastern, UT Austin, and Stanford as demo options).
- Connects their Midnight wallet (Lace 2.0 or 1AM).
- Links their bank through Plaid. Our attestor backend pulls the balance, signs an Ed25519 attestation, and hands it back. The attestor never tells anyone else what it saw.
- Hits "Generate proof." A Compact circuit on Midnight verifies that the signed attestation clears the tier, binds the proof to the chosen university and an expiry, and writes only
{tierIdx, universityId, expiry, attestorCommit, studentCommit}to the ledger. - The student sends the verifier portal URL to their university. The university opens it in a browser and sees a green checkmark with the four public fields. They don't even need a wallet.
The point is that no single party in the chain not the bank, not us, not the verifier, not anyone watching the chain ever sees the actual balance.
How we built it
This is a Turborepo monorepo with three apps and two shared packages.
The Compact smart contract lives in packages/contract. It exposes a verifyAndRecord circuit that takes the signed attestation as a witness, checks the Ed25519 signature against a registered attestor public key, asserts that the decoded balance clears the tier threshold, derives a student commitment, and writes the public fields to the ledger. We pinned the compiler to 0.30.0 because the Midnight SDK chain still ships runtime 0.15.0 more on that in the challenges section.
The prover dApp (apps/prover-app) is Next.js 14 with the app router, React 18, and Tailwind. The wallet integration goes through any DApp Connector API v4 provider injected under window.midnight (tested with Lace 2.0 and 1AM). We stage the compiled ZK artifacts (prover keys, verifier keys, zkir) into public/zk/ before every dev/build run so the browser can fetch them.
The attestor backend (apps/attestor-backend) is NestJS 11 on Fastify. It owns one Ed25519 keypair and exposes two endpoints: /attestor/pubkey for the pubkey and /verify/plaid which takes a Plaid public token, exchanges it for an access token, pulls the account balance, and returns a five-element preimage with a signature. In dev it falls back to a deterministic stub if Plaid creds aren't set, so you can run the whole demo offline.
The verifier portal (apps/verifier-portal) is a Next.js page that queries the Midnight indexer for the proof record at /verify/{proofId} and renders the verified state. No wallet required.
For the local network we use Midnight's midnight-local-dev stack a node, an indexer, and a proof server in Docker running alongside our apps.
Challenges we ran into
The Compact toolchain version mismatch ate a full day. Compact 0.31.0 was the latest release, the docs nudged us toward it, and then every contract call exploded with Version mismatch: compiled code expects 0.16.0, runtime is 0.15.0. Turns out midnight-js@4.0.4 pins runtime 0.15.0, which only 0.30.0-compiled bindings target. We pinned to 0.30.0 in SETUP.md so the next person doesn't lose an afternoon to it.
Mid-hackathon, the standalone "Lace Midnight Preview" extension we'd integrated against was deprecated and replaced by Lace 2.0 (a unified Cardano + Midnight wallet). The injection path changed from window.cardano.midnight to window.midnight.mnLace. We had to rewrite our wallet hook over a weekend.
NestJS dependency injection needs emitDecoratorMetadata at compile time. We started the attestor with tsx because that's what Nest's quickstart suggests, and nothing wired up every controller resolved its constructor params to undefined. The fix was to swap tsx for tsc && node dist/main.js since esbuild doesn't emit decorator metadata. Hours of "why is this guard not running."
The Preprod faucet is also slow and flaky. We hit InvalidAddressError because we were pasting our shielded address (mn_shield-addr_*) instead of the unshielded one (mn_addr_*). Once we figured that out, the faucet still took hours per request. We ended up running most demos against the local dev stack instead.
The deepest design challenge was the redemption story. We didn't want a proof to be redeemable by anyone that defeats the whole point. So the circuit takes the universityId as a public input and binds it into the student commitment. The same balance attestation can be reused to mint multiple proofs (one per recipient), but each proof on-chain is tied to exactly one verifier and has a nullifier so it can't be double-redeemed.
Accomplishments that we're proud of
The end-to-end demo actually works. Wallet connects, bank links, the ZK proof generates in under four seconds on a laptop, and the verifier URL renders a green checkmark with the four public fields and nothing else. We can hand a stranger the URL and they understand what they're looking at without us explaining a single line of Compact.
The privacy story is clean. No party in the system sees more than they need. The bank doesn't know what the balance is being used for. The attestor doesn't know which university the student is applying to. The university doesn't see the balance. The chain sees four public fields. That separation is hard to get right and we got it right on the first design pass.
The setup is reproducible. Anyone with Node 22 and Docker can clone the repo, follow SETUP.md, and have the local Midnight stack plus three apps running in about fifteen minutes. We tested it from a clean machine.
What we learned
Zero-knowledge isn't a buzzword anymore. Compact's witness model the explicit split between what's public input, what's private witness, and what's a derived commit forces you to think about your data model differently from day one. You can't bolt privacy on at the end. It changes which APIs exist, which fields can be logged, which params go where. We're better engineers for having had to think in those constraints.
The proof-of-funds market is much bigger than crypto people realize. Visa applications, university enrollment, apartment leases, mortgage pre-approval, immigration sponsorship, dating apps that want "verified income," even some employment screening every one of these flows currently runs on full bank statement uploads. The status quo is sending your entire transaction history to a stranger and trusting them to throw it away. There's a massive surface area for selective disclosure here.
Bank-statement PII is also weirdly under-regulated. We treat passport scans as sensitive but happily Slack a PDF of every Venmo we've ever sent. Building this made us notice.
What's next for proofvault-zk
The honest next step is real attestor partnerships. Right now we run our own attestor for the demo. In production, banks should publish their own attestor public keys and sign attestations directly the same way DKIM works for email. We've sketched what a well-known/proofvault-attestor.json discovery endpoint would look like.
Beyond that:
- Arbitrary threshold proofs instead of fixed tiers, so a student can prove "I have at least $42,317" if a specific university wants a specific number.
- Multi-recipient proofs from a single attestation, so reapplying to ten universities doesn't mean ten round-trips through Plaid.
- Mobile wallet integration once a Midnight-compatible wallet ships on iOS or Android. The student demographic is mobile-first.
- A redeemer SDK so a university's admissions portal can verify a proof inside its own UI instead of sending users to our verifier page.
- Expanding past visas and university enrollment apartment leasing and mortgage pre-approval are obvious adjacent markets that have the same shape.
We want this to be the default way humans answer "do you have the money" without surrendering the whole story.
Built With
- compact
- docker
- ed25519
- fastify
- lace-wallet
- midnight-network
- nestjs
- nextjs
- node.js
- plaid
- react
- tailwindcss
- turborepo
- typescript
- vitest
- zero-knowledge-proofs


Log in or sign up for Devpost to join the conversation.