Skip to main content

Overview

The light-verifier crate provides ZK proof verification for Light Protocol. It verifies Groth16 proofs for inclusion, non-inclusion (address creation), and combined operations using the groth16-solana library. Crate: light-verifier
Location: program-libs/verifier/
Error Codes: 13001-13007
Proof System: Groth16 over BN254

Key Features

Inclusion Proofs

Verify 1-20 compressed accounts exist in Merkle trees

Non-Inclusion Proofs

Verify 1-32 addresses don’t exist (for creation)

Combined Proofs

Single proof for both inclusion and address creation

Batch Updates

Verify batched tree updates (10, 250, 500 leaves)

Proof Types

Inclusion Proofs

Verify compressed accounts exist in state trees:
use light_verifier::verify_inclusion_proof;

let roots = vec![merkle_root];
let leaves = vec![account_hash];

verify_inclusion_proof(
    &roots,
    &leaves,
    &compressed_proof,
)?;
Supported counts:
  • V1: 1, 2, 3, 4, 8 accounts
  • V2: 1-20 accounts

Non-Inclusion Proofs

Verify addresses don’t exist in address trees (for creation):
use light_verifier::verify_create_addresses_proof;

let address_roots = vec![address_tree_root];
let addresses = vec![new_address];

verify_create_addresses_proof(
    &address_roots,
    &addresses,
    &compressed_proof,
)?;
Supported counts:
  • V1: 1, 2, 3, 4, 8 addresses
  • V2: 1-32 addresses

Combined Proofs

Single proof for both operations:
use light_verifier::verify_create_addresses_and_inclusion_proof;

verify_create_addresses_and_inclusion_proof(
    &[state_root],          // State tree roots
    &[account_hash],        // Account hashes to verify
    &[address_root],        // Address tree roots
    &[new_address],         // New addresses to create
    &compressed_proof,
)?;
Supported combinations:
  • V1: Various combinations up to 4 accounts + 4 addresses
  • V2: Various combinations up to 4 accounts + 4 addresses

Batch Update Proofs

Verify batched tree updates:
Verify batch append to state tree output queue.
pub fn verify_batch_append_with_proofs(
    batch_size: u64,
    public_input_hash: [u8; 32],
    compressed_proof: &CompressedProof,
) -> Result<(), VerifierError>
Batch sizes: 10, 500Public input hash: H(old_root, new_root, hash_chain, start_index)
Verify batch nullifier updates (state tree input queue).
pub fn verify_batch_update(
    batch_size: u64,
    public_input_hash: [u8; 32],
    compressed_proof: &CompressedProof,
) -> Result<(), VerifierError>
Batch sizes: 10, 500Public input hash: H(old_root, new_root, hash_chain)
Verify batch address updates (address tree).
pub fn verify_batch_address_update(
    batch_size: u64,
    public_input_hash: [u8; 32],
    compressed_proof: &CompressedProof,
) -> Result<(), VerifierError>
Batch sizes: 10, 250Public input hash: H(old_root, new_root, hash_chain, next_index)

CompressedProof

Groth16 proof structure (128 bytes total):
pub struct CompressedProof {
    pub a: [u8; 32],    // G1 point (compressed)
    pub b: [u8; 64],    // G2 point (compressed)
    pub c: [u8; 32],    // G1 point (compressed)
}
Serialization:
  • a: Compressed G1 point on BN254 (32 bytes)
  • b: Compressed G2 point on BN254 (64 bytes)
  • c: Compressed G1 point on BN254 (32 bytes)
Generation: Proofs are generated by the Light Protocol prover service using Gnark (Go)

Verifying Keys

Key Selection

Different verifying keys for different proof types:
use light_verifier::select_verifying_key;

// Get verifying key for 3 accounts + 2 addresses
let vk = select_verifying_key(3, 2)?;

// Keys are embedded in the verifier crate

Naming Convention

Verifying key modules follow this pattern:
  • V1 Inclusion: v1_inclusion_<height>_<count>
    • Example: v1_inclusion_26_4 (height 26, 4 accounts)
  • V1 Non-Inclusion: v1_non_inclusion_<height>_<count>
    • Example: v1_non_inclusion_26_2 (height 26, 2 addresses)
  • V1 Combined: v1_combined_<state_height>_<addr_height>_<state_count>_<addr_count>
    • Example: v1_combined_26_26_2_1 (2 accounts, 1 address)
  • V2 Inclusion: v2_inclusion_<height>_<count>
    • Example: v2_inclusion_32_10 (height 32, 10 accounts)
  • V2 Non-Inclusion: v2_non_inclusion_<height>_<count>
    • Example: v2_non_inclusion_40_5 (height 40, 5 addresses)
  • V2 Combined: v2_combined_<state_height>_<addr_height>_<state_count>_<addr_count>
    • Example: v2_combined_32_40_3_2 (3 accounts, 2 addresses)
  • Batch Operations:
    • batch_append_<height>_<batch_size>: Batch append proofs
    • batch_update_<height>_<batch_size>: Batch nullify proofs
    • batch_address_append_<height>_<batch_size>: Batch address proofs

Tree Heights

V1 Trees: Height 26 (67M leaves) V2 Trees:
  • State trees: Heights 26, 30, 32, 40
  • Address trees: Heights 26, 30, 32, 40

Error Codes

