Docs
/architecture
/Solana Program

Solana Program

On-chain implementation details and PDA patterns

Solana Program Architecture

The Veil on-chain program is built with the Anchor framework and handles zero-knowledge proof verification, state management, and transaction processing on Solana.

Program Overview

Program Type: Anchor-based Solana program Language: Rust Framework: Anchor v0.28+ Deployment: Solana devnet/mainnet Compute Budget: 200k-400k CU per instruction

Core Responsibilities:

  1. Verify Groth16 zkSNARK proofs
  2. Maintain Merkle tree of commitments
  3. Track spent nullifiers (double-spend prevention)
  4. Manage root history buffer (front-running protection)
  5. Handle token vault operations

State Accounts

PrivacyPool Account

The main state account for the privacy protocol.

Structure:

#[account]
pub struct PrivacyPool {
    /// Authority that can upgrade the program
    pub authority: Pubkey,                    // 32 bytes
 
    /// Current Merkle tree root
    pub merkle_root: [u8; 32],                // 32 bytes
 
    /// Number of commitments in the tree
    pub leaf_count: u64,                      // 8 bytes
 
    /// Circular buffer of recent roots (front-running protection)
    pub root_history: [[u8; 32]; 30],         // 960 bytes
 
    /// Current index in root history buffer
    pub current_root_index: u8,               // 1 byte
 
    /// Vault authority bump seed
    pub vault_bump: u8,                       // 1 byte
 
    /// Pool creation timestamp
    pub created_at: i64,                      // 8 bytes
 
    /// Total deposits
    pub total_deposits: u64,                  // 8 bytes
 
    /// Total withdrawals
    pub total_withdrawals: u64,               // 8 bytes
 
    /// Reserved for future use
    pub _reserved: [u64; 64],                 // 512 bytes
}

Total Size: ~1692 bytes

PDA Derivation:

let (pool_pda, bump) = Pubkey::find_program_address(
    &[b"privacy_pool"],
    program_id
);

Initialization:

  • One-time setup via initialize instruction
  • Sets initial authority
  • Initializes Merkle root to empty tree hash
  • Prepopulates root history with initial root

NullifierMarker Account

Tracks spent nullifiers to prevent double-spending.

Structure:

#[account]
pub struct NullifierMarker {
    /// Associated privacy pool
    pub pool: Pubkey,              // 32 bytes
 
    /// The spent nullifier
    pub nullifier: [u8; 32],       // 32 bytes
 
    /// Slot number when nullifier was spent
    pub spent_at: u64,             // 8 bytes
 
    /// Transaction signature that spent this nullifier
    pub spent_by: [u8; 64],        // 64 bytes
}

Total Size: ~136 bytes + 8 byte discriminator = ~144 bytes

PDA Derivation:

let (nullifier_pda, bump) = Pubkey::find_program_address(
    &[
        b"nullifier",
        pool.key().as_ref(),
        nullifier.as_ref(),  // 32 bytes
    ],
    program_id
);

Creation:

  • Created via Anchor's init constraint
  • Rent paid by transaction payer (relayer or user)
  • Attempting to create duplicate PDA fails → double-spend prevented

Storage Cost:

  • Rent: ~0.00089 SOL per nullifier
  • Permanent (no rent collection after deposit)
  • Total cost for 1M notes: 890 SOL (90kat90k at 100/SOL)

Vault Accounts

Hold deposited tokens in escrow.

SOL Vault:

  • Regular Solana account owned by program
  • Controlled by vault authority PDA

SPL Token Vaults:

  • SPL Token Accounts for each supported token
  • Owner: vault authority PDA
  • One vault per token type

Vault Authority PDA:

let (vault_authority, bump) = Pubkey::find_program_address(
    &[b"vault", pool.key().as_ref()],
    program_id
);

Purpose:

  • Signs token transfers on behalf of the program
  • Ensures only valid proofs can withdraw funds
  • No private key exists (PDA-derived authority)

Instructions

1. Initialize

Creates the privacy pool and sets initial state.

Accounts:

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,
 
    #[account(
        init,
        payer = authority,
        space = 8 + 1692,
        seeds = [b"privacy_pool"],
        bump
    )]
    pub pool: Account<'info, PrivacyPool>,
 
    pub system_program: Program<'info, System>,
}

