On-Chain Verifier Program
The ZKVAULT Solana verifier program is a highly optimized BPF program that verifies zk-SNARK proofs directly on-chain with minimal compute unit (CU) consumption.
BPF Constraints
Solana's Berkeley Packet Filter (BPF) runtime imposes several constraints that shape the verifier design:
Memory Limitations
- Stack size: 4 KB maximum
- Heap size: 32 KB default (can request up to 256 KB)
- Account data: 10 MB maximum per account
- Call depth: 64 maximum
Compute Budget
- Default CU limit: 200,000 per instruction
- Maximum CU limit: 1,400,000 per transaction
- CU price: ~$0.000005 per 1M CU (approximately)
Arithmetic Constraints
- Native integer ops: u8, u16, u32, u64, u128 supported
- No native u256: Must implement big integer arithmetic in software
- No floating point: All operations must be integer-based
Instruction Formats
The verifier program exposes the following instruction set:
1. InitVault
InitVault {
vault_owner: Pubkey, // Owner's public key
verification_key_hash: [u8; 32], // Hash of circuit VK
}
Accounts:
[writable, signer] payer // Pays for vault creation
[writable] vault_pda // Vault account (PDA)
[] system_program // System program
Seeds for PDA:
["vault", owner_pubkey, vk_hash]
Effects:
• Creates vault account at PDA address
• Initializes vault state
• Stores VK hash for proof verification2. SubmitProof
SubmitProof {
proof_bytes: Vec<u8>, // Compressed proof (128 bytes)
public_inputs: Vec<u64>, // Public inputs to circuit
nonce: u64, // Unique proof identifier
}
Accounts:
[writable, signer] submitter // Proof submitter
[writable] vault_pda // Target vault
[] verifier_program // This program
Effects:
• Decompresses proof from bytes
• Verifies proof against vault's VK
• Updates vault state with verification result
• Emits ProofVerified event3. VerifyProof
VerifyProof {
proof_id: u64, // Proof to verify
}
Accounts:
[] vault_pda // Vault containing proof
[] verifier_program // This program
Returns:
• bool: Proof verification result
• u64: Timestamp of verification
• Pubkey: Submitter addressSyscall Limitations
BPF programs can only call approved syscalls:
Available syscalls used by ZKVAULT:
• sol_log() - Logging (debugging)
• sol_sha256() - SHA-256 hashing
• sol_keccak256() - Keccak-256 hashing (not used, but available)
• sol_get_clock_sysvar() - Current timestamp
• sol_create_program_address() - PDA derivation
• sol_try_find_program_address() - PDA search with bump
• sol_memcpy() - Memory copy
• sol_memset() - Memory set
• sol_memmove() - Memory move
NOT available (must implement in program):
• Elliptic curve operations (pairing, mul, add)
• Big integer arithmetic (256-bit field math)
• Polynomial evaluationRe-Entrancy Prevention
The verifier implements several security checks to prevent re-entrancy attacks:
// State lock pattern
struct VaultState {
is_locked: bool,
owner: Pubkey,
vk_hash: [u8; 32],
nonce: u64,
// ... other fields
}
fn verify_proof(vault: &mut VaultState, proof: Proof) -> Result<bool> {
// 1. Check not already locked
if vault.is_locked {
return Err(ErrorCode::VaultLocked);
}
// 2. Acquire lock
vault.is_locked = true;
// 3. Perform verification
let result = verify_groth16(&proof, &vault.vk_hash)?;
// 4. Release lock
vault.is_locked = false;
Ok(result)
}
// Additional checks:
• Nonce must be strictly increasing
• Submitter must sign transaction
• Vault owner cannot be changed after initGas (CU) Optimization Patterns
The verifier applies several optimizations to minimize compute unit consumption:
1. Precomputed Tables
// Store precomputed multiples of base points
// Reduces scalar multiplication cost from O(n) to O(n/w)
const WINDOW_SIZE: usize = 4;
const TABLE_SIZE: usize = 1 << WINDOW_SIZE; // 16
static G1_MULTIPLES: [G1Point; TABLE_SIZE] = [
G1_GENERATOR * 0,
G1_GENERATOR * 1,
G1_GENERATOR * 2,
// ... up to G1_GENERATOR * 15
];
// Windowed scalar multiplication
fn scalar_mul(scalar: &[u8; 32], table: &[G1Point]) -> G1Point {
let mut result = G1Point::identity();
for window in scalar.windows(WINDOW_SIZE) {
result = result.double_n(WINDOW_SIZE);
let index = window_to_index(window);
result = result.add(&table[index]);
}
result
}2. Batch Inversion
// Computing n modular inverses individually: O(n log p)
// Batch inversion using Montgomery's trick: O(n + log p)
fn batch_invert(elements: &[FieldElement]) -> Vec<FieldElement> {
let n = elements.len();
let mut products = Vec::with_capacity(n);
let mut acc = FieldElement::one();
// Forward pass: compute products
for &elem in elements {
products.push(acc);
acc = acc.mul(&elem);
}
// Invert the final product (single expensive operation)
let mut inv = acc.invert();
// Backward pass: compute individual inverses
let mut inverses = Vec::with_capacity(n);
for i in (0..n).rev() {
inverses.push(products[i].mul(&inv));
inv = inv.mul(&elements[i]);
}
inverses.reverse();
inverses
}
// Saves ~60% compute units when inverting multiple elements3. Lazy Reduction
// Don't reduce modulo p after every operation
// Accumulate in larger integer type, reduce once at end
fn mul_accumulate(a: &[u64; 4], b: &[u64; 4], c: &mut [u128; 8]) {
// Multiply without reduction
for i in 0..4 {
for j in 0..4 {
c[i + j] += (a[i] as u128) * (b[j] as u128);
}
}
// Reduce later when converting back to FieldElement
}
// Reduces number of expensive modulo operations by ~3xProgram-Derived Address (PDA) Rules
Vaults are stored at PDAs to ensure deterministic addresses and ownership:
// PDA derivation for vault
fn find_vault_pda(
owner: &Pubkey,
vk_hash: &[u8; 32],
program_id: &Pubkey
) -> (Pubkey, u8) {
let seeds = &[
b"vault", // Static prefix
owner.as_ref(), // User's public key
vk_hash, // Circuit VK hash
];
Pubkey::find_program_address(seeds, program_id)
}
// Properties:
• Deterministic: Same inputs always yield same PDA
• Unique: Different (owner, vk_hash) pairs yield different PDAs
• Secure: Only program can sign for PDA
• Off-curve: PDA is not a valid ed25519 public key (no private key exists)
// This ensures:
✓ Users can compute vault address without RPC call
✓ Each user can have multiple vaults (different circuits)
✓ Program has exclusive write authority
✓ No private key can be compromisedVerification Implementation
The core verification logic in Rust (simplified):
pub fn verify_groth16(
proof: &Proof,
public_inputs: &[FieldElement],
vk: &VerificationKey,
) -> Result<bool> {
// 1. Validate proof structure
if !proof.A.is_on_curve() || !proof.B.is_on_curve() || !proof.C.is_on_curve() {
return Err(ErrorCode::InvalidProof);
}
// 2. Compute linear combination of public inputs
let mut vk_x = vk.ic[0];
for (i, input) in public_inputs.iter().enumerate() {
vk_x = vk_x.add(&vk.ic[i + 1].mul(input));
}
// 3. Compute pairing equation:
// e(A, B) = e(alpha, beta) * e(vk_x, gamma) * e(C, delta)
// Left side
let lhs = pairing(&proof.A, &proof.B);
// Right side (precomputed parts cached in VK)
let rhs = vk.alpha_beta_pairing
.mul(&pairing(&vk_x, &vk.gamma))
.mul(&pairing(&proof.C, &vk.delta));
// 4. Check equality
Ok(lhs == rhs)
}
// Compute unit breakdown:
• Point validation: ~10k CU
• Linear combination: ~5k CU per public input
• Pairing computation: ~80k CU each (x3 = 240k CU)
• Final equality check: ~2k CU
• Total: ~260k CU for typical proof with 4 public inputsAccount Structure
#[account]
pub struct Vault {
pub owner: Pubkey, // 32 bytes
pub vk_hash: [u8; 32], // 32 bytes
pub nonce: u64, // 8 bytes
pub is_locked: bool, // 1 byte
pub created_at: i64, // 8 bytes
pub proof_count: u64, // 8 bytes
pub last_verified_proof: Option<ProofMetadata>, // 1 + 64 bytes
// Total: ~154 bytes base
}
#[derive(AnchorSerialize, AnchorDeserialize)]
pub struct ProofMetadata {
pub proof_id: u64, // 8 bytes
pub submitter: Pubkey, // 32 bytes
pub timestamp: i64, // 8 bytes
pub public_inputs_hash: [u8; 32], // 32 bytes
// Total: 80 bytes
}For integration examples, see Solana Program Integration.