← Back to Blog

The Cost of Security in Solana Programs

By brymko15 min read8/25/2025
securitysolanacuoptimization
The Cost of Security in Solana Programs

The Cost of Security in Solana Programs

Every Solana developer faces the same dilemma: How much security can I afford?

With a soft limit of 200,000 compute units per transaction (can be upgraded to 1.4M), every security check comes at a cost. Should you verify that PDA? Check if an account is rent-exempt? Validate every signer? Each decision chips away at your compute budget.

We often see developers skip essential security checks to save compute units, so we decided to find out exactly how much each security operation costs. The results might surprise you.

Jump to the benchmark results if you're in a hurry.

Why This Matters

On Solana, compute units aren't just a technical detail. They directly impact your users through priority fees and transaction success rates. Understanding the exact cost of security operations helps you make informed decisions about which checks are essential and which optimizations are worth implementing.

This guide provides concrete numbers for common security operations, along with optimization strategies that can save thousands of compute units per transaction

Building on Prior Work

This research extends the excellent work by Solana Labs on compute optimization and their CU optimizations repository. We've replicated their results and expanded the analysis to include more security patterns and fixed-point arithmetic. Also If you prefer video content we can highly recommend SolAndy's 2 part series about CU optimization here and here.

For comprehensive security patterns including invariants, assertions, and best practices, see our 100 Solana tips.

Our Approach

All benchmarks measure actual on-chain compute units using sol_log_compute_units(). We focused on operations every Solana developer uses: pubkey comparisons, PDA verification, signer checks, and mathematical operations—including the costly but necessary fixed-point arithmetic.

Understanding Solana's Compute Budget