Logic:

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    let pool = &mut ctx.accounts.pool;
 
    pool.authority = ctx.accounts.authority.key();
    pool.merkle_root = EMPTY_TREE_ROOT;  // Precomputed for depth 20
    pool.leaf_count = 0;
    pool.current_root_index = 0;
 
    // Initialize root history with empty root
    for i in 0..30 {
        pool.root_history[i] = EMPTY_TREE_ROOT;
    }
 
    pool.created_at = Clock::get()?.unix_timestamp;
 
    Ok(())
}

Called: Once per deployment

2. Shield / ShieldSOL

Deposit public tokens and create private commitment.

Accounts (Shield SOL):

#[derive(Accounts)]
pub struct ShieldSOL<'info> {
    #[account(mut)]
    pub depositor: Signer<'info>,
 
    #[account(mut)]
    pub pool: Account<'info, PrivacyPool>,
 
    #[account(mut)]
    pub vault: SystemAccount<'info>,
 
    pub system_program: Program<'info, System>,
}

Instruction Data:

pub struct ShieldSOLData {
    pub commitment: [u8; 32],
    pub amount: u64,
}

Logic:

pub fn shield_sol(ctx: Context<ShieldSOL>, data: ShieldSOLData) -> Result<()> {
    let pool = &mut ctx.accounts.pool;
 
    // 1. Transfer SOL to vault
    anchor_lang::system_program::transfer(
        CpiContext::new(
            ctx.accounts.system_program.to_account_info(),
            Transfer {
                from: ctx.accounts.depositor.to_account_info(),
                to: ctx.accounts.vault.to_account_info(),
            },
        ),
        data.amount,
    )?;
 
    // 2. Add commitment to tree
    let leaf_index = pool.leaf_count;
    pool.leaf_count += 1;
 
    // 3. Compute new Merkle root
    let new_root = compute_merkle_root(data.commitment, leaf_index)?;
 
    // 4. Update root history
    pool.root_history[pool.current_root_index as usize] = new_root;
    pool.current_root_index = (pool.current_root_index + 1) % 30;
    pool.merkle_root = new_root;
 
    // 5. Update stats
    pool.total_deposits += data.amount;
 
    emit!(CommitmentAdded {
        commitment: data.commitment,
        leaf_index,
        merkle_root: new_root,
    });
 
    Ok(())
}

Compute Units: ~50,000 CU

3. Transfer

Spend a note privately and create a new note for the recipient.

Accounts:

#[derive(Accounts)]
pub struct Transfer<'info> {
    #[account(mut)]
    pub payer: Signer<'info>,  // Usually relayer
 
    #[account(mut)]
    pub pool: Account<'info, PrivacyPool>,
 
    #[account(
        init,
        payer = payer,
        space = 8 + 144,
        seeds = [b"nullifier", pool.key().as_ref(), nullifier.as_ref()],
        bump
    )]
    pub nullifier_marker: Account<'info, NullifierMarker>,
 
    pub system_program: Program<'info, System>,
}

Instruction Data:

pub struct TransferData {
    pub proof: [u8; 256],           // Groth16 proof
    pub merkle_root: [u8; 32],      // Root proof was generated against
    pub nullifier: [u8; 32],        // Prevents double-spend
    pub new_commitment: [u8; 32],   // Output commitment
    pub encrypted_note: [u8; 96],   // ECDH-encrypted note for recipient
}

Logic:

