25 KiB
Solana Account Model & Validation
This reference provides comprehensive coverage of Solana's account model, validation patterns, and rent mechanics for native Rust program development.
Table of Contents
- Account Structure
- Account Types
- Account Ownership
- Rent Mechanics
- Account Validation Patterns
- Security Best Practices
- Common Vulnerabilities
Account Structure
Every Solana account is a location on the blockchain that stores data. All accounts have a uniform structure defined by the Account struct:
pub struct Account {
/// lamports in the account
pub lamports: u64,
/// data held in this account
pub data: Vec<u8>,
/// the program that owns this account
pub owner: Pubkey,
/// this account's data contains a loaded program (and is now read-only)
pub executable: bool,
/// the epoch at which this account will next owe rent (DEPRECATED)
pub rent_epoch: Epoch,
}
Field Details
lamports (u64)
- The account's balance in lamports (1 SOL = 1,000,000,000 lamports)
- Every account must maintain a minimum balance for rent exemption
- Rent works as a refundable deposit - recoverable when account is closed
- Only the account owner can deduct lamports
- Any program can add lamports to any account
data (Vec)
- Maximum size: 10 MiB (10,485,760 bytes)
- Can contain any arbitrary sequence of bytes
- Structure defined by the owning program
- Common patterns:
- Program accounts: Executable code or pointer to program data account
- Data accounts: Serialized state (often using Borsh)
owner (Pubkey)
- The program ID that owns this account
- Critical security property: Only the owner can modify
dataor deductlamports - Cannot be changed after account creation (except by System Program for newly created accounts)
- Newly created accounts start owned by System Program
executable (bool)
true: Account contains executable program codefalse: Account is a data account- Cannot be changed after being set to
true
rent_epoch (Epoch)
- DEPRECATED - no longer used
- Remains in struct for backward compatibility
- Rent is now a one-time refundable deposit, not periodic payment
Account Types
1. Program Accounts (Executable)
Program accounts contain executable code and are owned by a loader program.
Simple Program Account Structure:
┌─────────────────────────────────────┐
│ Program Account │
├─────────────────────────────────────┤
│ lamports: 1000000 │
│ data: [executable bytecode] │
│ owner: BPFLoaderUpgradeab1e... │
│ executable: true │
└─────────────────────────────────────┘
Loader-v3 Program Structure (Upgradeable):
Programs deployed with loader-v3 use a two-account model:
┌─────────────────────────────────────┐
│ Program Account │
├─────────────────────────────────────┤
│ data: [pointer to program data] │ ──┐
│ executable: true │ │
└─────────────────────────────────────┘ │
│
▼
┌─────────────────────────────────────┐
│ Program Data Account │
├─────────────────────────────────────┤
│ data: [actual executable bytecode] │
│ executable: false │
└─────────────────────────────────────┘
This separation enables:
- Program upgrades without changing the program address
- Buffer accounts for staging uploads
- Separate upgrade authority management
2. Data Accounts (Non-Executable)
Data accounts store program state and are owned by programs (or System Program).
a) Program State Accounts
Accounts created and owned by your program to store application state:
// Example: Note account owned by a note-taking program
pub struct NoteAccount {
pub is_initialized: bool,
pub author: Pubkey,
pub note_id: u64,
pub content: String,
}
Creation Process:
- Invoke System Program to create account (allocate space, transfer lamports)
- System Program transfers ownership to your program
- Your program initializes the account data
// Step 1: Create account via System Program CPI
invoke_signed(
&system_instruction::create_account(
initializer.key,
pda_account.key,
rent_lamports,
account_len.try_into().unwrap(),
program_id, // Transfer ownership to our program
),
&[initializer.clone(), pda_account.clone(), system_program.clone()],
&[&[seeds, &[bump_seed]]],
)?;
// Step 2: Initialize the account data
let mut account_data = try_from_slice_unchecked::<NoteAccount>(&pda_account.data.borrow())?;
account_data.is_initialized = true;
account_data.author = *initializer.key;
account_data.note_id = note_id;
account_data.content = content;
account_data.serialize(&mut &mut pda_account.data.borrow_mut()[..])?;
b) System Accounts (Wallet Accounts)
Accounts owned by the System Program, typically used as user wallets:
┌─────────────────────────────────────┐
│ Wallet Account │
├─────────────────────────────────────┤
│ lamports: 1000000000 │
│ data: [] │
│ owner: 11111111111111111111... │ ← System Program
│ executable: false │
└─────────────────────────────────────┘
Characteristics:
- Can sign transactions (if you have the private key)
- Can pay transaction fees
- Can transfer SOL
- Created automatically when funded with SOL
c) Sysvar Accounts
Special accounts at predefined addresses that provide cluster state data:
| Sysvar | Address | Purpose |
|---|---|---|
| Clock | SysvarC1ock11111111111111111111111111111111 |
Current slot, epoch, timestamp |
| Rent | SysvarRent111111111111111111111111111111111 |
Rent rate calculation |
| EpochSchedule | SysvarEpochSchedu1e111111111111111111111111 |
Epoch duration info |
| SlotHashes | SysvarS1otHashes111111111111111111111111111 |
Recent slot hashes |
Access Pattern:
use solana_program::sysvar::{clock::Clock, Sysvar};
let clock = Clock::get()?;
let current_timestamp = clock.unix_timestamp;
Account Ownership
Ownership Rules
The Golden Rule: Only the account owner can:
- Modify the account's
datafield - Deduct lamports from the account
Critical Security Implication: Programs must verify account ownership to prevent unauthorized state modifications.
Ownership in Program Context
When a program receives accounts in an instruction:
pub fn process_instruction(
program_id: &Pubkey, // Your program's ID
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let data_account = next_account_info(account_info_iter)?;
// CRITICAL: Verify ownership before modifying
if data_account.owner != program_id {
return Err(ProgramError::IllegalOwner);
}
// Safe to modify - we own this account
// ...
}
AccountInfo Structure
Programs receive accounts as AccountInfo structs:
pub struct AccountInfo<'a> {
pub key: &'a Pubkey, // Account address
pub is_signer: bool, // Did this account sign the transaction?
pub is_writable: bool, // Is this account writable in this instruction?
pub lamports: Rc<RefCell<&'a mut u64>>, // Mutable lamport balance
pub data: Rc<RefCell<&'a mut [u8]>>, // Mutable data
pub owner: &'a Pubkey, // Owner program ID
pub executable: bool, // Is this executable?
pub rent_epoch: Epoch, // Deprecated
}
Key Operations:
// Read data
let data = data_account.data.borrow();
let account_state = MyState::try_from_slice(&data)?;
// Write data
let mut data = data_account.data.borrow_mut();
account_state.serialize(&mut *data)?;
// Modify lamports
**data_account.lamports.borrow_mut() += transfer_amount;
Rent Mechanics
Rent is a refundable security deposit required to store data on-chain. Despite the name "rent", it's not a recurring fee—it's a one-time deposit fully recoverable when the account is closed.
Rent Calculation
Rent is proportional to account size:
use solana_program::rent::Rent;
use solana_program::sysvar::Sysvar;
// Get current rent rates
let rent = Rent::get()?;
// Calculate minimum balance for rent exemption
let account_size: usize = 1000; // bytes
let rent_lamports = rent.minimum_balance(account_size);
Formula: Based on agave source:
minimum_balance = (LAMPORTS_PER_BYTE_YEAR * account_size) * EXEMPTION_THRESHOLD / slots_per_year
Constants:
LAMPORTS_PER_BYTE_YEAR: 3,480 lamportsEXEMPTION_THRESHOLD: 2.0 (200% of annual rent)- Typical cost: ~0.00139536 SOL per 100 bytes
Rent Exemption
All accounts must be rent-exempt. This means:
- Account lamport balance ≥
rent.minimum_balance(account.data.len()) - The Solana runtime enforces this requirement
- Non-exempt accounts cannot be created
Practical Example
pub fn create_data_account(
program_id: &Pubkey,
accounts: &[AccountInfo],
data_size: usize,
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let payer = next_account_info(account_info_iter)?;
let new_account = next_account_info(account_info_iter)?;
let system_program = next_account_info(account_info_iter)?;
// Calculate rent-exempt balance
let rent = Rent::get()?;
let rent_lamports = rent.minimum_balance(data_size);
// Create account with rent-exempt balance
invoke(
&system_instruction::create_account(
payer.key,
new_account.key,
rent_lamports, // Must be rent-exempt
data_size as u64,
program_id,
),
&[payer.clone(), new_account.clone(), system_program.clone()],
)?;
Ok(())
}
Closing Accounts (Recovering Rent)
To recover rent when an account is no longer needed:
pub fn close_account(
account_to_close: &AccountInfo,
destination: &AccountInfo,
) -> ProgramResult {
// Transfer all lamports to destination
let dest_lamports = destination.lamports();
**destination.lamports.borrow_mut() = dest_lamports
.checked_add(**account_to_close.lamports.borrow())
.ok_or(ProgramError::ArithmeticOverflow)?;
// Zero out lamports in closed account
**account_to_close.lamports.borrow_mut() = 0;
// Zero out data (security best practice)
let mut data = account_to_close.data.borrow_mut();
data.fill(0);
Ok(())
}
Important: The runtime will garbage-collect accounts with 0 lamports.
Account Validation Patterns
Proper account validation is critical for security. Programs must verify accounts before using them.
1. Ownership Check
Purpose: Ensure an account is owned by the expected program.
When to use:
- Before reading/writing account data
- When validating PDAs
- When ensuring proper account initialization
// Basic ownership check
if account.owner != program_id {
msg!("Account not owned by this program");
return Err(ProgramError::IllegalOwner);
}
// PDA ownership check (essential for security)
if note_pda.owner != program_id {
msg!("Invalid note account - wrong owner");
return Err(ProgramError::IllegalOwner);
}
Why it matters: Without ownership checks, malicious actors can pass arbitrary accounts that match the expected data format but are controlled by other programs or themselves.
2. Signer Check
Purpose: Verify that an account signed the transaction.
When to use:
- Before transferring funds from an account
- Before modifying user-specific data
- Before any privileged operation
if !initializer.is_signer {
msg!("Missing required signature");
return Err(ProgramError::MissingRequiredSignature);
}
// Practical example: Only allow note author to update
pub fn update_note(
program_id: &Pubkey,
accounts: &[AccountInfo],
new_content: String,
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let author = next_account_info(account_info_iter)?;
let note_pda = next_account_info(account_info_iter)?;
// Verify author signed the transaction
if !author.is_signer {
msg!("Author must sign to update note");
return Err(ProgramError::MissingRequiredSignature);
}
// Deserialize and verify author matches
let note_data = NoteAccount::try_from_slice(¬e_pda.data.borrow())?;
if note_data.author != *author.key {
msg!("Author mismatch");
return Err(ProgramError::IllegalOwner);
}
// Safe to proceed with update
// ...
}
3. Writable Check
Purpose: Verify an account is marked as writable.
When to use:
- Before modifying account data
- Before changing lamport balances
- Enforced automatically by runtime, but explicit checks improve clarity
if !account.is_writable {
msg!("Account must be writable");
return Err(ProgramError::InvalidAccountData);
}
4. Initialization Check
Purpose: Prevent re-initialization or use of uninitialized accounts.
Pattern: Flag-based initialization
#[derive(BorshSerialize, BorshDeserialize)]
pub struct DataAccount {
pub is_initialized: bool,
// ... other fields
}
impl DataAccount {
pub fn is_initialized(&self) -> bool {
self.is_initialized
}
}
// On creation - check NOT initialized
if account_data.is_initialized() {
msg!("Account already initialized");
return Err(ProgramError::AccountAlreadyInitialized);
}
// On update - check IS initialized
if !account_data.is_initialized() {
msg!("Account not initialized");
return Err(ProgramError::UninitializedAccount);
}
5. PDA Validation
Purpose: Verify a provided PDA matches expected derivation.
Critical for security: Always validate PDAs using canonical bump.
pub fn validate_pda(
program_id: &Pubkey,
accounts: &[AccountInfo],
note_id: u64,
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let author = next_account_info(account_info_iter)?;
let note_pda = next_account_info(account_info_iter)?;
// Derive expected PDA
let (expected_pda, _bump) = Pubkey::find_program_address(
&[
author.key.as_ref(),
note_id.to_le_bytes().as_ref(),
],
program_id,
);
// Validate match
if expected_pda != *note_pda.key {
msg!("Invalid PDA - seeds don't match");
return Err(ProgramError::InvalidSeeds);
}
Ok(())
}
Why use find_program_address instead of accepting a bump?
- Prevents bump seed manipulation attacks
- Ensures canonical bump is used
- Eliminates category of security vulnerabilities
6. Account Type Validation
Purpose: Ensure account contains expected data type.
Pattern: Discriminator/Type Field
#[derive(BorshSerialize, BorshDeserialize)]
pub enum AccountType {
Uninitialized,
UserProfile,
GameState,
}
#[derive(BorshSerialize, BorshDeserialize)]
pub struct AccountData {
pub account_type: AccountType,
// ... other fields
}
// Validation
let account_data = AccountData::try_from_slice(&account.data.borrow())?;
if !matches!(account_data.account_type, AccountType::UserProfile) {
msg!("Wrong account type");
return Err(ProgramError::InvalidAccountData);
}
Security Best Practices
1. Always Validate Before Trusting
Never assume accounts are correct. Always validate:
pub fn secure_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let user = next_account_info(account_info_iter)?;
let user_data_pda = next_account_info(account_info_iter)?;
// ✅ Signer check
if !user.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// ✅ Ownership check
if user_data_pda.owner != program_id {
return Err(ProgramError::IllegalOwner);
}
// ✅ PDA validation
let (expected_pda, _) = Pubkey::find_program_address(
&[b"user_data", user.key.as_ref()],
program_id,
);
if expected_pda != *user_data_pda.key {
return Err(ProgramError::InvalidSeeds);
}
// ✅ Initialization check
let data = UserData::try_from_slice(&user_data_pda.data.borrow())?;
if !data.is_initialized {
return Err(ProgramError::UninitializedAccount);
}
// Now safe to proceed
// ...
}
2. Fail Fast with Meaningful Errors
Return errors immediately when validation fails:
// ✅ Good - fail fast
if !account.is_signer {
msg!("User must sign the transaction");
return Err(ProgramError::MissingRequiredSignature);
}
// ❌ Bad - continues with invalid state
if account.is_signer {
// process...
}
3. Use Type Safety
Leverage Rust's type system for compile-time guarantees:
// Define a validated account type
pub struct ValidatedUserAccount<'a> {
info: &'a AccountInfo<'a>,
data: UserAccountData,
}
impl<'a> ValidatedUserAccount<'a> {
pub fn validate(
account: &'a AccountInfo<'a>,
program_id: &Pubkey,
) -> Result<Self, ProgramError> {
// Ownership check
if account.owner != program_id {
return Err(ProgramError::IllegalOwner);
}
// Deserialize and validate
let data = UserAccountData::try_from_slice(&account.data.borrow())?;
if !data.is_initialized {
return Err(ProgramError::UninitializedAccount);
}
Ok(Self { info: account, data })
}
}
// Usage guarantees validated account
pub fn process_with_validated_account(
validated: ValidatedUserAccount,
) -> ProgramResult {
// No need to re-validate!
// ...
}
4. Check Arithmetic Operations
Always use checked math to prevent overflow/underflow:
// ❌ Dangerous - can overflow
let total = amount1 + amount2;
// ✅ Safe - returns error on overflow
let total = amount1
.checked_add(amount2)
.ok_or(ProgramError::ArithmeticOverflow)?;
5. Validate Data Constraints
Check business logic constraints:
pub fn allocate_points(
character_account: &AccountInfo,
new_strength: u8,
) -> ProgramResult {
let mut character = Character::try_from_slice(&character_account.data.borrow())?;
// Validate attribute cap
if character.strength.checked_add(new_strength).ok_or(ProgramError::ArithmeticOverflow)? > 100 {
msg!("Attribute cannot exceed 100");
return Err(ProgramError::InvalidArgument);
}
// Validate allowance
if new_strength > character.available_points {
msg!("Insufficient available points");
return Err(ProgramError::InsufficientFunds);
}
character.strength += new_strength;
character.available_points -= new_strength;
character.serialize(&mut &mut character_account.data.borrow_mut()[..])?;
Ok(())
}
Common Vulnerabilities
1. Missing Ownership Check
Vulnerability:
// ❌ No ownership validation
pub fn update_data(
program_id: &Pubkey,
accounts: &[AccountInfo],
new_value: u64,
) -> ProgramResult {
let data_account = &accounts[0];
// Dangerous - could be any account!
let mut data = MyData::try_from_slice(&data_account.data.borrow())?;
data.value = new_value;
data.serialize(&mut &mut data_account.data.borrow_mut()[..])?;
Ok(())
}
Exploit: Attacker passes an account they control that happens to deserialize correctly, modifying arbitrary data.
Fix:
// ✅ With ownership check
if data_account.owner != program_id {
return Err(ProgramError::IllegalOwner);
}
2. Missing Signer Check
Vulnerability:
// ❌ No signer validation
pub fn withdraw(
accounts: &[AccountInfo],
amount: u64,
) -> ProgramResult {
let user_account = &accounts[0];
let vault = &accounts[1];
// Dangerous - anyone can drain anyone's funds!
**user_account.lamports.borrow_mut() += amount;
**vault.lamports.borrow_mut() -= amount;
Ok(())
}
Exploit: Attacker calls instruction with victim's account, draining their funds without signature.
Fix:
// ✅ With signer check
if !user_account.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
3. PDA Substitution Attack
Vulnerability:
// ❌ Accepts PDA without validation
pub fn update_user_data(
program_id: &Pubkey,
accounts: &[AccountInfo],
user: &AccountInfo,
user_pda: &AccountInfo,
) -> ProgramResult {
// No PDA derivation check!
let mut data = UserData::try_from_slice(&user_pda.data.borrow())?;
data.balance += 100;
data.serialize(&mut &mut user_pda.data.borrow_mut()[..])?;
Ok(())
}
Exploit: Attacker passes a different user's PDA, crediting that user's balance instead.
Fix:
// ✅ Validate PDA derivation
let (expected_pda, _) = Pubkey::find_program_address(
&[b"user_data", user.key.as_ref()],
program_id,
);
if expected_pda != *user_pda.key {
return Err(ProgramError::InvalidSeeds);
}
4. Integer Overflow/Underflow
Vulnerability:
// ❌ Unchecked arithmetic
pub fn add_rewards(
account: &AccountInfo,
reward: u64,
) -> ProgramResult {
let mut user = UserData::try_from_slice(&account.data.borrow())?;
user.total_rewards = user.total_rewards + reward; // Can overflow!
user.serialize(&mut &mut account.data.borrow_mut()[..])?;
Ok(())
}
Exploit: Overflow wraps around: u64::MAX + 1 = 0, causing balance to reset.
Fix:
// ✅ Checked arithmetic
user.total_rewards = user.total_rewards
.checked_add(reward)
.ok_or(ProgramError::ArithmeticOverflow)?;
5. Unvalidated Account Reuse
Vulnerability:
// ❌ No initialization check
pub fn update_score(
accounts: &[AccountInfo],
score: u64,
) -> ProgramResult {
let score_account = &accounts[0];
let mut data = ScoreData::try_from_slice(&score_account.data.borrow())?;
// What if account was never initialized?
data.score = score;
data.serialize(&mut &mut score_account.data.borrow_mut()[..])?;
Ok(())
}
Exploit: Reusing uninitialized memory can lead to undefined behavior or data corruption.
Fix:
// ✅ Check initialization
if !data.is_initialized {
return Err(ProgramError::UninitializedAccount);
}
Summary
Critical Account Validation Checklist:
- ✅ Ownership check: Verify
account.owner == expected_program_id - ✅ Signer check: Verify
account.is_signerfor privileged operations - ✅ PDA validation: Use
find_program_addresswith expected seeds - ✅ Initialization check: Verify account is initialized before use
- ✅ Type validation: Ensure account contains expected data structure
- ✅ Rent exemption: Calculate and enforce rent-exempt balances
- ✅ Arithmetic safety: Use
checked_add,checked_sub, etc. - ✅ Data constraints: Validate business logic rules
Think Like an Attacker: For every account your program receives, ask:
- "What if this is the wrong account?"
- "What if this account isn't owned by my program?"
- "What if the user didn't sign for this?"
- "What if this account is uninitialized?"
- "What if these seeds derive a different PDA?"
Validate everything. Trust nothing.