Explainer 03
On-chain verification: what 256 bytes and one pairing buy you
The puzzle
The smart contract that accepts our votes lives on Rarimo L2. It must decide, within a single transaction, whether a user:
- holds a real Iranian passport or INID,
- has not voted on this proposal before,
- is old enough and a citizen of Iran,
…all without ever seeing the document.
The naïve approach — recompute the entire passport signature check inside the smart contract — is impossible. It would require running millions of arithmetic operations on-chain, costing hundreds of dollars per vote.
Instead, the contract does one tiny check that stands in for the whole computation. That tiny check is what makes ZK identity practical.
What the user uploads
The phone does the heavy work. It produces three things:
| Object | Size | Where it comes from |
|---|---|---|
| Proof | ~256 bytes | Output of the Groth16 prover after running the circuit on private inputs |
| Public signals | ~24 numbers, ~768 bytes | The circuit's declared outputs: nullifier, ICAO root, event id, dates, citizenship, etc. |
| Circuit ID | a short string | Tells the contract which verification key to use (passport_rsa_2048_sha256_e65537, inid_rsa_2048, …) |
That is all the chain ever sees about the user. No name, no passport number, no signature. Just a proof + a fixed-size list of numbers + a label.
What the contract does — in five lines
function verify(circuitID, proof, publicSignals) {
VerifyingKey vk = circuitRegistry[circuitID]; // lookup
require(publicSignals.icaoRoot == currentRoot); // sanity check
require(!nullifierUsed[publicSignals.nullifier]); // anti-replay
require(groth16Verify(vk, proof, publicSignals)); // THE math step
nullifierUsed[publicSignals.nullifier] = true;
}
Everything is bookkeeping except the fourth line. That fourth line is the entire reason any of this works.
The fourth line: what groth16Verify actually does
A Groth16 proof consists of three elliptic-curve points:
- A on curve G1 (~64 bytes)
- B on curve G2 (~128 bytes)
- C on curve G1 (~64 bytes)
The verification key (committed once, per circuit) gives the contract:
- Two fixed points α and β
- One fixed point γ
- One fixed point δ
- One point per public signal, called IC₀, IC₁, …
The contract computes a linear combination over the public signals:
ICₚ = IC₀ + s₁·IC₁ + s₂·IC₂ + … + sₙ·ICₙ
…and then checks a single equation involving a pairing — a special operation on elliptic-curve points written as e(·, ·):
e(A, B) == e(α, β) · e(ICₚ, γ) · e(C, δ)
If this equation holds, the proof is valid. If it doesn't, it isn't. There is no middle ground, and no other thing to check.
That single equation is mathematically equivalent to verifying every multiplication and addition the prover claimed they did. The compression ratio is staggering: millions of constraints on the prover's side collapse into one pairing check on the verifier's side. This is the heart of "succinct" in zk-SNARK.
Why the EVM can do this in milliseconds
Ethereum-compatible chains include a special address — 0x08 — that performs the BN254 pairing operation in native code, not in interpreted EVM. This is called a precompiled contract. Calling it costs a fixed amount of gas (~120k for the pairing + ~6k per public signal). On Rarimo L2 at ~$0.001/transaction, that's well under a cent per verification.
Without the precompile, on-chain ZK verification would not be economically feasible. Rarimo L2, like Ethereum and every major L2, includes it. This is the same primitive that powers tornado-style mixers, Aztec rollups, and zk-EVM chains; we are reusing decades of cryptographic engineering, not inventing it.
Asymmetry: prover vs. verifier
The numbers below are typical for our registration circuit (~5M constraints):
| Step | Where it runs | Time | Cost |
|---|---|---|---|
| Generate witness | Phone (native module) | ~20–60 sec | Battery |
| Generate proof | Phone (rapidsnark-wrp) | ~30–90 sec | Battery |
| Submit transaction | Relayer | ~1 sec | ~$0.001 RMO gas |
| Verify on-chain | Smart contract | ~3 ms | ~120k gas |
This asymmetry is the whole point. The phone does work proportional to the circuit (millions of operations); the chain does constant-time work no matter how complex the circuit is. A 5-million-constraint passport check and a 5-billion-constraint check would both verify in roughly the same 3 ms.
Why we need a circuit registry
A single Groth16 verification key works for exactly one circuit. Different documents and signing algorithms need different circuits:
| Document class | Circuit ID | Verification key |
|---|---|---|
| Iranian biometric passport (RSA 2048, SHA-256, e=65537) | passport_rsa_2048_sha256_e65537 | vk1.json |
| Iranian biometric passport (RSA 3072, SHA-1, e=58333) | passport_rsa_2048_sha1_e58333 | vk2.json |
| Iranian National ID card | inid_rsa_2048 | vk3.json |
| German passport (ECDSA brainpoolP384r1) — planned | passport_ecdsa_p384_sha384 | (M7) |
Each circuit ID maps to a distinct verification key. Our sso-svc keeps a registry of these keys; the wallet tells the service which circuit it used, and the service picks the right key. Adding a new document class is a config change, not a code change.
What public signals reveal — and don't
The contract reads the public signals as fixed-position numbers. For the INID query circuit they are 23 values; for the passport circuit, 24. Concrete map for an INID registration:
| Index | Meaning | Sensitive? |
|---|---|---|
| 0 | Nullifier (specific to this event) | No — unlinkable to identity |
| 5 | Event ID (which proposal) | No — public anyway |
| 6 | Citizenship code (e.g. IR) | No — country-level only |
| 8 | Personal number hash (INID only) | No — hashed; no preimage on-chain |
| 10 | Identity creation timestamp | No — wall-clock |
| 12 | Selector (which assertions to publish) | No — published by design |
| 13 | Current date | No — wall-clock |
| 21 | Citizenship-check result (Iran? yes/no) | No — boolean |
| 22 | RegistrationSMT root | No — chain state |
There is no field for name, document number, photo hash, birth date, or expiry date. Those values participate in the proof but are consumed by the circuit — they never appear in the public signals or on-chain. See zero-knowledge proofs for why this is a mathematical guarantee, not a policy.
Nullifiers: how the contract enforces one-vote-per-person
A nullifier is deterministic: same secret + same event_id always produces the same nullifier. But it is also one-way: given the nullifier, no one can recover the secret or link it to the user's identity.
The contract simply records every nullifier it has seen. A second proof from the same person on the same proposal would produce the same nullifier; the contract rejects it.
Across events, the same person produces different nullifiers — by design. That is what makes the system anonymous between events: nobody can link your vote on proposal #42 to your vote on proposal #43, even though both are valid.
What can still go wrong on-chain
Honest list:
| Failure mode | What we do about it |
|---|---|
| Wrong verification key loaded | Circuit-registry config is reviewed and pinned; tests verify each VK before deploy. |
currentRoot outdated (chain forks, snapshot drift) | We maintain the ICAO root in our StateKeeper; M6 makes us the sole admin. See sovereign stack and M6. |
| Replay attack | Nullifier mapping prevents it within an event. |
| Pairing precompile bug at chain level | Inherited Ethereum risk — has not happened to BN254 in production. |
| Smart contract upgrade with a back-door | Mitigated by moving to Gnosis Safe multisig before public launch. |
None of these is a cryptographic failure of the proof itself. They are all operational risks of running the contract, and they are addressable with process and governance — which is what M6 is about.
Glossary
| Term | Meaning |
|---|---|
| Groth16 | A specific zk-SNARK proof system, 2016. Smallest verifier, widely adopted. |
| BN254 | The elliptic curve our proofs live on. Pairing-friendly, EVM-precompiled. |
| Precompile | A native-code Ethereum operation at a reserved address (0x08 = pairing). Cheap, fast. |
| Verifying key (VK) | Small file (~few KB) that the contract uses to check proofs from one specific circuit. |
| Public signals | The circuit's declared outputs, visible on-chain. Never include raw document data. |
| Pairing | A bilinear function e(P, Q) on elliptic-curve points; the only "magic" operation in Groth16 verify. |
| Witness | Phone-side intermediate values; never uploaded. |
| Nullifier | Identity-blinding deterministic anti-replay value, per-event. |
Next
Passport trust chain — where the data in the proof actually comes from, why we trust it, and what we can't protect against if the issuer's key is compromised.