pub fn transfer(ctx: Context<Transfer>, data: TransferData) -> Result<()> {
    let pool = &mut ctx.accounts.pool;
 
    // 1. Verify Merkle root is in history (front-running protection)
    require!(
        pool.root_history.contains(&data.merkle_root),
        ErrorCode::InvalidMerkleRoot
    );
 
    // 2. Verify zkSNARK proof
    let public_inputs = [
        data.merkle_root,
        data.nullifier,
        data.new_commitment,
    ];
 
    verify_groth16_proof(&data.proof, &public_inputs)?;
 
    // 3. Nullifier marker is created via `init` constraint
    // If nullifier already exists, transaction fails here
    let marker = &mut ctx.accounts.nullifier_marker;
    marker.pool = pool.key();
    marker.nullifier = data.nullifier;
    marker.spent_at = Clock::get()?.slot;
 
    // 4. Add new commitment to tree
    let leaf_index = pool.leaf_count;
    pool.leaf_count += 1;
 
    let new_root = compute_merkle_root(data.new_commitment, leaf_index)?;
 
    // 5. Update root history
    pool.root_history[pool.current_root_index as usize] = new_root;
    pool.current_root_index = (pool.current_root_index + 1) % 30;
    pool.merkle_root = new_root;
 
    emit!(PrivateTransfer {
        nullifier: data.nullifier,
        new_commitment: data.new_commitment,
        encrypted_note: data.encrypted_note,
        leaf_index,
    });
 
    Ok(())
}

Compute Units: ~200,000 CU (mostly Groth16 verification)

4. Unshield / UnshieldSOL

Spend a note and withdraw to a public address.

Accounts:

#[derive(Accounts)]
pub struct UnshieldSOL<'info> {
    #[account(mut)]
    pub payer: Signer<'info>,
 
    #[account(mut)]
    pub pool: Account<'info, PrivacyPool>,
 
    #[account(mut)]
    pub recipient: SystemAccount<'info>,
 
    #[account(mut)]
    pub vault: SystemAccount<'info>,
 
    #[account(
        seeds = [b"vault", pool.key().as_ref()],
        bump = pool.vault_bump
    )]
    pub vault_authority: SystemAccount<'info>,
 
    #[account(
        init,
        payer = payer,
        space = 8 + 144,
        seeds = [b"nullifier", pool.key().as_ref(), nullifier.as_ref()],
        bump
    )]
    pub nullifier_marker: Account<'info, NullifierMarker>,
 
    pub system_program: Program<'info, System>,
}

Instruction Data:

pub struct UnshieldSOLData {
    pub proof: [u8; 256],
    pub merkle_root: [u8; 32],
    pub nullifier: [u8; 32],
    pub amount: u64,             // PUBLIC (withdrawal amount)
}

Logic:

pub fn unshield_sol(ctx: Context<UnshieldSOL>, data: UnshieldSOLData) -> Result<()> {
    let pool = &mut ctx.accounts.pool;
 
    // 1-3: Same as transfer (verify root, proof, create nullifier marker)
 
    // 4. Transfer tokens from vault to recipient
    let vault_seeds = &[
        b"vault",
        pool.key().as_ref(),
        &[pool.vault_bump],
    ];
 
    anchor_lang::system_program::transfer(
        CpiContext::new_with_signer(
            ctx.accounts.system_program.to_account_info(),
            Transfer {
                from: ctx.accounts.vault.to_account_info(),
                to: ctx.accounts.recipient.to_account_info(),
            },
            &[vault_seeds],
        ),
        data.amount,
    )?;
 
    // 5. Update stats (no new commitment added)
    pool.total_withdrawals += data.amount;
 
    emit!(Unshield {
        nullifier: data.nullifier,
        recipient: ctx.accounts.recipient.key(),
        amount: data.amount,
    });
 
    Ok(())
}

Compute Units: ~150,000 CU

Groth16 Proof Verification

The most computationally expensive on-chain operation.

Integration

Crate: groth16-solana Algorithm: BN254 (alt_bn128) pairing checks Cost: ~140,000 CU for verification alone

Verification Process

use groth16_solana::groth16::Groth16Verifier;
 
