I deployed my first Anchor program to mainnet thinking I was hot shit. Clean code, passed all tests, followed the official Anchor guides. Then users started complaining that transactions were failing with "exceeded maximum compute units."
Took down trading for 2 hours because I didn't understand that PDA (Program Derived Address) operations in Anchor aren't free. Nobody tells you that find_program_address
can burn 15K CU per lookup, or that passing the wrong seeds can make it even worse. The Solana compute budget documentation explains the limits but doesn't warn you about PDA derivation costs.
Use Fixed-Length Seeds, You Dumbass
The biggest mistake I made was using dynamic-length strings as PDA seeds. Here's what killed me:
// This murdered my CU budget
let (pda, _bump) = Pubkey::find_program_address(
&[
b\"user_account\",
user_name.as_bytes(), // Variable length = death
timestamp.to_string().as_bytes(), // Converting numbers to strings = double death
],
program_id
);
Every time someone with a longer username hit my program, the PDA derivation got more expensive. Users with 20-character names were burning 20K CU just on PDA lookups. Users with 5-character names burned 15K CU. Stupid String Seeds ate my lunch.
The fix was embarrassingly simple:
// Fixed-length seeds = consistent performance
let (pda, _bump) = Pubkey::find_program_address(
&[
b\"user_account\",
user_pubkey.as_ref(), // Always 32 bytes
&[market_id], // Single byte
],
program_id
);
Using the user's public key instead of their name gave me consistent 32-byte seeds. Market ID as a single byte instead of a string saved another 5-10K CU per operation. This approach follows Solana's account model best practices and aligns with Anchor's PDA constraints documentation.
Real numbers from production:
- Variable string seeds: 18-25K CU per PDA lookup
- Fixed-length seeds: 8-12K CU per PDA lookup
- Savings: ~15K CU per transaction
Store Your Bump Seeds Like Your Life Depends On It
The second biggest fuckup was recalculating bump seeds every time. Anchor's `find_program_address` iterates through possible bump values until it finds one that creates a valid PDA. This can take anywhere from 1 iteration to 255 iterations. The Solana program development guide explains the canonical bump derivation, but doesn't emphasize the performance implications.
I was doing this shit:
#[derive(Accounts)]
pub struct UpdateUserAccount<'info> {
#[account(
mut,
seeds = [b\"user_account\", user.key().as_ref()],
bump, // Anchor recalculates this every time
)]
pub user_account: Account<'info, UserAccount>,
}
Every instruction was recalculating the bump. For accounts with high bump values (like 253), this was burning 25K CU just to verify the PDA.
The fix: store the bump in your account data:
#[account]
pub struct UserAccount {
pub user: Pubkey,
pub bump: u8, // Store this when you create the account
pub data: u64,
}
#[derive(Accounts)]
pub struct UpdateUserAccount<'info> {
#[account(
mut,
seeds = [b\"user_account\", user.key().as_ref()],
bump = user_account.bump, // Use stored bump
)]
pub user_account: Account<'info, UserAccount>,
}
Production impact:
- Recalculating bump: 15-25K CU
- Using stored bump: 2-3K CU
- Savings: ~20K CU per instruction
PDA Derivation Order Matters More Than You Think
I learned this the hard way when our marketplace started timing out. The order of your PDA seeds affects derivation cost because Solana has to hash different amounts of data.
Wrong way (expensive):
let (expensive_pda, _) = Pubkey::find_program_address(
&[
long_description.as_bytes(), // 500+ bytes
b\"marketplace_listing\", // 18 bytes
seller.as_ref(), // 32 bytes
],
program_id
);
Right way (cheap):
let (cheap_pda, _) = Pubkey::find_program_address(
&[
b\"listing\", // Short constant first
seller.as_ref(), // Fixed length second
&listing_id.to_le_bytes(), // Small numeric last
],
program_id
);
Put your shortest, most discriminating seeds first. This reduces the hash computation cost and makes PDA lookups faster. The SHA-256 hashing implementation in Solana's BPF loader processes seeds sequentially, so optimizing seed order can save significant compute units.
The Anchor Deserialization Tax You Don't See
Here's the dirty secret about Anchor: even when your program logic is fast, the framework is still burning CU on serialization overhead. Every account you declare in your `#[derive(Accounts)]` gets deserialized whether you use it or not. The Anchor serialization implementation uses Borsh serialization under the hood, which has documented performance characteristics.
I had this in production:
#[derive(Accounts)]
pub struct ProcessTrade<'info> {
pub user: Signer<'info>,
#[account(mut)]
pub user_token_account: Account<'info, TokenAccount>,
#[account(mut)]
pub market_token_account: Account<'info, TokenAccount>,
pub market_authority: AccountInfo<'info>, // Only needed for validation
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
// ... 8 more accounts I didn't always need
}
Anchor was deserializing all 13 accounts every time, even when the instruction only touched 3 of them. Each TokenAccount deserialization costs ~1,361 CU according to detailed benchmarks.
The fix: lazy loading with AccountInfo and manual deserialization:
#[derive(Accounts)]
pub struct ProcessTrade<'info> {
pub user: Signer<'info>,
/// CHECK: Manually validated and deserialized only when needed
#[account(mut)]
pub user_token_account: AccountInfo<'info>,
/// CHECK: Manually validated and deserialized only when needed
#[account(mut)]
pub market_token_account: AccountInfo<'info>,
// Only deserialize what you absolutely need upfront
}
pub fn process_trade(ctx: Context<ProcessTrade>, amount: u64) -> Result<()> {
// Only deserialize when you need the data
let user_token = Account::<TokenAccount>::try_from(&ctx.accounts.user_token_account)?;
// Do your validation and logic
if user_token.amount < amount {
return Err(ErrorCode::InsufficientFunds.into());
}
// Only deserialize the second account if the first check passes
let market_token = Account::<TokenAccount>::try_from(&ctx.accounts.market_token_account)?;
// ... rest of logic
Ok(())
}
Production savings:
- Automatic deserialization: ~17K CU (13 accounts × ~1.3K CU each)
- Manual lazy deserialization: ~4K CU (only deserialize what's used)
- Net savings: ~13K CU per instruction
Zero-Copy Is Still Copy in Anchor 0.31.1
The marketing materials tell you that zero-copy deserialization eliminates copying overhead. That's partially true, but misleading. Zero-copy in Anchor still validates account structure and performs safety checks. The zero-copy implementation in Anchor uses memory mapping but still includes bytemuck validation which costs compute units.
I thought this would be magic:
#[account(zero_copy)]
pub struct LargeMarketData {
pub prices: [u64; 1000], // 8KB of price data
pub volumes: [u64; 1000], // 8KB of volume data
pub timestamps: [i64; 1000], // 8KB of timestamp data
}
Reality check: zero-copy still costs 800 CU per account in Anchor 0.31.1, plus validation overhead. It's better than full deserialization (3K CU for 24KB), but it's not free.
When zero-copy actually helps:
- Large accounts (>1KB)
- Data you read frequently but modify rarely
- Arrays and fixed-size structures
When zero-copy doesn't help:
- Small accounts (<256 bytes)
- Data with complex validation logic
- Accounts you only access once per instruction
The deserialization overhead is still there. It's like using a sledgehammer to hang a picture - technically it works, but there's still the weight of the sledgehammer.