CodeErrorDescription
13001PublicInputsTryIntoFailedFailed to convert public inputs to array
13002DecompressG1FailedFailed to decompress G1 point
13003DecompressG2FailedFailed to decompress G2 point
13004InvalidPublicInputsLengthPublic inputs count doesn’t match proof type
13005CreateGroth16VerifierFailedFailed to create verifier
13006ProofVerificationFailedProof verification failed
13007InvalidBatchSizeUnsupported batch size (must be 10, 250, or 500)

Usage Examples

Basic Inclusion Proof

use light_verifier::{
    verify_inclusion_proof,
    CompressedProof,
};

// Get proof from indexer/prover
let proof = get_validity_proof(&account_hashes).await?;

// Verify on-chain
verify_inclusion_proof(
    &proof.roots,
    &proof.leaves,
    &proof.compressed_proof,
)?;

Create New Addresses

use light_verifier::verify_create_addresses_proof;

// Derive new addresses
let addresses = vec![
    derive_address(&[b"user", owner.as_ref()], &program_id)?,
];

// Get proof from prover
let proof = get_address_proof(&addresses).await?;

// Verify addresses don't exist
verify_create_addresses_proof(
    &proof.address_roots,
    &addresses,
    &proof.compressed_proof,
)?;

Combined Operation

use light_verifier::verify_create_addresses_and_inclusion_proof;

// Spending accounts + creating new ones
verify_create_addresses_and_inclusion_proof(
    &proof.roots,              // State tree roots
    &input_account_hashes,     // Accounts to spend
    &proof.address_roots,      // Address tree roots  
    &output_addresses,         // New addresses
    &proof.compressed_proof,
)?;

Batch Tree Update

use light_verifier::verify_batch_append_with_proofs;

// Forester updating tree
let public_input_hash = Poseidon::hashv(&[
    &old_root,
    &new_root,
    &hash_chain,
    &start_index.to_le_bytes(),
])?;

verify_batch_append_with_proofs(
    batch_size,
    public_input_hash,
    &proof,
)?;

Verification Flow

On-Chain Verification Steps

1

Select Verifying Key

Choose correct key based on account/address counts and tree heights
2

Decompress Proof Points

Decompress G1/G2 points from compressed format
3

Create Verifier

Initialize Groth16 verifier with proof and verifying key
4

Verify Pairing

Check pairing equation: e(A, B) = e(α, β) * e(L, γ) * e(C, δ)
5

Return Result

Success or specific error code

Public Inputs

Inclusion proof:
[root_1, leaf_1, root_2, leaf_2, ...]
Non-inclusion proof:
[root_1, address_1, root_2, address_2, ...]
Combined proof:
[state_root_1, leaf_1, ..., addr_root_1, address_1, ...]
Batch proof:
[public_input_hash]
Where public_input_hash = H(old_root, new_root, hash_chain, [optional_index])

Performance

Compute Units

Approximate CU costs:
Proof TypeAccounts/AddressesCU Cost
Inclusion1~90,000
Inclusion4~92,000
Inclusion8~95,000
Non-Inclusion1~90,000
Non-Inclusion4~92,000
Combined2 + 2~95,000
Batch (10)-~95,000
Batch (500)-~95,000
Note: CU cost is relatively constant regardless of proof complexity

Optimization Tips

Combine multiple account operations into a single proof when possible. Combined proofs are more efficient than separate inclusion + non-inclusion proofs.
Proofs are tied to specific roots. If the tree hasn’t changed, the same proof can be reused (though this is rare in practice).
Use the minimum required key size. Don’t request a proof for 8 accounts if you only need 2.

Circuit Details

Inclusion Circuit

Proves: Account hash is a leaf in the Merkle tree with given root Private inputs:
  • Merkle proof (path from leaf to root)
  • Path indices (left/right at each level)
Public inputs:
  • Root
  • Leaf (account hash)

Non-Inclusion Circuit

Proves: Address doesn’t exist in indexed Merkle tree Private inputs:
  • Low leaf (closest existing address < new address)
  • Merkle proof for low leaf
  • Next leaf (closest existing address > new address)
  • Proof that new address is between low and next
Public inputs:
  • Root
  • New address

Batch Append Circuit

Proves: Correct insertion of batch of leaves Private inputs:
  • All leaves in batch
  • Merkle proofs for insertions
Public inputs:
  • Old root
  • New root
  • Hash chain (commitment to leaves)
  • Start index

Testing

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_inclusion_proof() {
        // Mock proof data
        let roots = vec![[1u8; 32]];
        let leaves = vec![[2u8; 32]];
        let proof = CompressedProof {
            a: [0u8; 32],
            b: [0u8; 64],
            c: [0u8; 32],
        };
        
        // This will fail with mock data, but shows API
        let result = verify_inclusion_proof(
            &roots,
            &leaves,
            &proof,
        );
        
        // Real proofs from prover will succeed
    }
}

Best Practices

Never skip proof verification, even in development. Invalid proofs indicate serious issues.
Proofs are tied to specific Merkle roots. Always use recent roots from current slot.
Proof verification failures should halt execution. Don’t continue with state changes.
Only request proofs for the exact number of accounts/addresses needed. Larger proofs waste resources.

Feature Flags

solana
feature
Enables Solana SDK integration and error conversions
pinocchio
feature
Enables Pinocchio SDK integration

Resources

Source Code

View on GitHub

API Docs

Rust documentation

Groth16 Paper

Original Groth16 paper

Prover Service

ZK proof generation service

Build docs developers (and LLMs) love