Before diving into benchmarks, it's crucial to understand the constraints we're working within. The Agave validator (Solana's validator implementation) enforces strict limits defined in execution_budget.rs.

Here are the key constants that shape every Solana program:

Constant Value Description
MAX_INSTRUCTION_STACK_DEPTH 5 Max instruction stack depth. This is the maximum nesting of instructions that can happen during a transaction
MAX_CALL_DEPTH 64 Max call depth. This is the maximum nesting of SBF to SBF call that can happen within a program
STACK_FRAME_SIZE 4,096 The size of one SBF stack frame
MAX_COMPUTE_UNIT_LIMIT 1,400,000 Maximum compute unit limit
DEFAULT_HEAP_COST 8 Roughly 0.5us/page, where page is 32K; given roughly 15CU/us, the default heap page cost = 0.5 * 15 ~= 8CU/page
DEFAULT_INSTRUCTION_COMPUTE_UNIT_LIMIT 200,000 Default instruction compute unit limit
MAX_BUILTIN_ALLOCATION_COMPUTE_UNIT_LIMIT 3,000 SIMD-170 defines max CUs to be allocated for any builtin program instructions, that have not been migrated to sBPF programs
MAX_HEAP_FRAME_BYTES 262,144 Maximum heap frame bytes (256 * 1024)
MIN_HEAP_FRAME_BYTES HEAP_LENGTH Minimum heap frame bytes
MAX_LOADED_ACCOUNTS_DATA_SIZE_BYTES 67,108,864 The total accounts data a transaction can load is limited to 64MiB to not break anyone in Mainnet-beta today. It can be set by set_loaded_accounts_data_size_limit instruction
Field Default Value Description
compute_unit_limit 1,400,000 Number of compute units that a transaction or individual instruction is allowed to consume. Compute units are consumed by program execution, resources they use, etc...
max_instruction_stack_depth 5 Maximum program instruction invocation stack depth. Invocation stack depth starts at 1 for transaction instructions and the stack depth is incremented each time a program invokes an instruction and decremented when a program returns
max_instruction_trace_length 64 Maximum cross-program invocation and instructions per transaction
sha256_max_slices 20,000 Maximum number of slices hashed per syscall
max_call_depth 64 Maximum SBF to BPF call depth
stack_frame_size 4,096 Size of a stack frame in bytes, must match the size specified in the LLVM SBF backend
max_cpi_instruction_size 1,280 Maximum cross-program invocation instruction size (IPv6 Min MTU size)
heap_size HEAP_LENGTH program heap region size, default: solana_program_entrypoint::HEAP_LENGTH
log_64_units 100 Number of compute units consumed by a log_u64 call
create_program_address_units 1,500 Number of compute units consumed by a create_program_address call
invoke_units 1,000 Number of compute units consumed by an invoke call (not including the cost incurred by the called program)
sha256_base_cost 85 Base number of compute units consumed to call SHA256
sha256_byte_cost 1 Incremental number of units consumed by SHA256 (based on bytes)
log_pubkey_units 100 Number of compute units consumed by logging a Pubkey
cpi_bytes_per_unit 250 Number of account data bytes per compute unit charged during a cross-program invocation (~50MB at 200,000 units)
sysvar_base_cost 100 Base number of compute units consumed to get a sysvar
secp256k1_recover_cost 25,000 Number of compute units consumed to call secp256k1_recover
syscall_base_cost 100 Number of compute units consumed to do a syscall without any work
curve25519_edwards_validate_ point_cost 159 Number of compute units consumed to validate a curve25519 edwards point
curve25519_edwards_add_cost 473 Number of compute units consumed to add two curve25519 edwards points
curve25519_edwards_subtract_cost 475 Number of compute units consumed to subtract two curve25519 edwards points
curve25519_edwards_multiply_cost 2,177 Number of compute units consumed to multiply a curve25519 edwards point
curve25519_edwards_msm_base_cost 2,273 Number of compute units consumed for a multiscalar multiplication (msm) of edwards points. The total cost is calculated as msm_base_cost + (length - 1) * msm_incremental_cost
curve25519_edwards_msm_ incremental_cost 758 Number of compute units consumed for a multiscalar multiplication (msm) of edwards points. The total cost is calculated as msm_base_cost + (length - 1) * msm_incremental_cost
curve25519_ristretto_validate_ point_cost 169 Number of compute units consumed to validate a curve25519 ristretto point
curve25519_ristretto_add_cost 521 Number of compute units consumed to add two curve25519 ristretto points
curve25519_ristretto_subtract_cost 519 Number of compute units consumed to subtract two curve25519 ristretto points
curve25519_ristretto_multiply_cost 2,208 Number of compute units consumed to multiply a curve25519 ristretto point
curve25519_ristretto_msm_base_cost 2,303 Number of compute units consumed for a multiscalar multiplication (msm) of ristretto points. The total cost is calculated as msm_base_cost + (length - 1) * msm_incremental_cost
curve25519_ristretto_msm_ incremental_cost 788 Number of compute units consumed for a multiscalar multiplication (msm) of ristretto points. The total cost is calculated as msm_base_cost + (length - 1) * msm_incremental_cost
heap_cost 8 Number of compute units per additional 32k heap above the default (~.5 us per 32k at 15 units/us rounded up)
mem_op_base_cost 10 Memory operation syscall base cost
alt_bn128_addition_cost 334 Number of compute units consumed to call alt_bn128_addition
alt_bn128_multiplication_cost 3,840 Number of compute units consumed to call alt_bn128_multiplication
alt_bn128_pairing_one_pair_ cost_first 36,364 Total cost will be alt_bn128_pairing_one_pair_cost_first + alt_bn128_pairing_one_pair_cost_other * (num_elems - 1)
alt_bn128_pairing_one_pair_ cost_other 12,121 Total cost will be alt_bn128_pairing_one_pair_cost_first + alt_bn128_pairing_one_pair_cost_other * (num_elems - 1)
big_modular_exponentiation_ base_cost 190 Big integer modular exponentiation base cost
big_modular_exponentiation_ cost_divisor 2 Big integer moduler exponentiation cost divisor. The modular exponentiation cost is computed as input_length / big_modular_exponentiation_ cost_divisor + big_modular_exponentiation_base_cost
poseidon_cost_coefficient_a 61 Coefficient a of the quadratic function which determines the number of compute units consumed to call poseidon syscall for a given number of inputs
poseidon_cost_coefficient_c 542 Coefficient c of the quadratic function which determines the number of compute units consumed to call poseidon syscall for a given number of inputs
get_remaining_compute_units_cost 100 Number of compute units consumed for accessing the remaining compute units
alt_bn128_g1_compress 30 Number of compute units consumed to call alt_bn128_g1_compress
alt_bn128_g1_decompress 398 Number of compute units consumed to call alt_bn128_g1_decompress
alt_bn128_g2_compress 86 Number of compute units consumed to call alt_bn128_g2_compress
alt_bn128_g2_decompress 13,610 Number of compute units consumed to call alt_bn128_g2_decompress

Benchmark Results

Let's start with the operations you use in every instruction. We measured each operation multiple times and subtracted baseline costs to get the pure compute unit consumption.

Basic Security Checks: The Good News

Most fundamental security checks are remarkably cheap. So cheap that you should never skip them. Each of these operations costs around 30 compute units, which is negligible in a 200,000 CU budget.

Important caveat: These numbers assume primitive type comparisons. Custom PartialEq implementations can consume arbitrary compute units, so always be aware of what you're comparing. For more on discriminator checks and type safety, see our article on hidden IDL instructions.

  1. Boolean/Signer Checks - Cost: ~30 CU

    // Checking if an account is a signer
    if !account.is_signer {
        // 
        return Err(ProgramError::MissingRequiredSignature);
    }
    
  2. Field access check - Cost: ~30 CU

    // Pubkey equality checks
    if account.owner != expected_owner {
        return Err(ProgramError::IncorrectProgramId);
    }
    
  3. Size and Balance Checks - Cost: ~30 CU

    // Checking account size or lamports
    if account.data_len() != EXPECTED_SIZE {
        return Err(ProgramError::InvalidAccountData);
    }
    
  4. Account Data Validation - Cost: ~30 CU

    // Checking discriminators (first 8 bytes in anchor)
    if &account_data[..8] != DISCRIMINATOR {
        return Err(ProgramError::InvalidAccountData);
    }
    
  5. Rent Exemption Checks - Cost: ~300 CU

    let rent = Rent::get()?;
    if !rent.is_exempt(account.lamports(), account_data_len) {
        return Err(ProgramError::AccountNotRentExempt);
    }
    

    Ten times more expensive than basic checks, but still affordable. This includes fetching the rent sysvar and performing the calculation.

  6. Error Handeling - Cost: ~400 CU

        fn error_test(a: Pubkey, b: Pubkey) -> Result<()> {
            if a != b {
                solana_program::log::sol_log_compute_units();
                return Err(ErrorCode::AccountDidNotDeserialize.into());
            }
            Ok(())
        }
    
        // At the end of this function 414 CU will have been consumed
        fn error_test_with_propagation(a: Pubkey, b: Pubkey) -> Result<()> {
            error_test(a, b)?;
            Ok(())
        }
    
        // At the end of this function 415 CU will have been consumed
        fn error_test_without_propagation(a: Pubkey, b: Pubkey) -> Result<()> {
            if let Err(e) = error_test(a, b) {
                return Err(e);
            }
            Ok(())
        }
    

    for error paths. This overhead only matters if you're trying to recover from errors. If you're just propagating errors to fail the transaction, the CU cost is irrelevant since the transaction will abort anyway.

  7. Checked math

    Checked vs Unchecked Operations Comparison (Baseline Adjusted)

    Note: All values have been adjusted by subtracting the baseline cost of 104 CU for calling sol_log_compute_units and std::hint::black_box

    u64 Operations
    Operation Checked Unchecked Difference Overhead %
    Add 3 1 2 200%
    Sub 13 1 12 1200%
    Mul 8 2 6 300%
    Div 10 7 3 43%
    u128 Operation
    Operation Checked Unchecked Difference Overhead %
    Add 7 5 2 40%
    Sub 11 5 6 120%
    Mul 11 4 7 175%
    Div 6 4 2 50%
    I80F48 Operation
    Operation Checked Unchecked Difference Overhead %
    Add 7 5 2 40%
    Sub 11 5 6 120%
    Mul 11 4 3 75%
    Div 2,285 2,281 4 0.2%

    u64

    rust Decimal
    Operation Checked Unchecked Difference Overhead %
    Add 124 117 7 6%
    Sub 126 117 9 8%
    Mul 84 78 6 8%
    Div 669 662 7 1%

    The Surprising Truth About Checked Math

    Contrary to popular belief, using Rust's checked_* methods has minimal performance impact in release builds. The safety comes almost for free:

    • u64 operations: 2-12 CU overhead for checked operations
    • u128 operations: 2-7 CU overhead, with multiplication and division being vastly more expensive than u64 as a baseline cost

    The real shock comes with fixed-point arithmetic. While I80F48 addition and subtraction are comparable to u128 operations, division is catastrophic at 2,285 CU.

    The rust decimal crate performs bad in the basic operations at over 100 CU's for addition, subtraction and multiplication. Division is ~3-4 times cheaper than I80F48 division.

    For more safe math patterns and best practices, see Tip #13 in our 100 Solana tips.

The Expensive Operations: PDAs and Cryptography

Now we get to the operations that can make or break your compute budget. These cryptographic operations are essential for security but come at a significant cost.

  1. PDA Derivation (~1,500 CUs per bump checked)

    let (pda, bump) = Pubkey::find_program_address(
        &[b"seed", user.key.as_ref()],
        program_id
    );
    

    PDA derivation have a cost of 1500 CU per bump checked. The probability of a bump not causing the resulting address to be off curve is 50%. Meaning that low bump values are exponentially less likely.

    Secure Optimization Strategies for PDA's

    Here's where you can save thousands of compute units with smart design choices:

    1. Recalculation with stored bump

      Generic Version

      pub struct PDAAccount {
          // <...> Ideally the Account Discriminator, can be achieved with #[account]
          pub bump: u8, // <--- Bump is important to store, as you need it for `create_program_address`
          // <...> Other fields of this Account
      }
      
      pub fn verify_authority_raw(
          pda_info: &AccountInfo,
          seeds: &[&[u8]],
      ) -> Result<()> {
          // Load the PDA account to get the bump
          let pda_data = pda_info.try_borrow_data()?;
          let pda_account = PDAAccount::try_deserialize(&mut &pda_data[..])?; // Skip discriminator
      
          // Verify the PDA with the stored bump
          let pda_key = Pubkey::create_program_address(
              &[seeds, &[&[pda_account.bump]]].concat(),
              &crate::ID,
          ).map_err(|_| ProgramError::InvalidSeeds)?;
      
          if pda_info.key() != pda_key {
              return ProgramError::InvalidArgument;
          }
      
          Ok(())
      }
      

      Anchor Version

      // On Initialize store the bump!
      pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
          ctx.accounts.pda_account.bump = ctx.bumps.pda_account;
          Ok(())
      }
      
      #[derive(Accounts)]
      pub struct Initialize<'info> {
          #[account(
              init,
              seeds = [b"pda"],
              bump // <--- We get access to `ctx.bumps.pda_account` through this attribute
          )]
          pub pda_account: Account<'info, PdaAccount>,
      }
      
      #[derive(Accounts)]
      pub struct UsePda<'info> {
          #[account(
              seeds = [b"pda"],
              bump = pda_account.bump  // Tell anchor to use the stored bump for verification, greatly increasing performance and reducing CU cost
          )]
          pub pda_account: Account<'info, PdaAccount>,
      }
      
      #[account] 
      pub struct PdaAccount {
          // `#[account]` handles the discriminator for us
          pub bump: u8,
      }
      

    This method converts runtime cost into storage cost. By saving utilizing one byte of data we are saving on a magnitude of compute cost.

  2. Associated Token Address (ATA) Derivation (~1,500 CUs per bump)

    let ata = spl_associated_token_address::get_associated_token_address(wallet, mint);
    

    Optimization Strategy for ATA's

    Internaly get_associated_token_address calls this function:

    /// For internal use only.
    #[doc(hidden)]
    pub fn get_associated_token_address_and_bump_seed_internal(
        wallet_address: &Pubkey,
        token_mint_address: &Pubkey,
        program_id: &Pubkey, // <--- This is the AToken Program "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
        token_program_id: &Pubkey, // <--- This is the Token Progam "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
    ) -> (Pubkey, u8) {
        Pubkey::find_program_address(
            &[
                &wallet_address.to_bytes(),
                &token_program_id.to_bytes(),
                &token_mint_address.to_bytes(),
            ],
            program_id,
        )
    }
    

    Meaning we can optimize this similarly to the PDA optimization for the specific mint/authority. An example can look like this:

    Generic Version

    pub struct PDAAccount {
        // <...> Ideally the Account Discriminator, can be achieved with #[account]
        pub ata_bump: u8, 
        // <...> Other fields of this Account
    }
    
    pub fn derive_ata_with_cached_bump(
        authority: &Pubkey,
        mint: &Pubkey,
        ata_bump: u8,
    ) -> Result<Pubkey> {
        // Verify the PDA with the stored bump
        let ata_key = Pubkey::create_program_address(
            &[
                authority.as_ref(),
                spl_token::ID.as_ref(),
                mint.as_ref(),
                &[ata_bump]
            ],
            &spl_associated_token_account::ID,
        ).map_err(|_| ProgramError::InvalidSeeds)?;
    
        Ok(ata_key)
    }
    

    Anchor Version

    // On Initialize store the bump!
    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        ctx.accounts.pda_account.bump = ctx.bumps.pda_account;
        ctx.accounts.pda_account.ata_bump = ctx.bumps.ata;
        Ok(())
    }
    
    #[derive(Accounts)]
    pub struct Initialize<'info> {
        #[account]
        pub authority: UncheckedAccount<'info>
        #[account]
        pub mint: Account<'info, Mint>,
        #[account(
            seeds = [
                authority.key().as_ref(),
                token::ID.as_ref(),
                mint.key().as_ref(),
            ],
            bump, // We get access to `ctx.bumps.ata` 
            seeds::program = associated_token::ID
        )]
        pub ata: Account<'info, TokenAccount>,
        
        pub pda_account: Account<'info, PdaAccount>,
        #[account(
            init,
            seeds = [b"pda"],
            bump // <--- We get access to `ctx.bumps.pda_account` through this attribute
        )]
        pub pda_account: Account<'info, PdaAccount>,
    }
    
    #[derive(Accounts)]
    pub struct UsePda<'info> {
        #[account]
        pub authority: UncheckedAccount<'info>
        #[account]
        pub mint: Account<'info, Mint>,
        #[account(
            seeds = [
                authority.key().as_ref(),
                token::ID.as_ref(),
                mint.key().as_ref(),
            ],
            bump = pda_account.ata_bump,
            seeds::program = associated_token::ID
        )]
        pub ata: Account<'info, TokenAccount>,
        
        pub pda_account: Account<'info, PdaAccount>,
        #[account(
            init,
            seeds = [b"pda"],
            bump = pda_account.bump,
        )]
        pub pda_account: Account<'info, PdaAccount>,
    }
    
    #[account] 
    pub struct PdaAccount {
        // `#[account]` handles the discriminator for us
        pub bump: u8,
        pub ata_bump: u8,
    }
    

Key Takeaways

  1. Never skip basic security checks - At 30 CU each, they're essentially free
  2. Cache PDA derivations - Save 1,500 CU per derivation by storing the bump seed and even more by storing the Key
  3. Avoid I80F48 division - At 2,749 CU per operation, consider alternatives. Make sure you properly test for regressions if you refactor away from I80F48 due to performance reasons
  4. Checked math is practically free - Use it everywhere for safety without performance concerns

The Bottom Line

Security doesn't have to be expensive. With smart optimizations like storing bump seeds, caching derivations, and choosing the right mathematical operations you can build secure programs that fit comfortably within Solana's compute limits.

The choice isn't between security and performance. It's about understanding the costs and optimizing intelligently.