Files
gh-tenequm-claude-plugins-s…/skills/solana-security/references/security-fundamentals.md
2025-11-30 09:01:25 +08:00

27 KiB

Solana Program Security & Validation

This reference provides comprehensive security guidance for native Rust Solana program development, covering validation patterns, common vulnerabilities, and defensive programming practices.

Table of Contents

  1. Security Mindset
  2. Core Validation Patterns
  3. Common Vulnerabilities
  4. Input Validation
  5. State Management Security
  6. Arithmetic Safety
  7. Re-entrancy Protection
  8. Security Checklist

Security Mindset

Think Like an Attacker

The fundamental principle of secure programming: ask "How do I break this?"

Presented at Breakpoint 2021 by Neodyme, this mindset shift is critical:

  • Don't just test expected functionality - explore how it can be broken
  • All programs can be exploited - the goal is to make it as difficult as possible
  • You control nothing - once deployed, you can't control what transactions are sent
  • Assume malicious input - every account, every parameter, every edge case

The Harsh Reality

┌─────────────────────────────────────────┐
│ Your Program (Deployed)                 │
├─────────────────────────────────────────┤
│ • No control over incoming transactions │
│ • No control over accounts passed in    │
│ • No control over instruction data      │
│ • No control over timing                │
└─────────────────────────────────────────┘
           ▲            ▲            ▲
           │            │            │
     Legitimate    Malicious     Buggy
        User        Attacker     Client

Your only control: How your program handles inputs.

Security is Not Optional

Example Impact:

Without proper validation, a simple "update note" function becomes:

  • Anyone can update anyone's notes
  • Drain program funds
  • Corrupt global state
  • Brick the entire program

With validation:

  • Only note author can update
  • Funds are protected
  • State remains consistent
  • Program operates as intended

Core Validation Patterns

1. Signer Checks

Purpose: Verify that an account signed the transaction, authorizing the operation.

When Required:

  • Transferring funds from an account
  • Modifying user-specific data
  • Any privileged operation

Pattern:

use solana_program::{
    account_info::AccountInfo,
    entrypoint::ProgramResult,
    program_error::ProgramError,
    msg,
};

pub fn check_signer(account: &AccountInfo) -> ProgramResult {
    if !account.is_signer {
        msg!("Missing required signature");
        return Err(ProgramError::MissingRequiredSignature);
    }
    Ok(())
}

Real-World Example:

pub fn update_user_profile(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    new_name: String,
) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();
    let user = next_account_info(account_info_iter)?;
    let profile_pda = next_account_info(account_info_iter)?;

    // CRITICAL: Verify user signed the transaction
    if !user.is_signer {
        msg!("User must sign to update profile");
        return Err(ProgramError::MissingRequiredSignature);
    }

    // Validate PDA belongs to this user
    let (expected_pda, _) = Pubkey::find_program_address(
        &[b"profile", user.key.as_ref()],
        program_id,
    );

    if expected_pda != *profile_pda.key {
        msg!("Profile PDA doesn't match user");
        return Err(ProgramError::InvalidAccountData);
    }

    // Safe to update
    let mut profile = UserProfile::try_from_slice(&profile_pda.data.borrow())?;
    profile.name = new_name;
    profile.serialize(&mut &mut profile_pda.data.borrow_mut()[..])?;

    Ok(())
}

2. Ownership Checks

Purpose: Verify an account is owned by the expected program.

When Required:

  • Before reading/writing account data
  • When validating PDAs
  • Before performing any account-specific operations

Pattern:

pub fn check_ownership(
    account: &AccountInfo,
    expected_owner: &Pubkey,
) -> ProgramResult {
    if account.owner != expected_owner {
        msg!("Account owner mismatch");
        return Err(ProgramError::IllegalOwner);
    }
    Ok(())
}

Common Use Cases:

// 1. Verify program owns its PDA
if note_pda.owner != program_id {
    msg!("Note account not owned by this program");
    return Err(ProgramError::IllegalOwner);
}

// 2. Verify account owned by System Program (user wallet)
use solana_program::system_program;

if wallet.owner != &system_program::ID {
    msg!("Expected a system account (wallet)");
    return Err(ProgramError::IllegalOwner);
}

// 3. Verify account owned by Token Program
use spl_token::ID as TOKEN_PROGRAM_ID;

