Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 09:01:25 +08:00
commit d733741f8a
37 changed files with 26647 additions and 0 deletions

View File

@@ -0,0 +1,386 @@
# 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.