Case Study
BlindRoute: proving a delivery happened without saying what was delivered
A delivery escrow on Midnight where the courier releases the payment by proving, in zero knowledge, that they know a secret — without that secret, the buyer's address, or anyone's identity ever touching the ledger. The contract is four circuits and a clock; the hard part was deciding exactly which facts the public chain is allowed to learn.
Role
Solo Developer
Timeline
Jul – Aug 2026
Network
Midnight Preview
Tests
25 cases in CI
The problem
Escrow for a physical delivery needs an answer to one question: did the package arrive? On a public blockchain, the usual answer is to publish the evidence — a tracking number, a delivery hash, a courier receipt. That works, and it leaks.
Anyone reading the ledger can now see a buyer's purchasing pattern, their delivery cadence, and enough metadata to infer a great deal about them. The centralized alternative isn't better: logistics providers cache plaintext addresses, which turns every one of them into a breach waiting to happen. Both approaches treat "verify the delivery" and "reveal the delivery" as the same operation.
They aren't. That's the whole thesis of this project: verification and visibility are separable, and zero-knowledge proofs are how you separate them.
Drawing the line between public and private
The design work here wasn't writing circuits — it was deciding, field by field, what the ledger is allowed to know. Too much on-chain and the privacy claim is theatre; too little and neither party can prove the contract behaved correctly.
Public on the ledger
Escrow state (EMPTY / LOCKED / RELEASED), the payment amount, a commitment hash, derived public keys for payer and courier, a shared tick clock, and the refund deadline.
Why: either party — or an outside observer — must be able to confirm the escrow exists, holds what it claims, and resolved legitimately.
Private, never transmitted
The delivery-proof secret (standing in for a GPS ping, drop-off code, or recipient signature) and each party's identity secret key. Generated in the browser, held in memory for the session only.
Why: this is precisely the data the contract exists to keep off-chain. It reaches no server, no wallet call, and no DOM node.
What an observer sees is an escrow appear with an amount and a hash, then later flip to RELEASED alongside a valid proof. What they cannot recover at any point is the secret behind that hash, or any link between a derived public key and a real person. I enforced the same boundary in the UI as in the contract: the private secret is never assigned to an element or written to a log line — only the public commitment and transaction results are ever rendered.
Four circuits and a clock
lockEscrow — the customer commits
Publishes the amount, a hash commitment of the delivery secret, and a refund deadline expressed in ticks. The secret itself stays in the browser; only its commitment goes on-chain.
releaseEscrow — the zero-knowledge step
The courier proves they know a value that hashes to the recorded commitment, without disclosing it. This is the circuit that makes the whole thing work — and it is noticeably slower than the others, because its proving key is roughly 5MB against ~150KB for the lock circuit. Proof generation happens locally, so that cost is paid on the courier's machine, not by a server that would have to be trusted.
refundEscrow — the escape hatch
The first version had a real flaw: if the courier simply never released, the customer's funds were locked forever. Adding a refund path meant introducing a shared tick counter as an on-chain clock — once it passes the deadline set at lock time, and only for the original payer, the funds can be reclaimed.
Twenty-five test cases cover this: circuit logic, the full state machine including double-lock and double-release rejections, the refund path (too-early rejection, successful refund after the deadline, refusing anyone who isn't the payer, re-locking afterwards), and a privacy test asserting no public circuit call exposes or mutates private state. All of it runs in CI on every push, against a pinned Compact compiler version.
Working on unfinished infrastructure
Building on a young network means the ground moves. Three decisions came directly from that.
Deployed to Preview, not Preprod
Preprod's indexer and dust-sponsor infrastructure proved unstable at submission time. I confirmed the substitution with the organizers rather than shipping something that looked deployed but couldn't be demonstrated.
Let the wallet do the hard parts
I wrote a Node CLI first, and its wallet sync hit an SDK bug where progress never advanced past zero while leaking memory. Rather than fight someone else's sync implementation, the browser app delegates sync, balancing, signing and proving to Lace entirely. The CLI stays in the repo, documented as the alternate path, with its limitation stated plainly.
Treat error messages as a feature
Most failures here are environmental, not logical — a popup blocker eating the signing window, a competing wallet extension, dust not yet accumulated. Eleven of the tests cover error handling alone: distinguishing a user dismissing a prompt from a genuine failure, and unwrapping nested error shapes into something a human can act on. On infrastructure this raw, a clear error is worth more than another feature.
What I'd do differently
Model the failure path before the happy path
I built lock-and-release first and treated "courier never shows up" as an edge case to handle later. It isn't an edge case — it's the scenario escrow exists for. Retrofitting the refund meant adding a clock to a contract that hadn't been designed to need one. The timeout should have been in the state machine from the first sketch.
Check proving cost per circuit early
The 30× difference in proving key size between release and lock is a UX fact, not a footnote — it's the difference between an instant action and one that needs a progress indicator and an explanation. I discovered it by feel, late. Measuring each circuit's proving cost as it's written would have shaped the interface sooner.
Write the privacy table on day one
The public/private field table became the most useful artifact in the repo — it's what makes the privacy claim auditable instead of aspirational. I wrote it while documenting, but it would have been a better design tool than a summary, because every "should this be on-chain?" question was already answered by it.
Want something like this built?
I build full-stack and on-chain products end to end — contracts, frontend, deployment, and the careful thinking about what a system should and shouldn't reveal.