11 KiB
Important Caveats
Critical limitations, quirks, and gotchas in Solana and Anchor development that every security reviewer must know.
Anchor Framework Limitations
1. init_if_needed Re-initialization Risk
// Dangerous: Can bypass initialization logic
#[account(init_if_needed, payer = user, space = ...)]
pub user_account: Account<'info, UserAccount>,
Issue: If account already exists, initialization is skipped entirely. Existing malicious or inconsistent data is not validated.
When to use: Only when you explicitly validate existing accounts in instruction logic.
2. AccountLoader Missing Discriminator Check
// Does NOT validate discriminator by default!
#[account(mut)]
pub user: AccountLoader<'info, User>,
Issue: AccountLoader is for zero-copy accounts and doesn't check the account discriminator automatically. Enables type cosplay attacks.
Solution: Use Account<'info, T> when possible, or add manual discriminator check.
3. close Constraint Ordering
// ❌ Wrong: close must be last
#[account(
close = receiver,
mut,
has_one = authority
)]
// ✅ Correct: close is last
#[account(
mut,
has_one = authority,
close = receiver
)]
Issue: Anchor processes constraints in order. If close isn't last, subsequent constraints may check zeroed account.
4. Space Calculation Errors Are Permanent
// If this space is wrong, account is unusable!
#[account(
init,
payer = user,
space = 8 + 32 // Too small = can't deserialize later!
)]
pub user_account: Account<'info, UserAccount>,
Issue: Once initialized, account size is fixed. Too small = deserialization fails. Too large = wasted rent.
Solution: Always use InitSpace derive macro:
#[account]
#[derive(InitSpace)]
pub struct UserAccount {
pub authority: Pubkey,
#[max_len(100)]
pub name: String,
}
// Then use:
space = 8 + UserAccount::INIT_SPACE
5. constraint Expression Limitations
// constraint expressions can't call functions that return Results!
#[account(
constraint = some_validation(account.value)? @ ErrorCode::Invalid // Compile error!
)]
Issue: Constraint expressions must be simple boolean checks. Cannot use ? operator.
Solution: Validate in instruction body for complex checks.
Solana Runtime Quirks
1. Account Data Persists After Zeroing Lamports
// Within same transaction:
**account.lamports.borrow_mut() = 0;
let data = account.try_borrow_data()?; // Still readable!
Issue: Account data remains accessible within the transaction even after lamports are zeroed. Only garbage collected after transaction completes.
Implication: Always check lamports before reading account data.
2. Non-Canonical PDA Bumps
// Multiple PDAs possible with different bumps!
let (pda_255, bump_255) = Pubkey::find_program_address(seeds, program_id); // bump = 255
let (pda_254, bump_254) = Pubkey::create_program_address(&[seeds, &[254]], program_id); // Also valid!
Issue: Same seeds can derive multiple PDAs with different bumps. Creates confusion and potential exploits.
Solution: Always use canonical bump (255 counting down to first valid). Anchor's bump constraint enforces this.
3. Compute Budget Limits
| Network | Base Compute Units | With Optimization |
|---|---|---|
| Mainnet | 200,000 | Up to 1,400,000 (with request) |
| Devnet | 200,000 | Up to 1,400,000 |
Issue: Complex programs can exceed compute budget, causing transaction failure.
Optimization strategies:
- Minimize CPIs (each costs ~1000 CU)
- Use
AccountLoaderfor large accounts - Avoid loops with variable length
- Request higher compute budget:
ComputeBudgetProgram::set_compute_unit_limit()
4. Transaction Size Limit
Hard limit: ~1232 bytes for transaction
Implications:
- Limits number of accounts (~35-40 accounts typical max)
- Large instructions need Account Compression or chunking
- Can't pass large data directly in instruction
Solutions:
- Use PDAs to store large data
- Break operations into multiple transactions
- Use lookup tables for frequent accounts
5. Account Snapshot Loading
let balance_before = ctx.accounts.vault.balance;
// CPI happens here
// balance_before is STALE - account was loaded before CPI
Issue: Accounts are loaded as snapshots at transaction start. Modifications during transaction (via CPIs) don't update the loaded data.
Solution: Call .reload() after any CPI that might modify the account.
Token Program Gotchas
1. ATA Addresses Are Deterministic But Not Guaranteed
let ata = get_associated_token_address(&owner, &mint);
// ata address is deterministic but account might not exist!
Issue: ATA address can be calculated but account may not be initialized.
Solution: Check account exists and is initialized before use, or use init_if_needed with proper validation.
2. Delegates Don't Automatically Reset
// After transfer of ownership:
token_account.owner = new_owner;
// BUT: delegate and delegated_amount are NOT reset!
Issue: Changing owner doesn't clear delegate/close authority. Old delegate can still spend.
Solution: Explicitly reset authorities when changing ownership:
account.delegate = COption::None;
account.delegated_amount = 0;
if account.is_native() {
account.close_authority = COption::None;
}
3. Token-2022 Extension Rent
Issue: Each extension adds rent cost. Account size varies by extensions enabled.
Extensions and their sizes:
- Transfer Fee: ~83 bytes
- Transfer Hook: ~107 bytes
- Permanent Delegate: ~36 bytes
- Interest Bearing: ~40 bytes
Solution: Calculate rent based on all enabled extensions.
4. Token-2022 Transfer Hooks Can Be Malicious
// Transfer hook can call arbitrary program!
pub struct TransferHookAccount {
pub program_id: Pubkey, // Could be malicious
}
Issue: Transfer hook extensions allow calling external program during transfers. Malicious hook can fail transaction or drain funds.
Solution:
- Validate transfer hook program if accepting specific tokens
- Consider disallowing tokens with transfer hooks
- Use Anchor's
TransferCheckedinstruction
Testing Blind Spots
1. Concurrent Transaction Ordering
Issue: Tests typically run transactions sequentially. In production, concurrent transactions can interleave in unexpected ways.
Vulnerability example:
// Transaction 1: Check balance = 100
// Transaction 2: Withdraw 80 (balance now 20)
// Transaction 1: Withdraw 80 (uses stale check, balance now -60!)
Mitigation:
- Use atomic operations
- Reload accounts before critical operations
- Design for idempotency
2. Account Rent Reclaim Attacks
Issue: When account rent falls below minimum, validator can reclaim the account. Tests don't simulate this.
Solution: Ensure all accounts are rent-exempt (2+ years of rent).
3. Sysvar Manipulation in Tests
// In tests, you can set arbitrary clock values
ctx.accounts.clock = Clock { unix_timestamp: attacker_value, ... };
Issue: Tests may not catch reliance on tamper-resistant sysvars.
Solution: In production, always load sysvars from official sysvar accounts:
pub clock: Sysvar<'info, Clock>, // Validated address
4. Devnet vs Mainnet Differences
| Aspect | Devnet | Mainnet |
|---|---|---|
| Oracle prices | Often stale/fake | Real-time |
| Program versions | May differ | Stable versions |
| Compute limits | More lenient | Strict |
| Congestion | Minimal | Can be high |
| Token availability | Test tokens | Real value |
Issue: Programs tested only on devnet may fail on mainnet.
Solution: Test on mainnet-fork or mainnet with small amounts before full deployment.
Rust-Specific Gotchas
1. unwrap() Panics
// Panics kill the entire transaction!
let value = some_option.unwrap(); // ❌ Never do this
Solution: Always use proper error handling:
let value = some_option.ok_or(ErrorCode::MissingValue)?;
2. Integer Division Truncation
let result = 5 / 2; // result = 2, not 2.5!
Issue: Integer division truncates, potentially causing precision loss in financial calculations.
Solution: Use Decimal type for precise calculations, or multiply before divide:
let result = (5 * PRECISION) / 2 / PRECISION;
3. Overflow in Debug vs Release
// Debug mode: panics on overflow
// Release mode: wraps silently!
let x: u8 = 255;
let y = x + 1; // Debug: panic, Release: y = 0
Solution: Always use checked_* methods - they work same in debug and release.
Cross-Program Invocation (CPI) Gotchas
1. CPI Success Doesn't Guarantee Correct State
// CPI returns success but state may be unexpected
invoke(&transfer_instruction, &accounts)?;
// Transfer succeeded but amount might be different due to fees!
Solution: Reload and validate account state after CPI.
2. Signer Seeds Must Be Exact
// Seeds for signing must match PDA derivation exactly
let seeds = &[
b"vault",
user.key().as_ref(),
&[bump], // Must be same bump used to derive PDA
];
invoke_signed(&instruction, &accounts, &[seeds])?;
Issue: Wrong seeds = "signature verification failed" error.
3. CPI Depth Limit
Limit: 4 levels of CPI depth
Issue: Program A → Program B → Program C → Program D → Program E (fails!)
Solution: Design programs to minimize CPI depth.
Common Misunderstandings
1. "Anchor Prevents All Security Issues"
False: Anchor prevents some common issues (missing discriminators, wrong account types) but doesn't validate business logic, arithmetic, or authorization.
2. "Devnet Testing Is Sufficient"
False: Mainnet has different compute limits, real oracle data, congestion, and MEV considerations.
3. "One Audit Makes Code Secure"
False: Audits find issues in a snapshot. Code changes after audit reintroduce risk. Need continuous security review.
4. "checked_* Methods Are Slower"
False: Rust compiler optimizes these similarly to unchecked arithmetic. Always use checked methods.
5. "PDAs Can't Sign"
True for external transactions, false for CPIs: PDAs can sign CPIs using invoke_signed but can't sign transactions directly.
Version-Specific Issues
Anchor Version Compatibility
- < 0.28: No
InitSpacederive, manual space calculation error-prone - < 0.29: Different constraint syntax
- 0.30+: Breaking changes in error handling and account initialization
Solution: Check Cargo.toml for version and consult Anchor Changelog.
Solana Version Differences
- Pre-1.14: Different fee structure
- Pre-1.16: No Address Lookup Tables
- Pre-1.17: No Token-2022
Solution: Verify target Solana version matches deployment network.
Key Takeaway: Many "obvious" assumptions about blockchain behavior don't hold in Solana. Always validate against actual runtime behavior, not assumptions from other chains.