387 lines
11 KiB
Markdown
387 lines
11 KiB
Markdown
# 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
|
|
|
|
```rust
|
|
// 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
|
|
|
|
```rust
|
|
// 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
|
|
|
|
```rust
|
|
// ❌ 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
|
|
|
|
```rust
|
|
// 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:
|
|
```rust
|
|
#[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
|
|
|
|
```rust
|
|
// 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
|
|
|
|
```rust
|
|
// 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
|
|
|
|
```rust
|
|
// 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 `AccountLoader` for 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
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
// 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:
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
// 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 `TransferChecked` instruction
|
|
|
|
## Testing Blind Spots
|
|
|
|
### 1. Concurrent Transaction Ordering
|
|
|
|
**Issue:** Tests typically run transactions sequentially. In production, concurrent transactions can interleave in unexpected ways.
|
|
|
|
**Vulnerability example:**
|
|
```rust
|
|
// 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
|
|
|
|
```rust
|
|
// 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:
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
// Panics kill the entire transaction!
|
|
let value = some_option.unwrap(); // ❌ Never do this
|
|
```
|
|
|
|
**Solution:** Always use proper error handling:
|
|
```rust
|
|
let value = some_option.ok_or(ErrorCode::MissingValue)?;
|
|
```
|
|
|
|
### 2. Integer Division Truncation
|
|
|
|
```rust
|
|
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:
|
|
```rust
|
|
let result = (5 * PRECISION) / 2 / PRECISION;
|
|
```
|
|
|
|
### 3. Overflow in Debug vs Release
|
|
|
|
```rust
|
|
// 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
|
|
|
|
```rust
|
|
// 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
|
|
|
|
```rust
|
|
// 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 `InitSpace` derive, 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](https://github.com/coral-xyz/anchor/blob/master/CHANGELOG.md).
|
|
|
|
### 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.
|