Submitting Your First Proof
This is where it connects
You have a verification key registered. You have a proof generated by your circuit. Now you submit that proof to zkVerify, and the chain does the work.
This lesson walks through the complete submission flow using zkverifyjs — from starting a session to reading the transaction result. By the end you will have a proof verified on-chain and a statement hash you can use for aggregation.
Prerequisites
Before submitting a proof you need:
- A funded account on zkVerify. On testnet (Volta) you can get tVFY from the faucet. On mainnet you need VFY. - Your proof artifacts — the proof file, public inputs, and either the full verification key or the vkHash from registration. - zkverifyjs installed: npm i zkverifyjs
Starting a session
Every interaction with zkVerify begins with a session. A session establishes the connection to the network and attaches your account for signing transactions.
```typescript import { zkVerifySession, ZkVerifyEvents, Library, CurveType } from "zkverifyjs"; import fs from "fs";
// For testnet const session = await zkVerifySession .start() .Testnet() .withAccount(process.env.SEED_PHRASE);
// For mainnet const session = await zkVerifySession .start() .zkVerify() .withAccount(process.env.SEED_PHRASE); ```
If you only need to read data and not send transactions, you can start a read-only session by omitting .withAccount(). For proof submission you always need a full session with an account.
Submitting a proof
Load your proof artifacts and call verify() with your proof type. The example below uses Groth16 generated by snarkjs, with a registered verification key.
```typescript const proof = JSON.parse(fs.readFileSync("./data/proof.json")); const publicInputs = JSON.parse(fs.readFileSync("./data/public.json")); 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 domainId field is optional but important. If you include it, your verified proof will be included in that domain's aggregation batch and eventually settled on a destination chain. If you omit it, the proof is verified and recorded on zkVerify but not aggregated. More on domains in the next lesson.
Listening to events
The events object is an EventEmitter. Three events matter for proof submission:
```typescript events.on(ZkVerifyEvents.IncludedInBlock, (eventData) => { console.log("Proof included in block:", eventData); // txHash and blockHash are available here // This confirms the proof reached the chain });
events.on(ZkVerifyEvents.Finalized, (eventData) => { console.log("Proof finalized:", eventData); // The block containing your proof is now finalized // statement is available here — save it });
events.on(ZkVerifyEvents.ErrorEvent, (eventData) => { console.error("Verification failed:", eventData); }); ```
IncludedInBlock fires first and means the proof is in a proposed block. Finalized fires after consensus and means the result is permanent. For most applications, you want to wait for Finalized before acting on the result.
Reading the transaction result
The transactionResult promise resolves after the transaction finalizes and gives you the full result:
```typescript let transactionInfo;
try { transactionInfo = await transactionResult; } catch (error) { throw new Error(Transaction failed: ${error.message}); }
console.log(transactionInfo); // { // blockHash: "0x4cf46a...", // txHash: "0xfe125e...", // status: "finalized", // statement: "0xc5a838...", ← your proof's statement hash // domainId: 0, // aggregationId: 137 // } ```
The statement is a 32-byte hash representing your specific proof within the Merkle tree. Save this. It is what you use to retrieve your Merkle path once the aggregation is published.
Submitting without a registered key
If you skipped registration and want to submit the full verification key inline, remove .withRegisteredVk() and pass the raw key object directly:
```typescript const key = JSON.parse( fs.readFileSync("./data/main.groth16.vkey.json") );
const { events, transactionResult } = await session .verify() .groth16({ library: Library.snarkjs, curve: CurveType.bn128 }) .execute({ proofData: { vk: key, proof: proof, publicSignals: publicInputs } }); ```
This works but costs more per transaction. For a single proof in a test, it is fine. For production use, register the key first.
Batch submission
If you have multiple proofs of the same type to submit at once, use batchVerify(). All proofs must use the same proof type and configuration:
``typescript const { events, transactionResult } = await session .batchVerify() .groth16({ library: Library.snarkjs, curve: CurveType.bn128 }) .withRegisteredVk() .execute([ { proofData: { vk: vkey.hash, proof: proof1, publicSignals: pubs1 }, domainId: 0 }, { proofData: { vk: vkey.hash, proof: proof2, publicSignals: pubs2 }, domainId: 0 } ]); ``
Common errors and what they mean
Invalid proof — the proof bytes could not be deserialized. Check your proof format matches what the pallet expects for your proof type.
Verification failed — the proof deserialized correctly but the cryptographic check failed. The proof does not satisfy the circuit for the given public inputs and verification key.
vkHash not registered — you passed a hash with .withRegisteredVk() but never ran the registration transaction. Go back to lesson 7 and register first.
Insufficient funds — your account does not have enough VFY to pay the transaction fee. Get tVFY from the faucet on testnet.
What happens next
Once your proof is finalized on zkVerify, it enters the aggregation engine. In the next lesson we cover what domains are, how aggregation works, and how the resulting Merkle root lands on a destination chain your smart contract can read.
Answer the quiz correctly to continue →
After a proof is finalized on zkVerify, what should you save from the transaction result to use for aggregation later?