if token_account.owner != &TOKEN_PROGRAM_ID {
    msg!("Expected a token account");
    return Err(ProgramError::IllegalOwner);
}

3. PDA Validation

Purpose: Ensure a provided PDA matches the expected derivation.

Critical for Security: Multiple bumps can derive different PDAs. Always use canonical bump.

Pattern:

pub fn validate_pda(
    pda_account: &AccountInfo,
    seeds: &[&[u8]],
    program_id: &Pubkey,
) -> Result<u8, ProgramError> {
    // Derive expected PDA with canonical bump
    let (expected_pda, bump_seed) = Pubkey::find_program_address(seeds, program_id);

    // Validate match
    if expected_pda != *pda_account.key {
        msg!("Invalid PDA derivation");
        return Err(ProgramError::InvalidSeeds);
    }

    Ok(bump_seed)
}

Complete Validation:

pub fn validate_user_vault(
    program_id: &Pubkey,
    user: &AccountInfo,
    vault_pda: &AccountInfo,
) -> ProgramResult {
    // 1. Derive expected PDA
    let (expected_pda, _bump) = Pubkey::find_program_address(
        &[b"vault", user.key.as_ref()],
        program_id,
    );

    // 2. Validate address match
    if expected_pda != *vault_pda.key {
        msg!("Vault PDA seeds don't match");
        return Err(ProgramError::InvalidSeeds);
    }

    // 3. Validate ownership
    if vault_pda.owner != program_id {
        msg!("Vault not owned by program");
        return Err(ProgramError::IllegalOwner);
    }

    // 4. Validate initialization
    let vault_data = VaultAccount::try_from_slice(&vault_pda.data.borrow())?;
    if !vault_data.is_initialized {
        msg!("Vault not initialized");
        return Err(ProgramError::UninitializedAccount);
    }

    Ok(())
}

4. Initialization Checks

Purpose: Prevent re-initialization or use of uninitialized accounts.

Pattern: Discriminator Field

#[derive(BorshSerialize, BorshDeserialize)]
pub struct AccountData {
    pub is_initialized: bool,
    // ... other fields
}

// On creation - ensure NOT initialized
if account_data.is_initialized {
    msg!("Account already initialized");
    return Err(ProgramError::AccountAlreadyInitialized);
}

account_data.is_initialized = true;

// On update - ensure IS initialized
if !account_data.is_initialized {
    msg!("Account not initialized");
    return Err(ProgramError::UninitializedAccount);
}

Advanced: Enum Discriminator

#[derive(BorshSerialize, BorshDeserialize, PartialEq)]
pub enum AccountState {
    Uninitialized,
    Initialized,
    Frozen,
    Closed,
}

#[derive(BorshSerialize, BorshDeserialize)]
pub struct GameAccount {
    pub state: AccountState,
    pub player: Pubkey,
    pub score: u64,
}

// Validation
let account = GameAccount::try_from_slice(&account_info.data.borrow())?;

match account.state {
    AccountState::Uninitialized => {
        msg!("Account not initialized");
        return Err(ProgramError::UninitializedAccount);
    }
    AccountState::Frozen => {
        msg!("Account is frozen");
        return Err(ProgramError::InvalidAccountData);
    }
    AccountState::Closed => {
        msg!("Account is closed");
        return Err(ProgramError::InvalidAccountData);
    }
    AccountState::Initialized => {
        // Proceed
    }
}

5. Account Type Validation

Purpose: Ensure account contains the expected data structure.

Pattern: Type Discriminator

#[derive(BorshSerialize, BorshDeserialize, PartialEq)]
#[repr(u8)]
pub enum AccountType {
    Uninitialized = 0,
    UserProfile = 1,
    GameState = 2,
    Leaderboard = 3,
}

#[derive(BorshSerialize, BorshDeserialize)]
pub struct GenericAccount {
    pub account_type: AccountType,
    // ... rest of data varies by type
}

// Validation
pub fn validate_account_type(
    account_info: &AccountInfo,
    expected_type: AccountType,
) -> ProgramResult {
    let account = GenericAccount::try_from_slice(&account_info.data.borrow())?;

    if account.account_type != expected_type {
        msg!("Unexpected account type");
        return Err(ProgramError::InvalidAccountData);
    }

    Ok(())
}

6. Writable Validation

Purpose: Ensure accounts that need modification are marked writable.

Pattern:

