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:

…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:

ObjectSizeWhere it comes from
Proof~256 bytesOutput of the Groth16 prover after running the circuit on private inputs
Public signals~24 numbers, ~768 bytesThe circuit's declared outputs: nullifier, ICAO root, event id, dates, citizenship, etc.
Circuit IDa short stringTells 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:

The verification key (committed once, per circuit) gives the contract:

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):

StepWhere it runsTimeCost
Generate witnessPhone (native module)~20–60 secBattery
Generate proofPhone (rapidsnark-wrp)~30–90 secBattery
Submit transactionRelayer~1 sec~$0.001 RMO gas
Verify on-chainSmart 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 classCircuit IDVerification key
Iranian biometric passport (RSA 2048, SHA-256, e=65537)passport_rsa_2048_sha256_e65537vk1.json
Iranian biometric passport (RSA 3072, SHA-1, e=58333)passport_rsa_2048_sha1_e58333vk2.json
Iranian National ID cardinid_rsa_2048vk3.json
German passport (ECDSA brainpoolP384r1) — plannedpassport_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:

IndexMeaningSensitive?
0Nullifier (specific to this event)No — unlinkable to identity
5Event ID (which proposal)No — public anyway
6Citizenship code (e.g. IR)No — country-level only
8Personal number hash (INID only)No — hashed; no preimage on-chain
10Identity creation timestampNo — wall-clock
12Selector (which assertions to publish)No — published by design
13Current dateNo — wall-clock
21Citizenship-check result (Iran? yes/no)No — boolean
22RegistrationSMT rootNo — 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 modeWhat we do about it
Wrong verification key loadedCircuit-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 attackNullifier mapping prevents it within an event.
Pairing precompile bug at chain levelInherited Ethereum risk — has not happened to BN254 in production.
Smart contract upgrade with a back-doorMitigated 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

TermMeaning
Groth16A specific zk-SNARK proof system, 2016. Smallest verifier, widely adopted.
BN254The elliptic curve our proofs live on. Pairing-friendly, EVM-precompiled.
PrecompileA 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 signalsThe circuit's declared outputs, visible on-chain. Never include raw document data.
PairingA bilinear function e(P, Q) on elliptic-curve points; the only "magic" operation in Groth16 verify.
WitnessPhone-side intermediate values; never uploaded.
NullifierIdentity-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.