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.
-
Boolean/Signer Checks - Cost: ~30 CU
// Checking if an account is a signer if !account.is_signer { // return Err(ProgramError::MissingRequiredSignature); }
-
Field access check - Cost: ~30 CU
// Pubkey equality checks if account.owner != expected_owner { return Err(ProgramError::IncorrectProgramId); }
-
Size and Balance Checks - Cost: ~30 CU
// Checking account size or lamports if account.data_len() != EXPECTED_SIZE { return Err(ProgramError::InvalidAccountData); }
-
Account Data Validation - Cost: ~30 CU
// Checking discriminators (first 8 bytes in anchor) if &account_data[..8] != DISCRIMINATOR { return Err(ProgramError::InvalidAccountData); }
-
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.
-
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.
-
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
andstd::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.
-
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:
-
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.
-
-
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
- Never skip basic security checks - At 30 CU each, they're essentially free
- Cache PDA derivations - Save 1,500 CU per derivation by storing the bump seed and even more by storing the Key
- 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
- 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.