Solana Program
On-chain implementation details and PDA patterns
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 Type: Anchor-based Solana program Language: Rust Framework: Anchor v0.28+ Deployment: Solana devnet/mainnet Compute Budget: 200k-400k CU per instruction
Core Responsibilities:
- Verify Groth16 zkSNARK proofs
- Maintain Merkle tree of commitments
- Track spent nullifiers (double-spend prevention)
- Manage root history buffer (front-running protection)
- Handle token vault operations
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
initializeinstruction - 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
initconstraint - 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 (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)
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
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
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
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_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,
}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);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
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(())
}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)
- Batch Verification: Verify multiple proofs in single transaction
- Relayer Registry: On-chain relayer reputation and fee caps
- Multi-Asset Support: Handle multiple SPL tokens efficiently
- Recursive Proofs: Aggregate multiple transfers into one proof
- Compressed State: Use state compression for Merkle tree storage
See also:
- Architecture Overview - System components
- Data Flow - Transaction flows
- Privacy Model - Security guarantees
On This Page
- Solana Program Architecture
- Program Overview
- State Accounts
- PrivacyPool Account
- NullifierMarker Account
- Vault Accounts
- Instructions
- 1. Initialize
- 2. Shield / ShieldSOL
- 3. Transfer
- 4. Unshield / UnshieldSOL
- Groth16 Proof Verification
- Integration
- Verification Process
- Endianness Handling
- Verification Key Storage
- Merkle Tree Implementation
- Storage Strategy
- Root Computation
- Poseidon Hash (On-Chain)
- Zero Hashes
- Root History Buffer
- Implementation
- Update Logic
- Verification
- Error Codes
- Security Mechanisms
- 1. Access Control
- 2. Overflow Protection
- 3. Reentrancy Guards
- 4. PDA Validation
- 5. Amount Validation
- Performance Optimization
- Upgrade Authority
- Testing
- Future Enhancements