Explainer 05
The full flow: from NFC tap to on-chain vote
This file traces one user, one vote, end to end. Every other explainer covers a slice in depth; this one walks the whole pipeline and shows where each piece of data lives and dies.
If you only read one explainer, read this one second (after #01).
The three phases
1. Registration
one-time
2. Wallet sign-in
per session
3. Voting
per proposal
Registration is heavy: NFC scan, ZK proof, on-chain write. Done once per document. Wallet sign-in is local: PIN / biometric, no chain interaction. Voting is medium-weight: a smaller "query" ZK proof and one on-chain transaction per ballot.
Phase 1: Registration
Step 1.1 — User installs Jomhoor wallet
The wallet app is downloaded from the App Store or Play Store. On first launch, it:
- Generates a BabyJubjub private key locally (random, ~32 bytes).
- Stores it in the platform secure-storage (iOS Keychain / Android Keystore), encrypted by the OS.
- Sets up biometric / PIN unlock.
- Performs app attestation (App Attest on iOS, Play Integrity on Android) and registers the wallet with
sso-svc. This blocks emulators, rooted devices, and modified app builds in production.
Data created: wallet private key. Lives only on this device, ever. Data sent off-device: wallet public key (BabyJubjub {x, y}) + attestation payload.
Step 1.2 — User scans the MRZ with the camera
The wallet uses the device camera to OCR the machine-readable zone of their passport or INID. This is a one-line text scan; the data is needed to derive the NFC session key.
Data created: MRZ string (document number, birth date, expiry). Lifetime: held in app memory, used to derive BAC/PACE keys, then discarded.
Step 1.3 — User taps the document on the phone (NFC)
The wallet establishes a secure NFC session with the chip using BAC (older passports) or PACE (newer passports / INIDs). Over this session it reads:
- DG1 — MRZ data
- DG2 — face image
- DG13 (INID) — personal data
- DG15 (passports with active authentication) — auth public key
- SOD — signed hash list
- For INID specifically, the signing certificate and the authentication certificate are read via the Pardis/Mav4 APDU command sequence.
Data created: the entire chip dump. Lives only in app memory; never written to disk, never sent off-device.
Step 1.4 — App selects the right ZK circuit
The wallet inspects the Document Signer certificate to determine:
- signature algorithm (RSA vs. ECDSA)
- hash algorithm (SHA-1 / SHA-256 / SHA-384)
- key size (2048 / 3072 / 4096 bits) or curve
- document type (TD1 for INID/ID cards, TD3 for passports)
Based on that, it picks a circuit ID like passport_rsa_2048_sha256_e65537 or inid_rsa_2048.
Step 1.5 — Witness generation
The wallet feeds the chip data + the user's secret + the current ICAO root into the chosen circuit's WASM. A native module (witnesscalculator) computes the witness — the full list of intermediate values that satisfy the circuit's millions of constraints. This takes 20–60 seconds.
Data created: witness (~10–100 MB, in memory). Discarded after proof generation.
Step 1.6 — Proof generation
The wallet calls the Groth16 prover (rapidsnark-wrp, native module). Input: the witness + the circuit's proving key (~hundreds of MB, pre-bundled in the app). Output: a 256-byte proof and ~24 public signals. 30–90 seconds of phone CPU, fans on, battery drains.
Data created: 256-byte proof + public signals (nullifier, ICAO root, citizenship code, dates). These are the only artifacts that will leave the phone.
Step 1.7 — Submit to the registration relayer
The wallet POSTs the proof + public signals to:
https://api.iranians.vote/integrations/registration-relayer/v1/register
The relayer is a thin Go service. It:
- Verifies the proof off-chain as a sanity check (cheap).
- Wraps the proof + signals into a Rarimo L2 transaction.
- Signs the transaction with its own funded wallet (RMO gas).
- Broadcasts to Rarimo L2 RPC.
- Returns the transaction hash to the wallet.
The relayer does not see the user's document data — only the public proof + signals. It exists to pay gas, not to gate access.
Data created on-chain: an entry inRegistrationSMT(sparse Merkle tree of identity commitments). The user is now registered. The DS certificate hash is also added toCertificatesSMTif it wasn't already.
Step 1.8 — App writes a "registered" marker locally
The wallet stores: the wallet's secret-derived identity commitment, the transaction hash, and the circuit ID used. Nothing about the document itself.
Data destroyed at this step: MRZ, DG1/DG2/DG13/DG15, SOD, certificates, witness. All zeroed in memory.
Phase 1 complete. The user can now sign in and vote. They never need to scan the document again, unless they reinstall the app or rotate their wallet.
Phase 2: Wallet sign-in
When the user opens the app, they unlock the wallet with Face ID / Touch ID / PIN. The OS decrypts the BabyJubjub private key from secure storage; it lives in memory only for the duration of the session.
For "Sign in with Jomhoor" SSO (used by Taraaz, Civic-Compass, future partners), the flow is:
- Relying party (e.g. Taraaz) opens
/v1/authorize?client_id=…&code_challenge=…. sso-svcreturns a nonce + redirects to the wallet via Universal Link.- The wallet signs
{nonce, client_id, app_attestation}with its BabyJubjub key. sso-svcverifies the signature + attestation, issues a one-timecode.- RP exchanges
code + code_verifierfor an access + refresh JWT. - JWT's
subis a pairwise subject —HMAC(secret, wallet_id : client_id)— so different RPs see different identifiers for the same user. No correlation across services.
No chain interaction in this phase. The trust is bootstrapped from Phase 1's on-chain registration, but the sign-in protocol itself is purely off-chain.
Phase 3: Voting
Step 3.1 — User opens a proposal
The wallet fetches active proposals from the voting contract on Rarimo L2 and displays them. Each proposal carries:
- a numeric ID
- a description (off-chain, IPFS-pinned)
- accepted-options bitmask (e.g.
[7]= three options: Yes / No / Abstain) - selector specifying which assertions are required (
citizenship == IR,is_18_plus, etc.) - whitelist data (country codes + date bounds, see proposal config)
Step 3.2 — User picks an option
The wallet binds the choice to the proposal ID locally. No signature yet.
Step 3.3 — Query-proof generation
This is a smaller, faster ZK proof than the registration proof. It does NOT re-scan the document; it uses the secret already stored in the wallet.
It proves:
- "I am registered in the
RegistrationSMTat root R." - "My identity satisfies the proposal's selector" (citizenship, age, etc.).
- "My nullifier for
event_id = proposal.idis N." - "I am voting for option X."
This proof takes ~10–30 seconds on a phone. Output: ~256 bytes + ~23 public signals (INID circuit) or ~24 (passport circuit).
Step 3.4 — Submit to the voting relayer
https://api.iranians.vote/integrations/proof-verification-relayer/v3/vote
Relayer wraps the proof into a Rarimo L2 transaction, signs with its funded wallet, broadcasts. The voting contract:
- Calls the BN254 pairing precompile at address
0x08. - Checks nullifier-not-used.
- Increments the vote count for option X.
- Records the nullifier as used.
If the user tries to vote again: contract returns the key already exists → relayer returns 400 Bad Request → wallet shows "Already Voted" screen.
Step 3.5 — Result
The transaction is mined within ~2 seconds on Rarimo L2. The user sees their receipt: transaction hash, vote recorded, option chosen.
Phase 3 complete for this proposal. The user can vote on the next proposal independently — same wallet, different nullifier.
Where every piece of data lives, in one table
| Data | Lives on phone? | Sent off-device? | On-chain? | Lifetime |
|---|---|---|---|---|
| Wallet private key (BabyJubjub) | ✅ Secure enclave | ❌ Never | ❌ Never | Permanent until uninstall |
| Wallet public key | ✅ | ✅ At registration | ❌ | Permanent in sso-svc DB |
| MRZ string | ✅ In memory | ❌ | ❌ | Seconds (NFC session) |
| DG1, DG2, DG13, DG15 | ✅ In memory | ❌ | ❌ | Until proof generated, then zeroed |
| SOD + DS cert | ✅ In memory | ❌ | ❌ | Until proof generated, then zeroed |
| ZK witness | ✅ In memory | ❌ | ❌ | Seconds, then zeroed |
| ZK proof (256 bytes) | ✅ Briefly | ✅ To relayer | ✅ Part of tx data | Permanent on chain |
| Public signals | ✅ Briefly | ✅ To relayer | ✅ Part of tx data | Permanent on chain |
| Nullifier | ✅ Briefly | ✅ To relayer | ✅ Recorded in contract | Permanent on chain |
| DS cert hash | ✅ Briefly | ✅ To relayer | ✅ Once per cert in CertificatesSMT | Permanent on chain |
| Identity commitment | ✅ Briefly | ✅ To relayer | ✅ Once in RegistrationSMT | Permanent on chain |
| Vote choice (option X) | ✅ Briefly | ✅ Inside proof | ✅ Counted in contract | Permanent on chain |
| Pairwise subject (SSO) | ❌ | ✅ In JWT to RP | ❌ | Per-session JWT lifetime |
The pattern is consistent: document data never leaves the device; only the cryptographic proof and its declared public outputs do. Everything on-chain is either a hash, a count, or a randomized commitment.
A walk-through narrative (the non-technical version)
For when an Iranian voter abroad asks "what happens when I tap my passport on the app?", here is the same story without jargon:
You scan the first page of your passport with the camera. You tap the passport against your phone — the chip inside the passport talks to your phone over short-range radio.
Your phone reads the photo, the name, the dates, and a tiny digital signature from the chip — proof from the Iranian Civil Registry that the document is real.
Your phone then does something clever: it builds a mathematical receipt that says "I have a real Iranian passport, I am over 18, my name and number do not appear here." This receipt is about as small as a tweet. Your passport data is then deleted from your phone.
The receipt is uploaded to the blockchain — a public, tamper-proof ledger that runs in dozens of countries. The blockchain checks the math, accepts the receipt, and now you are registered: a real Iranian citizen, eligible to vote, with no name attached.
When a vote happens, you produce a smaller receipt: "I'm a registered user, I am voting Yes." The blockchain counts it, marks you as having voted on this question, and you cannot vote on the same question twice.
Nobody knows your name. Nobody can take your account away. Nobody can delete your vote.
That paragraph is the only version of the system most readers will ever need. Everything in this folder exists to support it being true.