Sharing something that changed how I build on Midnight, plus where I got stuck afterwards.
The lesson: I was using a Set to check whether someone was on an approved list. Seemed obvious. It is wrong. A Set reveals which element you tested. Set.member(x) discloses x, so every check leaks exactly who is being checked, and if the same person checks in repeatedly, all their visits become linkable. I would have shipped that without noticing.
The right tool is a HistoricMerkleTree with the membership path supplied as a witness. The person proves they are somewhere in the tree without revealing where. I picked this up from @DR.Ecovery’s thread on ZKP compliance for 42 CFR Part 2 (ZKP compliance for 42 CFR Part 2 — substance use treatment privacy), a check-in app for a drug recovery centre using HistoricMerkleTree plus a path witness plus nullifiers, so nobody can tell which patient checked in. Seeing it applied to something with real stakes made it land far better than reading the rule on its own. The disclosure docs state it plainly too, and it is an easy line to skim past.
Then that lesson created two problems I had not anticipated.
1. Proving membership works. Proving optional membership does not.
I am building a credential where two of three independent issuers have to vouch for you. So I need to ask each tree “is this person in you?” and be able to accept “no”. If they are not in that tree, the witness cannot produce a valid path, and the whole proof dies. A threshold becomes impossible to express.
What worked: when the person is not in a tree, the witness returns a well formed but deliberately invalid path, correct shape with dummy siblings. The recomputed root matches nothing, checkRoot returns false, and the circuit carries on. That lets you write (a && b) || (a && c) || (b && c).
On the TypeScript side the witness is just tree.findPathForLeaf(leaf) ?? dummyPath(leaf), where the dummy is { sibling: { field: 0n }, goes_left: false } repeated to the tree depth.
2. Revocation fights unlinkability.
The obvious way to revoke someone is a blocklist. But checking one means disclosing a stable value about that person on every proof, which undoes the privacy you just bought. (Brave Research flagged this exact tension last November: privacy preserving revocation is underdeveloped and hard to reconcile with unlinkability guarantees.)
What worked: bind each leaf to a time period, and re-attest on a schedule. Revoking someone is then simply not renewing them. They hold no valid leaf for the new period. No blocklist, nothing disclosed. Same idea as the web moving to short lived certificates instead of revocation lists. The cost is that revocation takes effect at the next period rather than instantly.
Small gotcha while wiring up tests: a Compact Counter shows up on the generated TypeScript Ledger type as a plain bigint. It is ledger.myCounter, not ledger.myCounter.read().
Still building this out as a proof of concept. The contract compiles on 0.31.1 and the tests pass. I will share it once it is in a state worth reading. If there is a better way to do either of those two things, I would genuinely like to hear it.
One last thing, said with affection. I am self taught, and somewhere between the failed proofs and the third rewrite I worked out that the part I enjoy most is not making the compiler happy. It is figuring out what the thing should be and who it is actually for. If that is a job in crypto, something in the design thinking and operations direction rather than a pure dev seat, I would love to hear how people ended up in those roles.