Skip to main content

On-Chain Verification

ZisK PLONK proofs are constant-size and can be verified by a smart contract on any EVM-compatible chain. This guide walks through the verifier contract, how to extract the proof and public values from Rust, deploying the contract, and calling it from your own Solidity code.

Prerequisites

Before starting, make sure you have:

  • A PLONK SNARK proof. See Choosing a proof output for how to generate one.
  • Foundry installed for contract deployment.
  • An RPC endpoint and a funded account on your target chain.

The verifier contract

The verifier contract lives in zisk-contracts/. It is made up of three files:

  • IZiskVerifier.sol: the interface your contracts call.
  • ZiskVerifier.sol: the implementation, which inherits PlonkVerifier.
  • PlonkVerifier.sol: the auto-generated snarkJS PLONK verifier.

The entry point is verifySnarkProof. It takes four parameters, either succeeds silently or reverts with InvalidProof():

IZiskVerifier.sol
interface IZiskVerifier {
function verifySnarkProof(
uint64[4] calldata programVK,
uint64[4] calldata rootCVadcopFinal,
bytes calldata publicValues,
bytes calldata proofBytes
) external view;

function getRootCVadcopFinal() external pure returns (uint64[4] memory);

function hashPublicValues(
uint64[4] calldata programVK,
uint64[4] calldata rootCVadcopFinal,
bytes calldata publicValues
) external pure returns (uint256);
}

Parameters:

ParameterDescription
programVKFingerprint of the guest program. Unique per compiled ELF.
rootCVadcopFinalCommitment to the VADCOP circuit. Fixed per ZisK version.
publicValuesThe outputs the guest committed, encoded as bytes.
proofBytesThe raw PLONK proof, decoded internally as uint256[24].

How verification works

Before checking the proof, the contract builds a public values digest:

Verifier.sol
uint256 publicValuesDigest = hashPublicValues(programVK, rootCVadcopFinal, publicValues);

hashPublicValues packs the components into a preimage in this exact order:

programVK (32 bytes) | publicValues (variable) | rootCVadcopFinal (32 bytes)

Each uint64[4] array is packed as four bytes8 values (32 bytes total). The SHA-256 hash of this preimage is then reduced modulo the BN254 scalar field.

warning

The preimage order is programVK | publicValues | rootCVadcopFinal. Getting this order wrong is the most common source of verification failure.


Byte order

ZisK stores values internally in little-endian. Solidity and the EVM use big-endian. Every uint64 and uint32 must be byte-swapped before being passed to the contract.

The SDK provides helper methods that handle all conversions automatically:

MethodDescription
publics.public_bytes_solidity()Converts 64 public u32 values from LE to BE (256 bytes).
publics.bytes_solidity(&program_vk, vadcop_verkey)Builds the full hash preimage with all byte swaps applied.
publics.hash_solidity(&program_vk, vadcop_verkey)Returns the SHA-256 digest directly.
warning

Getting the endianness wrong is the single most common cause of on-chain verification failure. Always use the SDK helpers and never construct the preimage manually.


Getting the values from Rust

All the proof data you need to call the verifier contract comes out of the proving pipeline. Build a PLONK proof, then extract the program VK, public values, and proof bytes:

host/src/main.rs
use zisk_sdk::{GuestProgram, ProverClient, ZiskProof, ZiskStdin};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ProverClient::default();

let program = GuestProgram::from_uri(
"target/riscv64ima-zisk-zkvm-elf/release/my-guest",
)?;

client.setup(&program).run()?;

let mut stdin = ZiskStdin::new();
stdin.write(&10u32);

let handle = client.prove(&program, stdin).plonk().submit()?;
let result = handle.proof().await?;

let program_vk = result.get_program_vk();
let publics = result.get_publics();
let proof = result.get_proof();

let proof_bytes = match proof {
ZiskProof::Plonk(bytes) => bytes,
_ => panic!("Expected PLONK proof"),
};

let public_values_be = publics.public_bytes_solidity();

println!("Proof size: {} bytes", proof_bytes.len());
println!("Public values: {} bytes", public_values_be.len());

Ok(())
}

Deploying the verifier

The verifier is stateless and immutable. Deploy it once and reuse it for every guest program. Deploy with Foundry:

bash
forge create --rpc-url $RPC_URL \
--private-key $PRIVATE_KEY \
ZiskVerifier.sol:ZiskVerifier

Save the deployed address. You will pass it to your application contract in the next step.


Calling the verifier from your contract

With the verifier deployed, your contract can call verifySnarkProof to trustlessly check any proof before acting on its result. Call getRootCVadcopFinal() to fetch the VADCOP commitment directly from the verifier rather than hardcoding it:

MyVerifiedApp.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IZiskVerifier {
function verifySnarkProof(
uint64[4] calldata programVK,
uint64[4] calldata rootCVadcopFinal,
bytes calldata publicValues,
bytes calldata proofBytes
) external view;

function getRootCVadcopFinal() external pure returns (uint64[4] memory);
}

contract MyVerifiedApp {
IZiskVerifier public immutable verifier;

event ComputationVerified(uint256 result);

constructor(address _verifier) {
verifier = IZiskVerifier(_verifier);
}

function submitProof(
uint64[4] calldata programVK,
bytes calldata publicValues,
bytes calldata proofBytes,
uint256 claimedResult
) external {
uint64[4] memory rootC = verifier.getRootCVadcopFinal();
verifier.verifySnarkProof(programVK, rootC, publicValues, proofBytes);
emit ComputationVerified(claimedResult);
}
}

verifySnarkProof reverts with InvalidProof() if the proof is invalid, so the rest of the function only runs when the proof is accepted.


Gas costs

Gas cost is dominated by the EC pairing operations inside the PLONK verifier. The cost is constant regardless of guest program complexity or input size. Verifying a simple hash costs the same as verifying a complex state transition.


Next steps