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 verification

2. 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 event

3. 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 address

Syscall 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 evaluation

Re-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 init

Gas (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 elements

3. 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 ~3x

Program-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 compromised

Verification 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 inputs

Account 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.