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 inheritsPlonkVerifier.PlonkVerifier.sol: the auto-generated snarkJS PLONK verifier.
The entry point is verifySnarkProof. It takes four parameters,
either succeeds silently or reverts with InvalidProof():
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:
| Parameter | Description |
|---|---|
programVK | Fingerprint of the guest program. Unique per compiled ELF. |
rootCVadcopFinal | Commitment to the VADCOP circuit. Fixed per ZisK version. |
publicValues | The outputs the guest committed, encoded as bytes. |
proofBytes | The raw PLONK proof, decoded internally as uint256[24]. |
How verification works
Before checking the proof, the contract builds a public values digest:
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.
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:
| Method | Description |
|---|---|
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. |
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:
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:
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:
// 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
- Choosing a proof format: understand when to use PLONK versus STARK or Compressed.
- Managing guest I/O: read the public values the guest committed before passing them to the contract.