Inspiration
I'm socially awkward. I'm not great at talking to people, and I'm worse at being assertive — the conversation where I need to push back, ask for something, or hold a boundary is the one I replay in my head for a week afterward and still get wrong.
Writing is my workaround. In an email I can draft it, sit with it, cut the part that comes out wrong, and send the version I actually meant. But some people you can't reach by email. Some conversations only happen out loud, in real time, with no draft folder — and those are the exact ones that matter most.
So I built SoundingBoard: an app where you rehearse those conversations out loud with an AI that plays the other person and doesn't just roll over, then tells you how you did. Push-to-talk, not typing, on purpose — practicing the thing you're actually bad at only works if you have to say it with your mouth. It's been a real tool for me.
But here's what I kept noticing about it. The conversations you most need to practice are exactly the ones you'd never let anyone read — asking for a raise, setting a boundary with a parent, a hard medical conversation, telling someone something they don't want to hear. I designed the app so none of it ever leaves the phone: speech-to-text runs on-device, transcripts are stored locally, the server keeps nothing, and there are no accounts.
That's a good privacy story right up until the moment someone needs proof. A therapist assigning communication homework, a coach, an employer with a training requirement, a court-ordered program — the second you want credit for the practice, the only thing you can offer is the diary itself. Private, or provable: pick one.
Midnight is the thing that made me realize it doesn't have to be a choice.
What it does
SoundingBoard lets you rehearse a difficult conversation with an AI persona you configure (relationship, temperament, difficulty), speaking out loud with push-to-talk, and then scores you on clarity, composure, and assertiveness with specific moments quoted back.
Practice Proof is what this hackathon added. It generates a zero-knowledge proof that you have completed at least N practice sessions and posts the attestation to Midnight Preview. What goes on-chain is a single number tied to your key. What never leaves your phone: the transcripts, the scores, the scenarios, who the conversation was with, or that any particular session ever happened.
You can hand someone a verifiable receipt that you did the work, without handing them your diary.
How I built it
- The app (already existed): Expo / React Native with Expo Router and Zustand, talking to a Cloudflare Worker (Hono) that calls Claude for the persona turns and for JSON-schema-constrained feedback scoring. Session history persists on-device in AsyncStorage; the Worker is stateless.
- The commitment scheme (new): each completed session yields a salted hash commitment derived from the local history record. The salt is generated once and stored on-device. Transcripts and scores are never inputs to anything that leaves the phone.
- The Compact contract (new): a circuit that proves possession of N distinct session
commitments and writes only
publicKey -> milestoneto the public ledger, with a monotonicity check so an attestation can't be replayed downward. It lives inmidnight/contract/src/practice_attestation.compact— one exported circuit,attest(claimed), over a single ledger field,milestones: Map<Bytes<32>, Uint<8>>. That map is the entire public footprint. 52 tests cover it, including the privacy invariants: no commitment ever reaches the ledger, and two users with identical practice history still get distinct on-chain identities. - The attest dApp (new): a Vite + React + TypeScript page with two halves. The verification
half reads the contract's
milestonesmap straight from the Preview indexer and needs no wallet, no extension and no permission from the attester — which is the property that makes an attestation a receipt rather than a claim. The attest half loads the exported witness file, validates it against the same format module the circuit consumes, and shows exactly what would stay on the device versus what would become public. - Submission runs headlessly (new):
npm run attest -w sb-phase0-smokeloads a witness file, provesattestagainst a local proof server, submits to Preview, and reads the receipt back through the indexer. Submitting from the browser via the Lace extension is not implemented — the only Lace install available was on mainnet, so that path could not be tested, and shipping an untested transaction path seemed worse than naming the gap. The proving and submission are real either way; they just run in a terminal.
React Native's JS engine has no WebAssembly, so proof generation can't run inside the mobile app today. The app exports a witness file and the companion web dApp does the proving — which keeps the sensitive data on the device and puts only the proof step in the browser.
Challenges I ran into
A version trap that fails silently, on-chain. The Compact toolchain tells you to run
compact update. Doing that installs compiler 0.34.0, which targets ledger 9 — while Preview,
Preprod and Mainnet all still run ledger 8. Code built on it compiles perfectly, passes tests,
and then fails once it's deployed. That's the worst place to find a bug: past the point where
anything is cheap to change. I pinned the whole stack (compiler 0.31.1, CLI 0.5.2,
compact-runtime 0.16.0, midnight-js 4.1.1, proof server 8.1.0) and wrote the warning into every
build script, because a mistake that only surfaces on-chain will absolutely be made twice
otherwise.
The rest of the setup was ordinary Windows friction. The real toolchain only runs under WSL, and
on Windows compact on the PATH is Microsoft's NTFS compression utility — so the doctor script
cheerfully reports "CLI found" when it has found entirely the wrong program.
Disclosure was stricter than I expected, and it was right to be. My first compile failed with
"potential witness-value disclosure must be declared but is not: parameter claimed of exported
circuit attest." I'd assumed only values coming out of a witness() call were private. They're
not — the parameters of an exported circuit are treated as witness data too, because the prover
supplies them. The compiler wouldn't let me write a number to the public ledger until I said, in
code, that I meant to make it public. Coming from TypeScript, where nothing stops you leaking a
field into a response body, having the type system refuse to compile a privacy mistake was the
moment the model actually landed for me.
I found a soundness bug in my own circuit. The witness is a fixed-width vector of ten commitments, zero-filled past the real ones, and the circuit proves the claimed ones are pairwise distinct. Writing the padding helper, I realised the distinctness check catches someone claiming two padding slots — the zeros collide — but claiming exactly one passes silently, letting you overstate your milestone by one session.
The tempting fix was to have the app never do that. I added a second in-circuit pass that rejects any empty claimed slot instead, at a cost of 61 extra instructions. That's the difference between "padding isn't a session" being a convention the app is trusted to follow, and being a property the circuit proves. In a system whose entire point is not trusting the client with the claim, the first version isn't good enough.
The optimisation that would have made it slower. Distinctness is a pairwise O(N²) comparison,
and every Compact loop fully unrolls at compile time, so that cost is real gates. The obvious fix
is to require sorted input and compare only neighbours — O(N). I went to implement it and found
that ordering comparisons on Bytes<32> aren't native in Compact: they need unpacking to a byte
vector and converting to a 256-bit integer, and the helper that does the unpacking alone is
annotated at 10,231 rows. Equality is native. The quadratic version of the cheap operation beats
the linear version of the expensive one by a wide margin at this size. So I measured instead of
guessing — 45 comparisons cost 430 instructions, 300 cost 2,045, 1,225 cost 7,743 — and set the
cap deliberately rather than discovering it at proving time.
And a long stupid one. Thirteen of twenty-two tests failed with Cannot read properties of
undefined. The cause: initialState() returns a ContractState, whose ledger you read through
.data, but a circuit call returns that inner state directly. My simulator stored both in one
field, so every test that ran a circuit before reading the ledger unwrapped one level too far. No
amount of staring found it; a ten-line script that just printed the shape of both objects found it
immediately.
One problem I ran into was not caused by my code. From late Saturday night into Sunday morning, Preview stopped accepting transactions properly. My proofs were still being created locally in about two seconds, the node could still answer requests, new blocks were still being created, and the RPC connection stayed online. But when I submitted a transaction, it would never actually go through.
I tried several times over about nine hours. I restarted the proof server and completely resynced the wallet, but it always got stuck at the same point.
The attestation shown in my demo is real. I submitted it earlier that day at block 632945, before the problem started, and it can be independently verified through the indexer.
Accomplishments that I'm proud of
Starting the weekend having never written a line of blockchain code, and ending it with a working zero-knowledge attestation flow against a real app I actually use.
It's not a demo app. SoundingBoard existed before this weekend, I built it for myself, and I use it. Practice Proof isn't a feature invented to have something to attach a blockchain to — it's the answer to a limitation I'd already run into and assumed was permanent. The privacy design and the proof are solving the same problem from opposite ends, which is why they fit.
The public footprint is as small as I could get it. One 32-byte hash and one integer between 1 and 10. Not encrypted, not anonymised — the transcripts, scores, scenarios and timestamps simply never enter the system that could leak them. I kept looking for something else that had to go on-chain and couldn't find anything.
Adding it made the app more private, not less. Wiring up the key storage, I noticed that "Delete all my data" cleared the stored keys but not the copy cached in memory, so a deleted identity would keep being used until the app restarted. That's a bug I'd have shipped in a feature whose entire premise is that deletion means deletion. Fixed, and I found the same latent pattern sitting in the existing device-id module.
I wrote down what it doesn't prove. The circuit shows the sessions are distinct, not that they were real. That's in the README and in this write-up rather than buried, because a proof you can't state the boundary of isn't worth much — and the incentive at a hackathon runs entirely the other way.
What I learned
A blockchain is a public database, and that's the whole problem. I came in with a vague sense that "crypto" and "privacy" went together. They don't, by default — every map, set and list on a Midnight contract is fully visible to anyone with an indexer. The privacy doesn't come from the chain. It comes from deciding what never goes on it. Which reframed the whole design: the interesting question was never "how do I prove this", it was "what is the smallest true thing I can publish?" For me that turned out to be one hash and one integer between 1 and 10.
The witness/ledger split is the actual mental model. Witnesses are values the prover supplies
locally and the contract never sees; the ledger is the public part. A circuit is the bridge, and
disclose() marks every crossing. Once I stopped reading disclose() as boilerplate and started
reading it as a budget — every call site is a decision to make something public, and the compiler
makes me write it down — the whole language made sense.
Cost is a design-time property here. Loops unroll at compile time, so vector widths have to be compile-time constants and "just make the maximum bigger later" isn't available. The capacity of my circuit is baked into the witness format, the export in the app, and the dApp that reads it. That's a very different relationship with a constant than I'm used to, and it's why I measured the cost curve before choosing rather than after.
Testing the seams matters more than testing the parts. The app derives commitments in React Native, exports JSON, and a browser dApp feeds them to the circuit. Nothing type-checks across those process boundaries — it's three languages' worth of assumptions meeting in a text file. So I generated the test fixture using the app's own derivation code and ran it straight through the real contract. If those ever drift apart, a test fails instead of a demo.
The most useful thing I learned is where my proof stops. It proves the commitments are distinct. It does not prove they came from real practice sessions, because the device generates them. That gap has a known fix — the server blind-signs a receipt it can't read — and naming it precisely was more valuable than anything I'd have gained by quietly hoping nobody asked. Knowing the exact boundary of what a proof establishes seems like most of the skill.
What's next for Practice Proof
- Countersigned sessions. Right now the commitments are generated purely on-device, so a determined user could fabricate them. The fix is for the Worker to issue a blind signed receipt on each completed feedback call, which the circuit then verifies — real unforgeability without the server learning anything about the conversation.
- Threshold proofs, not just counts: prove "my composure score averaged at least 4 across ten sessions" without revealing any individual score.
- Proving on-device, once mobile ZK tooling makes that practical, so the companion dApp becomes optional.
What already existed before this hackathon
Per the Integrate track rules, here is the honest split.
Existed before the event (public repo, last commit dd2ca6a on 2026-07-27, project parked):
the entire SoundingBoard app and Cloudflare Worker — rehearse and vent modes, persona system,
push-to-talk with on-device speech recognition, the feedback scoring pipeline, local history
storage, and its 73 passing Worker tests. None of it had anything to do with Midnight or
blockchain.
Built during the hackathon (all on the midnight-hackathon branch):
midnight/contract/— the Compact circuit, its witnesses, the shared witness-file format module, and 52 testsmidnight/phase0-smoke/scripts/— the deploy and attest CLIs, plus a wallet-session module that snapshots Preview sync state so a run costs seconds instead of thirteen minutesmidnight/attest-app/— the verification and witness-inspection dAppapp/src/lib/practiceProof.tsand the Practice Proof screen and export flowmidnight/README.md— thirteen documented traps, most of which cost real hours
Everything else in the repo predates the event and is untouched. The Worker was not modified at all, and its 73 tests still pass.
Built With
- anthropic-claude
- cloudflare-workers
- compact
- expo.io
- hono
- midnight-network
- react
- react-native
- typescript
- vite
- zero-knowledge-proofs
- zustand
Log in or sign up for Devpost to join the conversation.