pub fn check_writable(account: &AccountInfo) -> ProgramResult {
    if !account.is_writable {
        msg!("Account must be writable");
        return Err(ProgramError::InvalidAccountData);
    }
    Ok(())
}

Note: Runtime enforces this, but explicit checks improve clarity and error messages.


Common Vulnerabilities

1. Missing Signer Check

Vulnerability:

// ❌ VULNERABLE - no signer check
pub fn withdraw_funds(
    accounts: &[AccountInfo],
    amount: u64,
) -> ProgramResult {
    let user = &accounts[0];
    let vault = &accounts[1];

    // Anyone can call this to withdraw anyone's funds!
    **user.lamports.borrow_mut() += amount;
    **vault.lamports.borrow_mut() -= amount;

    Ok(())
}

Exploit:

Attacker creates transaction:
- Passes victim's account as user
- Drains vault to victim's account
- Profits by intercepting the transaction or social engineering

Fix:

// ✅ SECURE - with signer check
pub fn withdraw_funds(
    accounts: &[AccountInfo],
    amount: u64,
) -> ProgramResult {
    let user = &accounts[0];
    let vault = &accounts[1];

    if !user.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    **user.lamports.borrow_mut() += amount;
    **vault.lamports.borrow_mut() -= amount;

    Ok(())
}

2. Missing Ownership Check

Vulnerability:

// ❌ VULNERABLE - no ownership check
pub fn update_score(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    new_score: u64,
) -> ProgramResult {
    let player_account = &accounts[0];

    // Could be ANY account with matching data structure!
    let mut player = PlayerData::try_from_slice(&player_account.data.borrow())?;
    player.score = new_score;
    player.serialize(&mut &mut player_account.data.borrow_mut()[..])?;

    Ok(())
}

Exploit:

Attacker creates a fake account:
- Owned by attacker's program
- Has same data structure
- Passes it to victim program
- Victim program modifies attacker's account!

Fix:

// ✅ SECURE - with ownership check
pub fn update_score(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    new_score: u64,
) -> ProgramResult {
    let player_account = &accounts[0];

    // Verify ownership
    if player_account.owner != program_id {
        return Err(ProgramError::IllegalOwner);
    }

    let mut player = PlayerData::try_from_slice(&player_account.data.borrow())?;
    player.score = new_score;
    player.serialize(&mut &mut player_account.data.borrow_mut()[..])?;

    Ok(())
}

3. PDA Substitution Attack

Vulnerability:

// ❌ VULNERABLE - accepts any PDA
pub fn claim_reward(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
) -> ProgramResult {
    let user = &accounts[0];
    let reward_pda = &accounts[1];

    // No PDA validation!
    let mut reward = RewardData::try_from_slice(&reward_pda.data.borrow())?;
    reward.claimed = true;
    reward.serialize(&mut &mut reward_pda.data.borrow_mut()[..])?;

    Ok(())
}

Exploit:

Attacker passes someone else's reward PDA:
- Creates transaction with victim's reward PDA
- Claims victim's rewards
- Victim loses rewards

Fix:

// ✅ SECURE - validates PDA derivation
pub fn claim_reward(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
) -> ProgramResult {
    let user = &accounts[0];
    let reward_pda = &accounts[1];

    // Validate PDA belongs to this user
    let (expected_pda, _) = Pubkey::find_program_address(
        &[b"reward", user.key.as_ref()],
        program_id,
    );

    if expected_pda != *reward_pda.key {
        return Err(ProgramError::InvalidSeeds);
    }

    let mut reward = RewardData::try_from_slice(&reward_pda.data.borrow())?;
    reward.claimed = true;
    reward.serialize(&mut &mut reward_pda.data.borrow_mut()[..])?;

    Ok(())
}

4. Non-Canonical Bump

Vulnerability:

// ❌ VULNERABLE - accepts user-provided bump
pub fn update_data(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    bump: u8,  // User provides bump!
) -> ProgramResult {
    let user = &accounts[0];
    let data_pda = &accounts[1];

    // Uses user's bump - could derive DIFFERENT PDA!
    let derived_pda = Pubkey::create_program_address(
        &[b"data", user.key.as_ref(), &[bump]],
        program_id,
    )?;

    if derived_pda != *data_pda.key {
        return Err(ProgramError::InvalidSeeds);
    }

    // Proceeds with potentially wrong PDA
    // ...
}

Exploit:

Multiple bumps derive different valid PDAs:
- Canonical bump 254: User A's PDA
- Bump 253: User B's PDA (also valid!)
- Attacker uses bump 253 to access User B's data

Fix:

// ✅ SECURE - uses canonical bump only
pub fn update_data(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
) -> ProgramResult {
    let user = &accounts[0];
    let data_pda = &accounts[1];

    // Always use find_program_address (canonical bump)
    let (expected_pda, _bump) = Pubkey::find_program_address(
        &[b"data", user.key.as_ref()],
        program_id,
    );

    if expected_pda != *data_pda.key {
        return Err(ProgramError::InvalidSeeds);
    }

    // Safe - validated with canonical bump
    // ...
}

5. Type Cosplay Attack

Vulnerability:

// ❌ VULNERABLE - assumes account type
pub fn admin_withdraw(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    amount: u64,
) -> ProgramResult {
    let admin_config = &accounts[0];

    // No type validation!
    let config = AdminConfig::try_from_slice(&admin_config.data.borrow())?;

    // Proceeds assuming it's actually an AdminConfig
    // ...
}

Exploit:

Attacker creates fake account:
- UserProfile with same memory layout as AdminConfig
- First field happens to match admin pubkey format
- Deserializes successfully as AdminConfig
- Attacker gains admin privileges!

Fix:

#[derive(BorshSerialize, BorshDeserialize)]
pub struct AdminConfig {
    pub discriminator: [u8; 8],  // Type identifier
    pub admin: Pubkey,
    // ... other fields
}

const ADMIN_CONFIG_DISCRIMINATOR: [u8; 8] = [1, 2, 3, 4, 5, 6, 7, 8];

// ✅ SECURE - validates type
pub fn admin_withdraw(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    amount: u64,
) -> ProgramResult {
    let admin_config = &accounts[0];

    let config = AdminConfig::try_from_slice(&admin_config.data.borrow())?;

    // Validate discriminator
    if config.discriminator != ADMIN_CONFIG_DISCRIMINATOR {
        msg!("Invalid account type");
        return Err(ProgramError::InvalidAccountData);
    }

    // Safe - type validated
    // ...
}

6. Uninitialized Account Reuse

Vulnerability:

// ❌ VULNERABLE - no initialization check
pub fn update_balance(
    accounts: &[AccountInfo],
    amount: u64,
) -> ProgramResult {
    let balance_account = &accounts[0];

    let mut balance = BalanceData::try_from_slice(&balance_account.data.borrow())?;

    // What if this account was never initialized?
    // Default values could lead to undefined behavior
    balance.amount += amount;

    balance.serialize(&mut &mut balance_account.data.borrow_mut()[..])?;
    Ok(())
}

Fix:

// ✅ SECURE - checks initialization
pub fn update_balance(
    accounts: &[AccountInfo],
    amount: u64,
) -> ProgramResult {
    let balance_account = &accounts[0];

    let mut balance = BalanceData::try_from_slice(&balance_account.data.borrow())?;

    if !balance.is_initialized {
        msg!("Account not initialized");
        return Err(ProgramError::UninitializedAccount);
    }

    balance.amount += amount;
    balance.serialize(&mut &mut balance_account.data.borrow_mut()[..])?;
    Ok(())
}

Input Validation

Validate All Input Data

Never trust instruction data. Always validate constraints.

pub fn allocate_stat_points(
    accounts: &[AccountInfo],
    strength: u8,
    agility: u8,
    intelligence: u8,
) -> ProgramResult {
    let character_account = &accounts[0];
    let mut character = Character::try_from_slice(&character_account.data.borrow())?;

    // 1. Validate individual stat caps
    let new_strength = character.strength.checked_add(strength)
        .ok_or(ProgramError::ArithmeticOverflow)?;

    if new_strength > 100 {
        msg!("Strength cannot exceed 100");
        return Err(ProgramError::InvalidArgument);
    }

    // 2. Validate total points spent
    let total_spent = (strength as u64)
        .checked_add(agility as u64)
        .and_then(|sum| sum.checked_add(intelligence as u64))
        .ok_or(ProgramError::ArithmeticOverflow)?;

    if total_spent > character.available_points {
        msg!("Insufficient available points");
        return Err(ProgramError::InsufficientFunds);
    }

    // 3. Safe to apply
    character.strength = new_strength;
    character.agility += agility;
    character.intelligence += intelligence;
    character.available_points -= total_spent;

    character.serialize(&mut &mut character_account.data.borrow_mut()[..])?;
    Ok(())
}

