Proving you're over 21 on the phone: building an age-check dApp with the Kuira SDK

I spent this sprint building Doorman, an Android dApp on the Kuira SDK. The idea came from a boring situation: she walks up to a bar and hands over a driving licence carrying her name, her address, her licence number and her exact date of birth, so a stranger can answer one question. Is she over 21.

Doorman answers that question and nothing else. A registered issuer enrols her once, and what lands on-chain is a single opaque hash binding her identity, her birth year and the issuer who vouched for it. No name, no document number, no date of birth. At the door her phone proves she holds a valid enrolment and that the year inside it clears the threshold. The venue gets one bit and a pseudonym unique to that venue, so two bars comparing notes cannot tell they served the same person.

Why this has to be a phone app. The proof is generated on the device. That is the only reason the data can stay there. Build it as a website and something has to be uploaded to a server to be proven, and you are back to a vendor holding her ID. My first sketch actually had that flaw: encrypt the ID, send it to a “proofer”, let the proofer decrypt and vouch. That is not zero knowledge, it just moves the honeypot.

What proving on the phone is actually like. Slower than you expect and less magical than it sounds. A call takes 30 to 120 seconds on an emulator, and the SDK’s staged progress bar (execute, prove, balance, submit) is doing real work in each stage. The proving keys are around 33MB. It is not a spinner you can hide.

Five things that cost me an evening. Building a contract with witnesses turned out to be off the documented path, because both Kuira reference apps (the counter and bboard) have no witnesses and no Merkle tree. In order:

  1. Every witness the contract declares must be present in the handle, even ones the circuit you are calling never reads.
  2. WitnessResult takes a ByteArray and nothing else, whatever the Compact type is.
  3. deploy() passes no constructor arguments, so your constructor must declare none.
  4. The constructor cannot read witnesses at all. That rules out the usual deployer-becomes-owner pattern, since you cannot set a sealed ledger field from localSecretKey(). I claim the registry in a separate circuit right after deploy instead, and that gap is a race I am not happy about.
  5. A contract declaring a HistoricMerkleTree does not deploy at all on alpha05. It dies inside initialState before any circuit runs. I confirmed it by compiling and deploying the same contract twice with only the tree removed.

That last one is the real constraint. It means the membership proof, and therefore the actual age proof, is not in the deployed build. What runs today is the issuer half: a registry, licensed issuers, and enrolment. The full contract is in the repo and the tests cover the whole design, including a regression test for a Merkle path-binding bug I found last week. I would rather ship the honest half and say so than fake the demo.

All five are now a PR against the cookbook.

What I would build next. The browser version, because the TypeScript SDK can query the tree for a membership path and the full design works there. Then bind the full date rather than just the year, which the circuit has the headroom for: proveOfAge is k=15 at 17,607 rows, and k only moves at powers of two, so there are about 15,000 rows spare for free.

1 Like

Hey this is great feedback for Kuira Labs! sorry you had constraints that prevented you from completing your dApp on mobile. We will resolve these issues on alpha06.

1 Like