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:

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:

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:

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:

  1. Verifies the proof off-chain as a sanity check (cheap).
  2. Wraps the proof + signals into a Rarimo L2 transaction.
  3. Signs the transaction with its own funded wallet (RMO gas).
  4. Broadcasts to Rarimo L2 RPC.
  5. 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 in RegistrationSMT (sparse Merkle tree of identity commitments). The user is now registered. The DS certificate hash is also added to CertificatesSMT if 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:

  1. Relying party (e.g. Taraaz) opens /v1/authorize?client_id=…&code_challenge=….
  2. sso-svc returns a nonce + redirects to the wallet via Universal Link.
  3. The wallet signs {nonce, client_id, app_attestation} with its BabyJubjub key.
  4. sso-svc verifies the signature + attestation, issues a one-time code.
  5. RP exchanges code + code_verifier for an access + refresh JWT.
  6. JWT's sub is a pairwise subjectHMAC(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:

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:

  1. "I am registered in the RegistrationSMT at root R."
  2. "My identity satisfies the proposal's selector" (citizenship, age, etc.).
  3. "My nullifier for event_id = proposal.id is N."
  4. "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:

  1. Calls the BN254 pairing precompile at address 0x08.
  2. Checks nullifier-not-used.
  3. Increments the vote count for option X.
  4. 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

DataLives on phone?Sent off-device?On-chain?Lifetime
Wallet private key (BabyJubjub)✅ Secure enclave❌ Never❌ NeverPermanent until uninstall
Wallet public key✅ At registrationPermanent in sso-svc DB
MRZ string✅ In memorySeconds (NFC session)
DG1, DG2, DG13, DG15✅ In memoryUntil proof generated, then zeroed
SOD + DS cert✅ In memoryUntil proof generated, then zeroed
ZK witness✅ In memorySeconds, then zeroed
ZK proof (256 bytes)✅ Briefly✅ To relayer✅ Part of tx dataPermanent on chain
Public signals✅ Briefly✅ To relayer✅ Part of tx dataPermanent on chain
Nullifier✅ Briefly✅ To relayer✅ Recorded in contractPermanent on chain
DS cert hash✅ Briefly✅ To relayer✅ Once per cert in CertificatesSMTPermanent on chain
Identity commitment✅ Briefly✅ To relayer✅ Once in RegistrationSMTPermanent on chain
Vote choice (option X)✅ Briefly✅ Inside proof✅ Counted in contractPermanent on chain
Pairwise subject (SSO)✅ In JWT to RPPer-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.