String Length Validation

pub fn set_username(
    accounts: &[AccountInfo],
    username: String,
) -> ProgramResult {
    // Validate length
    if username.len() < 3 {
        msg!("Username too short (min 3 characters)");
        return Err(ProgramError::InvalidArgument);
    }

    if username.len() > 20 {
        msg!("Username too long (max 20 characters)");
        return Err(ProgramError::InvalidArgument);
    }

    // Validate characters (alphanumeric only)
    if !username.chars().all(|c| c.is_alphanumeric()) {
        msg!("Username must be alphanumeric");
        return Err(ProgramError::InvalidArgument);
    }

    // Safe to use
    // ...
}

Enum Validation

#[derive(BorshDeserialize)]
#[repr(u8)]
pub enum Rarity {
    Common = 0,
    Uncommon = 1,
    Rare = 2,
    Epic = 3,
    Legendary = 4,
}

pub fn create_item(
    accounts: &[AccountInfo],
    rarity_value: u8,
) -> ProgramResult {
    // Validate enum range
    if rarity_value > 4 {
        msg!("Invalid rarity value");
        return Err(ProgramError::InvalidArgument);
    }

    let rarity: Rarity = unsafe {
        std::mem::transmute(rarity_value)
    };

    // Safe to use
    // ...
}

State Management Security

Avoid Race Conditions

Problem: Multiple transactions modifying shared state.

Solution: Use account-level locking and atomic operations.

pub fn claim_limited_reward(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
) -> ProgramResult {
    let user = &accounts[0];
    let global_pool = &accounts[1];
    let user_claim = &accounts[2];

    // Load global state
    let mut pool = RewardPool::try_from_slice(&global_pool.data.borrow())?;

    // Check availability
    if pool.claimed >= pool.total_rewards {
        msg!("No rewards remaining");
        return Err(ProgramError::InsufficientFunds);
    }

    // Check user hasn't claimed
    let mut claim = UserClaim::try_from_slice(&user_claim.data.borrow())?;
    if claim.has_claimed {
        msg!("User already claimed");
        return Err(ProgramError::Custom(0));
    }

    // Atomically update both accounts
    pool.claimed += 1;
    claim.has_claimed = true;

    pool.serialize(&mut &mut global_pool.data.borrow_mut()[..])?;
    claim.serialize(&mut &mut user_claim.data.borrow_mut()[..])?;

    Ok(())
}

Note: Solana's account locking prevents true race conditions within a single transaction, but be aware of state assumptions across multiple transactions.

Prevent State Corruption

Always validate state transitions:

#[derive(BorshSerialize, BorshDeserialize, PartialEq)]
pub enum GameState {
    NotStarted,
    InProgress,
    Finished,
}

pub fn start_game(
    accounts: &[AccountInfo],
) -> ProgramResult {
    let game_account = &accounts[0];
    let mut game = Game::try_from_slice(&game_account.data.borrow())?;

    // Validate current state
    if game.state != GameState::NotStarted {
        msg!("Game already started or finished");
        return Err(ProgramError::InvalidAccountData);
    }

    // Transition state
    game.state = GameState::InProgress;
    game.start_time = Clock::get()?.unix_timestamp;

    game.serialize(&mut &mut game_account.data.borrow_mut()[..])?;
    Ok(())
}

Arithmetic Safety

Always Use Checked Math

Rust default: Integer overflow/underflow panics in debug, wraps in release.

Solana requirement: Use checked operations to prevent wrapping.

// ❌ DANGEROUS - can overflow/underflow
let total = a + b;
let remaining = balance - withdrawal;

// ✅ SAFE - returns error on overflow/underflow
let total = a.checked_add(b)
    .ok_or(ProgramError::ArithmeticOverflow)?;

let remaining = balance.checked_sub(withdrawal)
    .ok_or(ProgramError::InsufficientFunds)?;

Common Checked Operations

// Addition
let sum = a.checked_add(b)
    .ok_or(ProgramError::ArithmeticOverflow)?;

// Subtraction
let diff = a.checked_sub(b)
    .ok_or(ProgramError::InsufficientFunds)?;

// Multiplication
let product = a.checked_mul(b)
    .ok_or(ProgramError::ArithmeticOverflow)?;

// Division
let quotient = a.checked_div(b)
    .ok_or(ProgramError::InvalidArgument)?;  // b could be 0

