Registering Your Verification Key

The step most tutorials skip

Every zkVerify integration tutorial eventually shows you how to submit a proof. What most of them gloss over is the step that should happen first: registering your verification key.

This lesson covers what registration is, why it matters for your costs, and exactly how to do it using both zkverifyjs and the Relayer API.

What a verification key is

When you compile a ZK circuit, you get two keys. The proving key is used to generate proofs and stays private. The verification key is public — it encodes the structure of your circuit and is what anyone needs to check that a proof is valid.

Every time you submit a proof to zkVerify, the chain needs this verification key to run the check. You have two options for providing it:

Option A — send the full verification key with every proof submission. Simple, but the key gets included in every transaction. Verification keys can be large, and you pay for every byte.

Option B — register the key once, get back a 32-byte hash, and reference that hash in every future submission. The chain looks up the key from storage instead of deserializing it fresh each time.

For any application submitting more than a handful of proofs, option B is the correct default. The registration transaction costs a small amount of VFY once. Every subsequent proof submission is cheaper because it carries a hash instead of the full key.

💡
Registration is required once per circuit. If your circuit changes, the verification key changes, and you register again. The same key used across thousands of proofs only needs to be registered once.

When to skip registration

The official documentation notes one scenario where skipping registration is reasonable: hackathon projects or proof-of-concept builds where you are submitting a small number of proofs and iteration speed matters more than cost optimization.

For anything moving toward production, register the key.

Registering with zkverifyjs

Start a session with your account seed phrase. The account needs VFY to pay for the registration transaction.

```typescript import { zkVerifySession, ZkVerifyEvents, Library, CurveType } from "zkverifyjs"; import fs from "fs";

const key = JSON.parse(fs.readFileSync("./data/main.groth16.vkey.json"));

const session = await zkVerifySession .start() .Testnet() .withAccount(process.env.SEED_PHRASE);

const { regevent } = await session .registerVerificationKey() .groth16({ library: Library.snarkjs, curve: CurveType.bn128 }) .execute(key);

regevent.on(ZkVerifyEvents.Finalized, (eventData) => { console.log("Registration finalized:", eventData); fs.writeFileSync( "vkey.json", JSON.stringify({ hash: eventData.statementHash }, null, 2) ); }); ```

When the transaction finalizes, a VkRegistered event is emitted containing the statementHash — this is your vkHash. Save it. You will pass this hash instead of the full key in every proof submission from this point forward.

The saved vkey.json will look like this:

``json { "vkey": "0x828c736b33ab492251a8b275468a29ce06e98fc833c0c7f0bc7f6272b300c05b" } ``

Using the vkHash in proof submissions

Once registered, add .withRegisteredVk() to your verify call and pass the hash as the vk field instead of the full key:

```typescript const vkey = JSON.parse(fs.readFileSync("./vkey.json"));

const { events, transactionResult } = await session .verify() .groth16({ library: Library.snarkjs, curve: CurveType.bn128 }) .withRegisteredVk() .execute({ proofData: { vk: vkey.hash, proof: proof, publicSignals: publicInputs }, domainId: 0 }); ```

The .withRegisteredVk() flag tells the pallet to look up the key from storage using the hash rather than expecting the full key in the transaction.

Registering via the Relayer API

If you are using the Relayer instead of zkverifyjs, the registration step is a GET request:

```javascript import axios from "axios";

const API_URL = "https://relayer-api.horizenlabs.io/api/v1";

const params = { proofType: "groth16", proofOptions: { library: "snarkjs", curve: "bn128" }, vk: verificationKey };

const response = await axios.get( ${API_URL}/register-vk/${process.env.API_KEY}, { params } );

fs.writeFileSync("vkey.json", JSON.stringify(response.data)); ```

The response contains the vkHash which you then pass in all subsequent submit-proof calls with "vkRegistered": true.

What each proof type requires

Registration is supported across all proof types but the configuration options differ per pallet. Here is what to specify for the most common ones:

```typescript // Groth16 session.registerVerificationKey() .groth16({ library: Library.snarkjs, curve: CurveType.bn128 }) .execute(vk)

// RISC Zero session.registerVerificationKey() .risc0() .execute(proof.image_id)

// UltraHonk session.registerVerificationKey() .ultrahonk() .execute(vk.split("\n")[0])

// UltraPlonk session.registerVerificationKey() .ultraplonk({ numberOfPublicInputs: 2 }) .execute(vk)

// SP1 session.registerVerificationKey() .sp1() .execute(vk) ```

For RISC Zero, the verification key is the image ID of your guest program, not a separate key file. For UltraHonk, pass only the first line of the vk file. Each pallet has a specific expected format — check the proof type documentation if registration fails on deserialization.

What happens if you submit a hash with no registration

If you pass a vkHash to submitProof without having registered it first, the chain throws a specific error:

Answer the quiz correctly to continue →

Quiz · Multiple Choice1 / 2

Why do you register a verification key on zkVerify before submitting proofs?