fn verify_groth16_proof(
    proof: &[u8; 256],
    public_inputs: &[[u8; 32]; 3],
) -> Result<()> {
    // 1. Detect proof type by length
    if proof.len() == 96 {
        // MVP mode (development only)
        return verify_mvp_proof(proof, public_inputs);
    }
 
    require!(proof.len() == 256, ErrorCode::InvalidProofSize);
 
    // 2. Deserialize proof components
    let proof_a = deserialize_g1_point(&proof[0..64])?;
    let proof_b = deserialize_g2_point(&proof[64..192])?;
    let proof_c = deserialize_g1_point(&proof[192..256])?;
 
    // 3. Deserialize public inputs
    let merkle_root_scalar = bytes_to_scalar(&public_inputs[0])?;
    let nullifier_scalar = bytes_to_scalar(&public_inputs[1])?;
    let commitment_scalar = bytes_to_scalar(&public_inputs[2])?;
 
    // 4. Load verification key (hardcoded in program)
    let vk = load_verification_key()?;
 
    // 5. Perform pairing check
    // e(A, B) == e(α, β) · e(C, δ) · e(public_inputs · γ, γ)
    let verifier = Groth16Verifier::new(
        &proof_a,
        &proof_b,
        &proof_c,
        &[merkle_root_scalar, nullifier_scalar, commitment_scalar],
        &vk,
    )?;
 
    require!(verifier.verify()?, ErrorCode::ProofVerificationFailed);
 
    Ok(())
}

Endianness Handling

BN254 Field Elements: Big-endian Solana: Little-endian by default

fn bytes_to_scalar(bytes: &[u8; 32]) -> Result<Scalar> {
    // Reverse for endianness conversion
    let mut reversed = *bytes;
    reversed.reverse();
 
    Scalar::from_bytes(&reversed)
        .ok_or(ErrorCode::InvalidScalar.into())
}

Verification Key Storage

The verification key is embedded in the program binary:

pub const VERIFICATION_KEY: VerificationKey = VerificationKey {
    alpha_g1: G1Point { ... },
    beta_g2: G2Point { ... },
    gamma_g2: G2Point { ... },
    delta_g2: G2Point { ... },
    ic: [
        G1Point { ... },  // Constant term
        G1Point { ... },  // merkle_root coefficient
        G1Point { ... },  // nullifier coefficient
        G1Point { ... },  // commitment coefficient
    ],
};

Size: ~512 bytes Generated: During trusted setup ceremony

Merkle Tree Implementation

The Merkle tree is maintained on-chain with Poseidon hashing.

Storage Strategy

On-Chain Storage: Current root + leaf count Off-Chain Storage: Full tree (too expensive on-chain) Reconstruction: Clients maintain local tree replica

Root Computation

fn compute_merkle_root(commitment: [u8; 32], leaf_index: u64) -> Result<[u8; 32]> {
    let mut current = commitment;
    let mut index = leaf_index;
 
    // Compute path from leaf to root
    for level in 0..TREE_DEPTH {
        let sibling = get_sibling_hash(index, level)?;
 
        if index % 2 == 0 {
            // Current node is left child
            current = poseidon_hash_2(current, sibling)?;
        } else {
            // Current node is right child
            current = poseidon_hash_2(sibling, current)?;
        }
 
        index /= 2;
    }
 
    Ok(current)
}

Poseidon Hash (On-Chain)

Parameters:

  • Width (t): 3 (2 inputs + 1 capacity element)
  • Full rounds (RF): 8 (4 at start, 4 at end)
  • Partial rounds (RP): 57
  • S-box: x^5

Cost: ~5,000 CU per hash Total for depth-20 tree: 20 hashes × 5,000 = ~100,000 CU

fn poseidon_hash_2(left: [u8; 32], right: [u8; 32]) -> Result<[u8; 32]> {
    let left_scalar = bytes_to_scalar(&left)?;
    let right_scalar = bytes_to_scalar(&right)?;
 
    let result = poseidon::hash_2(left_scalar, right_scalar)?;
 
    Ok(scalar_to_bytes(result))
}

Zero Hashes

Empty nodes use precomputed "zero hashes":

pub const ZERO_HASHES: [[u8; 32]; 21] = [
    ZERO_VALUE,                          // Level 0 (leaf)
    poseidon_hash_2(ZERO_VALUE, ZERO_VALUE),  // Level 1
    poseidon_hash_2(LEVEL_1_ZERO, LEVEL_1_ZERO), // Level 2
    // ... up to level 20
];

Benefits:

  • Efficient verification of sparse trees
  • No need to store empty subtrees
  • Deterministic root for empty tree

Root History Buffer

Circular buffer protecting against front-running.

Implementation