// Power
let power = base.checked_pow(exponent)
    .ok_or(ProgramError::ArithmeticOverflow)?;

Compound Operations

// Calculate: (a + b) * c / d
let result = a.checked_add(b)
    .and_then(|sum| sum.checked_mul(c))
    .and_then(|product| product.checked_div(d))
    .ok_or(ProgramError::ArithmeticOverflow)?;

Precision Loss

Be careful with division:

// ❌ Loses precision
let fee = amount / 100;  // 1.5% becomes 1%

// ✅ Better - multiply first, then divide
let fee = amount.checked_mul(15)
    .and_then(|v| v.checked_div(1000))
    .ok_or(ProgramError::ArithmeticOverflow)?;

Re-entrancy Protection

Solana's Built-in Protection

Good news: Solana provides strong protection against traditional re-entrancy:

  • Account locking: Accounts are locked during transaction execution
  • No concurrent modification: Same account can't be modified by multiple instructions simultaneously
  • Atomic transactions: Either all instructions succeed or all fail

Residual Risks

Cross-program state assumptions:

// ❌ RISKY - state can change between checks
pub fn risky_operation(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
) -> ProgramResult {
    let vault = &accounts[0];
    let mut vault_data = VaultData::try_from_slice(&vault.data.borrow())?;

    // Check balance
    let balance = **vault.lamports.borrow();
    if balance < 1000 {
        return Err(ProgramError::InsufficientFunds);
    }

    // CPI that might modify vault
    invoke(&some_instruction, accounts)?;

    // Balance might have changed!
    // Don't rely on previous check
    **vault.lamports.borrow_mut() -= 1000;  // Could underflow!

    Ok(())
}

Better:

pub fn safe_operation(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
) -> ProgramResult {
    let vault = &accounts[0];

    // CPI first
    invoke(&some_instruction, accounts)?;

    // Check and modify atomically
    let balance = **vault.lamports.borrow();
    let new_balance = balance.checked_sub(1000)
        .ok_or(ProgramError::InsufficientFunds)?;

    **vault.lamports.borrow_mut() = new_balance;

    Ok(())
}

Security Checklist

Pre-Deployment Checklist

Account Validation:

  • All signers verified with is_signer
  • All account owners checked
  • All PDAs validated with canonical bump
  • All accounts checked for initialization
  • Account types validated (discriminators)
  • Writable accounts verified

Input Validation:

  • All numeric inputs range-checked
  • All string inputs length-limited
  • All enum values validated
  • All business logic constraints enforced

Arithmetic:

  • All additions use checked_add
  • All subtractions use checked_sub
  • All multiplications use checked_mul
  • All divisions check for zero
  • No unsafe casting that could overflow

State Management:

  • State transitions validated
  • Initialization flags checked
  • No assumptions across CPI boundaries
  • Atomicity maintained

Error Handling:

  • All errors properly propagated
  • Meaningful error messages
  • No silent failures
  • Proper cleanup on errors

Testing Checklist

Security Testing:

  • Test with missing signers
  • Test with wrong account owners
  • Test with wrong PDAs (non-canonical bumps)
  • Test with uninitialized accounts
  • Test with re-initialized accounts
  • Test integer overflow/underflow
  • Test boundary conditions
  • Test with maximum values
  • Test with malicious input

Fuzzing:

  • Random account combinations
  • Random instruction data
  • Random ordering
  • Edge case values

Summary

Core Security Principles:

  1. Validate Everything - Assume all inputs are malicious
  2. Fail Fast - Return errors immediately when validation fails
  3. Use Checked Math - Prevent integer overflow/underflow
  4. Think Like an Attacker - Ask "How do I break this?"
  5. Test Malicious Cases - Don't just test happy paths

The Three Pillars of Account Security:

// 1. Signer Check
if !account.is_signer {
    return Err(ProgramError::MissingRequiredSignature);
}

// 2. Ownership Check
if account.owner != expected_owner {
    return Err(ProgramError::IllegalOwner);
}

// 3. PDA Validation (if applicable)
let (expected_pda, _) = Pubkey::find_program_address(&seeds, program_id);
if expected_pda != *account.key {
    return Err(ProgramError::InvalidSeeds);
}

Remember: Once deployed, you have no control over what transactions are sent to your program. Your only defense is rigorous validation.

Security is not a feature—it's a requirement.