Inspiration
Blockchains are irreversible. One wrong transaction permanently destroys funds, and current wallet UIs are blind to intent — they verify signatures, not what the signed bytes actually do. Every wallet drain we've seen on XRPL exploits that gap.
There's a second gap. Public allowlists work for known good destinations, but they leak the user's entire trusted-counterparty graph to anyone who looks. Removing the allowlist removes the protection. We wanted users to be able to say "this destination is one I've already vetted" — provably — without publishing their full list.
We built zkcaptcha to close both gaps. A deterministic, LLM-free interpretation engine surfaces what a transaction actually does in plain language and flags intent-vs-effect mismatches. A private set-membership attestation layer, backed by a real Midnight Compact zero-knowledge circuit, lets users assert trust without disclosure. The two compose.
What it does
Users maintain trusted destination sets privately on their own machine. A Compact zero-knowledge circuit proves the queried destination is in that set, and Midnight's ledger verifies the proof — without the underlying allowlist ever appearing on-chain or in the verifier's hands. Only the set's commitment hash and the queried destination become public; every other member stays private.
The ZK attestation layer composes with the interpretation engine's rule-based checks. On a safe-exchange-deposit example, you see two independent verification signals stack: the engine recognizes Bitstamp's hot wallet via its public allowlist rule, AND a private Midnight attestation confirms the destination is in the user's pre-vetted trust set. Neither alone is enough; both together is strong.
The Verification context section of every interpretation makes the boundaries explicit. What was cryptographically verified, what wasn't, where the trust came from, and — critically — whether the on-chain state the attestation was bound to has moved on since the proof was produced. Verification gaps are surfaced as first-class output, not hidden.
How we built it
Six layers, bottom-up:
- Compact circuit (
membership.compact) —witness trustSet(): Vector<8, Bytes<32>>,proveMembership(destination)with an explicit-disjunction membership check. Public ledger fields are onlytrustSetCommitment(hash) andlastCheckedDestination(hash of queried destination). The trust set never appears in any transaction. - Integration harness (
harness/midnight/) — isolated TypeScript workspace wiring up@midnight-ntwrk/midnight-js, a local Docker Midnight stack (node + indexer + proof server), and "bite" scripts that exercise SDK imports → wallet → contract deploy → real ZK proof → cryptographic non-member rejection → end-to-end attestation capture. @zkcaptcha/allowlist-attestation— provider-neutralProver/Verifierinterface with a non-cryptographicplaceholderbackend that exercises the architectural shape honestly (the placeholder'sVerificationFacttext explicitly declares itself non-cryptographic).@zkcaptcha/allowlist-attestation-midnight-compact— real Midnight-backed backend, decoupled from any specific contract via aMidnightCompactHelpersinjection point. Heavy SDK deps stay here, never leak into the portable package above.- Browser-side live re-verifier (
src/lib/midnight-compact-snapshot.ts) — pre-captured attestations + state hex from the harness; at demo time the browser hits the local indexer's GraphQL, compares hex, surfaces drift in theVerificationFact.textfield with explicit "this slot has been overwritten by a later proof" language when the chain has moved on. - Next.js demo UI — backend toggle (placeholder vs midnight-compact), live attestation status, a PrivacyVisualizer comparing public-list vs ZK-set approaches with a single scan-reveal animation, a ZK proof trail timeline in the RiskCard with real hex pulled from the proof payload, and a "Show ZK under the hood" judge-mode toggle exposing the full cryptographic data for cross-check against the chain.
60 unit tests across engine, attestation interface, and browser verifier — covering every VerificationFact text branch (verified, stale, no contract, indexer unreachable, HTTP error, GraphQL error).
Challenges we ran into
Midnight's prove/verify asymmetry. The Midnight JS SDK supports standalone proving (HTTP to a local proof server, no chain required) but exposes no in-process verifier — verifying a Compact proof requires the Midnight ledger to check it on transaction submission. We discovered this during a deliberate feasibility investigation before writing the backend, which let us shape the design around honest disclosure instead of pretending the gap didn't exist. Every midnight-compact VerificationFact text makes the chain dependency explicit.
Compact toolchain version skew. Three independent runtime mismatches surfaced during the integration:
compactc 0.31.0emits code targetingcompact-runtime 0.16.0, but the scaffoldingtestkit-js@4.0.4hard-pinscompact-runtime 0.15.0viacompact-js@2.5.0. Resolved by pinningcompactc 0.30.0for the scaffold and0.31.0for our target stack, gated by aprecompacthook that aborts if the compiler is on the wrong version.testkit-jshardcodes a 1-second health-check timeout against hosted Preprod endpoints that respond in ~2 seconds. Patched viapatch-package.- WSL's default
appendWindowsPath=truelets Windowsnpmleak in over nvm-managed Linux node in non-interactive shells, silently running scripts viacmd.exeand failing on UNC working directories.
Doctrinal constraints. zkcaptcha's load-bearing principles — truthful capability boundaries, no theatrical functionality, no LLM in the verdict path, severity and confidence as independent axes — prevented several "easy wins." No in-browser proving animation (we don't actually prove in the browser). No simulated verification latency. No emoji in product UI. No "your wallet generates the proof" copy. The result is a demo that survives inspection by a ZK-knowledgeable judge.
What we learned
- Prove/verify asymmetry has real architectural consequences. There is no such thing today as fully off-chain, in-browser Midnight verification. Designs that pretend otherwise are lying. Designs that acknowledge it can still be honest and useful.
- Toolchain version pinning matters more for emerging ecosystems. Compact, runtime, and SDK versions need to agree across an entire dependency tree. Every mismatch we hit is documented so the next builder doesn't re-discover them.
- Doctrinal documents earn their keep. Repeatedly pushing back on tempting shortcuts produced a demo more credible to technical judges than a polished-but-overclaimed one would have been.
What's next
- Wallet-integrated proving. Today proofs are generated offline by the harness; production needs DApp Connector v4 wallet signing so the user's wallet generates the proof at transaction time.
- Historical verification. The browser verifier reads only current ledger state — staleness is surfaced honestly but historical re-verify at an attestation's specific block height isn't implemented.
- Multi-prover composition. Multiple users each attest "destination X is in my trust set" → composed reputation signal that doesn't leak who vouched.
- Beyond destination allowlisting. The same private-set-membership primitive unlocks anonymous voting eligibility, sybil-resistant rate limiting, cross-chain reputation portability, whitelist-gated mints without revealing wallet identity, and any other "prove X is in my list without revealing the list" use case.
Built With
- compact
- compact-runtime
- docker
- eslint
- graphql
- midnight-js-sdk
- midnight-network
- next.js
- node.js
- plonk
- react
- tailwindcss
- typescript
- vitest
- wsl
- xrpl

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