pub struct PrivacyPool {
    pub root_history: [[u8; 32]; 30],
    pub current_root_index: u8,
    pub merkle_root: [u8; 32],  // Most recent root
}

Update Logic

fn update_root_history(pool: &mut PrivacyPool, new_root: [u8; 32]) {
    // Add new root to buffer
    pool.root_history[pool.current_root_index as usize] = new_root;
 
    // Advance index (circular)
    pool.current_root_index = (pool.current_root_index + 1) % 30;
 
    // Update current root
    pool.merkle_root = new_root;
}

Verification

fn verify_root_in_history(pool: &PrivacyPool, claimed_root: [u8; 32]) -> Result<()> {
    require!(
        pool.root_history.contains(&claimed_root),
        ErrorCode::InvalidMerkleRoot
    );
    Ok(())
}

Validity Window:

  • 30 roots × ~2 sec/block = ~60 seconds
  • Sufficient time for proof generation (5-10s) and submission
  • Protects against concurrent transaction invalidation

Error Codes

#[error_code]
pub enum ErrorCode {
    #[msg("Merkle root not found in history")]
    InvalidMerkleRoot,
 
    #[msg("Proof verification failed")]
    ProofVerificationFailed,
 
    #[msg("Invalid proof size")]
    InvalidProofSize,
 
    #[msg("Nullifier already spent")]
    NullifierAlreadySpent,
 
    #[msg("Invalid scalar value")]
    InvalidScalar,
 
    #[msg("Insufficient vault balance")]
    InsufficientBalance,
 
    #[msg("Unauthorized operation")]
    Unauthorized,
}

Security Mechanisms

1. Access Control

// Only authority can upgrade
require!(
    ctx.accounts.authority.key() == pool.authority,
    ErrorCode::Unauthorized
);

2. Overflow Protection

// Checked arithmetic
pool.leaf_count = pool.leaf_count.checked_add(1)
    .ok_or(ErrorCode::Overflow)?;

3. Reentrancy Guards

Anchor's account constraints prevent reentrancy:

#[account(mut)]  // Mutable borrow prevents concurrent access
pub pool: Account<'info, PrivacyPool>,

4. PDA Validation

All PDAs verified via seeds:

#[account(
    seeds = [b"nullifier", pool.key().as_ref(), nullifier.as_ref()],
    bump
)]

5. Amount Validation

require!(amount > 0, ErrorCode::InvalidAmount);
require!(amount <= MAX_TRANSFER, ErrorCode::AmountTooLarge);

Performance Optimization

Compute Unit Budget:

#[program]
pub mod veil_privacy {
    use super::*;
 
    pub fn transfer(ctx: Context<Transfer>, data: TransferData) -> Result<()> {
        // Request additional CU if needed
        solana_program::compute_budget::request_units(250_000)?;
 
        // ... instruction logic
    }
}

Account Caching:

  • Frequently accessed accounts loaded once
  • Minimize deserialization overhead

Batch Operations:

  • Future: Batch verify multiple proofs in one transaction
  • Amortize verification cost across multiple transfers

Upgrade Authority

Current: Authority held by deployment key Future: Multisig or governance

pub fn set_authority(
    ctx: Context<SetAuthority>,
    new_authority: Pubkey
) -> Result<()> {
    let pool = &mut ctx.accounts.pool;
 
    require!(
        ctx.accounts.authority.key() == pool.authority,
        ErrorCode::Unauthorized
    );
 
    pool.authority = new_authority;
 
    Ok(())
}

Testing

Unit Tests: Rust native tests for crypto primitives Integration Tests: Anchor tests with local validator Fuzz Testing: Random input generation for robustness

Test Coverage:

  • Proof verification (valid & invalid proofs)
  • Double-spend prevention
  • Root history management
  • Vault operations
  • Edge cases (overflow, underflow, reentrancy)

Future Enhancements

  1. Batch Verification: Verify multiple proofs in single transaction
  2. Relayer Registry: On-chain relayer reputation and fee caps
  3. Multi-Asset Support: Handle multiple SPL tokens efficiently
  4. Recursive Proofs: Aggregate multiple transfers into one proof
  5. Compressed State: Use state compression for Merkle tree storage

See also: