Skip to content

Module 2: Solana Architecture Deep Dive

This module explores Solana's unique architecture that enables high throughput and low latency. Understanding these fundamentals is essential for building efficient programs.

Learning Objectives

By the end of this module, you will understand:

  • How Proof of History (PoH) enables Solana's speed
  • The accounts model and how data is stored
  • Programs vs smart contracts
  • Program Derived Addresses (PDAs)
  • Transaction structure and compute units
  • Network clusters and their purposes

1. Proof of History (PoH)

The Problem: Distributed Time

In distributed systems, agreeing on time is hard. Traditional blockchains waste resources agreeing on ordering:

Node A: "TX1 happened at 10:00:01"
Node B: "TX1 happened at 10:00:02"
Node C: "TX1 happened at 10:00:01"
         └── Who is correct? Need consensus!

Solana's Solution: Cryptographic Clock

PoH creates a verifiable sequence of hashes that proves time has passed:

                    ┌─────────────────────────────────────────┐
                    │         Proof of History Chain          │
                    └─────────────────────────────────────────┘

Hash 0 ──SHA256──► Hash 1 ──SHA256──► Hash 2 ──SHA256──► Hash 3
  │                  │                  │                  │
  │                  ▼                  │                  │
  │             Event A                 │                  │
  │         (mixed into hash)           ▼                  │
  │                               Event B                  │
  │                           (mixed into hash)            │
  ▼                                                        ▼
Genesis                                              Current State

How PoH Works

// Simplified PoH concept
fn proof_of_history(initial_hash: [u8; 32], events: Vec<Event>) -> Vec<HashEntry> {
    let mut entries = vec![];
    let mut current_hash = initial_hash;
    let mut counter = 0;

    loop {
        // Continuously hash
        current_hash = sha256(&current_hash);
        counter += 1;

        // When an event arrives, mix it in
        if let Some(event) = events.pop() {
            current_hash = sha256(&[current_hash, event.hash()].concat());
            entries.push(HashEntry {
                hash: current_hash,
                counter,
                event: Some(event),
            });
        }
    }
}

Key Insight

Each hash proves it came after the previous one. To verify:

  1. Take any hash in the sequence
  2. Run SHA256 the specified number of times
  3. Confirm you get the next hash

This creates a trustless timestamp without communication overhead.

PoH vs Other Approaches

Approach How it Orders Overhead
Bitcoin (PoW) Longest chain wins High energy, slow
Ethereum (PoS) Validator voting Communication rounds
Solana (PoH) Cryptographic sequence Single hash verification

2. Tower BFT Consensus

PoH is the clock; Tower BFT is the consensus mechanism.

How Tower BFT Works

Validators vote on the PoH sequence. Each vote has increasing lockout:

Vote 1: "Hash at slot 100 is valid"     → Locked for 2 slots
Vote 2: "Hash at slot 102 is valid"     → Locked for 4 slots
Vote 3: "Hash at slot 106 is valid"     → Locked for 8 slots
Vote 4: "Hash at slot 114 is valid"     → Locked for 16 slots
...
Vote 32: Locked for 2^32 slots (~100 years)

Exponential Lockout

Votes:    1    2    3    4    5    6    ...   32
Lockout:  2    4    8   16   32   64   ...  2^32 slots

After 32 consecutive votes on a fork, a validator is effectively permanently committed. This provides finality without explicit finality messages.

Optimistic Confirmation

Solana achieves fast confirmation through optimistic processing:

Confirmation Level Time Meaning
Processed ~400ms Transaction seen by leader
Confirmed ~400ms Supermajority voted (66%+)
Finalized ~12s 31+ votes on block (max rollback ~400ms)

3. The Accounts Model

Solana uses an accounts model, not UTXO (Bitcoin) or global state (Ethereum).

Account Structure

Every piece of data on Solana lives in an account:

┌─────────────────────────────────────────────────────────────┐
│                        Account                              │
├─────────────────────────────────────────────────────────────┤
│ lamports: u64          │ Balance in lamports (1 SOL = 10^9) │
├────────────────────────┼────────────────────────────────────┤
│ data: Vec<u8>          │ Arbitrary data (up to 10MB)        │
├────────────────────────┼────────────────────────────────────┤
│ owner: Pubkey          │ Program that controls this account │
├────────────────────────┼────────────────────────────────────┤
│ executable: bool       │ Is this account a program?         │
├────────────────────────┼────────────────────────────────────┤
│ rent_epoch: u64        │ Next epoch rent is due             │
└────────────────────────┴────────────────────────────────────┘

Account Types

// System Account (wallet)
Account {
    lamports: 1_000_000_000,  // 1 SOL
    data: [],                  // Empty
    owner: SystemProgram,      // System Program owns it
    executable: false,
    rent_epoch: 0,
}

// Program Account
Account {
    lamports: 5_000_000,
    data: [BPF bytecode...],   // Compiled program
    owner: BPFLoader,          // BPF Loader owns programs
    executable: true,          // Can be invoked
    rent_epoch: 0,
}

// Data Account (PDA)
Account {
    lamports: 2_000_000,
    data: [user state...],     // Serialized data
    owner: YourProgram,        // Your program owns it
    executable: false,
    rent_epoch: 0,
}

Owner Rules

Critical security concept:

  1. Only the owner can modify data
  2. Only the owner can debit lamports
  3. Anyone can credit lamports
  4. Only the System Program can assign new owners
┌──────────────────┐         ┌──────────────────┐
│   Your Program   │ ──owns──► │  Data Account   │
│   (executable)   │         │  (your state)    │
└──────────────────┘         └──────────────────┘
        │                            │
        │ can modify                 │ cannot modify
        ▼                            ▼
  ┌──────────────┐           ┌──────────────┐
  │ Account data │           │ Other data   │
  └──────────────┘           └──────────────┘

Accounts vs EVM Comparison

Aspect Solana Ethereum
State Location Separate accounts Contract storage
Code Location Separate executable account Same as state
Parallelism Accounts specified upfront Sequential
Size Limit 10MB per account 24KB code, unlimited storage
Cost Model Rent + tx fees Gas

4. Programs

Programs vs Smart Contracts

Solana "Program" Ethereum "Smart Contract"
Stateless (code only) Stateful (code + storage)
State in separate accounts State in contract storage
Compiled to BPF bytecode Compiled to EVM bytecode
Can be upgradeable Usually immutable

Program Execution Model

┌─────────────────────────────────────────────────────────────────┐
│                        Transaction                              │
├─────────────────────────────────────────────────────────────────┤
│ Instruction 1: Call Program A with accounts [1, 2, 3]          │
│ Instruction 2: Call Program B with accounts [3, 4]             │
│ Instruction 3: Call Program A with accounts [1, 5]             │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                      Solana Runtime                             │
├─────────────────────────────────────────────────────────────────┤
│ 1. Load Program A                                               │
│ 2. Pass accounts [1, 2, 3] and instruction data                │
│ 3. Execute Program A                                            │
│ 4. Load Program B                                               │
│ 5. Pass accounts [3, 4] and instruction data                   │
│ 6. Execute Program B                                            │
│ ... and so on                                                   │
└─────────────────────────────────────────────────────────────────┘

Native vs BPF Programs

Native Programs (built into runtime):
├── System Program         - Create accounts, transfer SOL
├── Token Program          - SPL token operations
├── Associated Token       - Create associated token accounts
├── BPF Loader             - Deploy and upgrade programs
└── Stake/Vote Programs    - Staking and voting

BPF Programs (user-deployed):
├── Your custom programs
├── DeFi protocols (Raydium, Orca, etc.)
├── NFT programs (Metaplex)
└── Any Anchor program

Upgradeable Programs

Solana programs can be upgraded (unlike most Ethereum contracts):

┌────────────────────┐     ┌────────────────────┐
│  Program Account   │────►│  Program Data      │
│  (proxy)           │     │  (actual code)     │
│                    │     │                    │
│  executable: true  │     │  executable: false │
│  owner: BPFLoader  │     │  owner: BPFLoader  │
└────────────────────┘     └────────────────────┘
         │ Upgrade Authority
┌────────────────────┐
│  Authority Keypair │
│  (can update code) │
└────────────────────┘

5. Program Derived Addresses (PDAs)

PDAs are the foundation of Solana program architecture.

What is a PDA?

A PDA is an address that:

  1. Has no private key (cannot sign transactions)
  2. Is derived deterministically from seeds
  3. Can only be "signed" by the program that derived it

PDA Derivation

// PDA = hash(seeds, program_id, "ProgramDerivedAddress")
// If result is on the Ed25519 curve, add bump and retry

use solana_program::pubkey::Pubkey;

let seeds = &[
    b"user-stats",           // Static seed
    user_pubkey.as_ref(),    // Dynamic seed (user's wallet)
];

let (pda, bump) = Pubkey::find_program_address(seeds, &program_id);

// pda: The derived address
// bump: The "bump seed" that makes it off-curve (0-255)

Why PDAs?

Problem: How does a program "own" accounts?

Without PDAs:
┌────────────┐
│  Program   │ ──cannot sign──► Account creation fails!
└────────────┘

With PDAs:
┌────────────┐                   ┌─────────────────┐
│  Program   │ ──derives PDA──►  │  PDA Account    │
└────────────┘                   │  owner: Program │
      │                          └─────────────────┘
      │ invoke_signed()
"Signs" with seeds + bump

Common PDA Patterns

// 1. Global state (one per program)
let (config_pda, _) = Pubkey::find_program_address(
    &[b"config"],
    &program_id
);

// 2. User-specific state
let (user_pda, _) = Pubkey::find_program_address(
    &[b"user", user.key().as_ref()],
    &program_id
);

// 3. Relationship between entities
let (escrow_pda, _) = Pubkey::find_program_address(
    &[b"escrow", seller.key().as_ref(), buyer.key().as_ref()],
    &program_id
);

// 4. Vault for holding tokens
let (vault_pda, _) = Pubkey::find_program_address(
    &[b"vault", mint.key().as_ref()],
    &program_id
);

PDA vs Keypair Accounts

Aspect PDA Keypair Account
Private key None Exists
Creation Program derives User generates
Signing Program with invoke_signed Wallet signs
Deterministic Yes (same seeds = same address) No
Use case Program-owned state User wallets

6. Rent and Account Lifecycle

Why Rent?

Storing data on-chain has costs. Rent ensures:

  1. Accounts pay for storage
  2. Unused accounts eventually get cleaned up
  3. Network remains sustainable

Rent Calculation

// Rent is based on data size
// As of 2024: ~0.00089088 SOL per byte per year

let data_size: usize = 165;  // bytes
let rent = Rent::get()?;

// Minimum balance for rent exemption (2 years worth)
let min_balance = rent.minimum_balance(data_size);
// ≈ 0.00147 SOL for 165 bytes

Rent Exemption

Most accounts are rent-exempt (prepay 2 years of rent):

┌─────────────────────────────────────────────────────────────┐
│ Rent-Exempt Account                                         │
├─────────────────────────────────────────────────────────────┤
│ lamports >= minimum_balance(data.len())                     │
│                                                             │
│ Benefits:                                                   │
│ - Never charged rent                                        │
│ - Never deleted                                             │
│ - Most common approach                                      │
└─────────────────────────────────────────────────────────────┘

Account Lifecycle

1. Creation
   └── Fund with rent-exempt amount + any additional SOL

2. Active Use
   └── Read/write by owner program
   └── Remains rent-exempt as long as balance maintained

3. Closing (optional)
   └── Zero out data
   └── Transfer lamports to recipient
   └── Account marked for deletion

Closing Accounts (Recovering Rent)

// In Anchor, closing is simple:
#[derive(Accounts)]
pub struct CloseAccount<'info> {
    #[account(
        mut,
        close = recipient  // Transfers lamports to recipient
    )]
    pub account_to_close: Account<'info, MyData>,

    #[account(mut)]
    pub recipient: SystemAccount<'info>,
}

7. Transaction Anatomy

Transaction Structure

┌─────────────────────────────────────────────────────────────────┐
│                        Transaction                              │
├─────────────────────────────────────────────────────────────────┤
│ Signatures: [Sig1, Sig2, ...]                                  │
│   └── Array of Ed25519 signatures                              │
├─────────────────────────────────────────────────────────────────┤
│ Message:                                                        │
│   ├── Header                                                    │
│   │     ├── num_required_signatures: u8                        │
│   │     ├── num_readonly_signed_accounts: u8                   │
│   │     └── num_readonly_unsigned_accounts: u8                 │
│   ├── Account Keys: [Pubkey1, Pubkey2, ...]                    │
│   │     └── All accounts referenced in transaction             │
│   ├── Recent Blockhash                                         │
│   │     └── Prevents replay, expires in ~2 minutes             │
│   └── Instructions: [                                          │
│         {                                                       │
│           program_id_index: u8,     // Index into account keys │
│           accounts: [u8, ...],      // Indices into account keys│
│           data: Vec<u8>             // Instruction data        │
│         },                                                      │
│         ...                                                     │
│       ]                                                         │
└─────────────────────────────────────────────────────────────────┘

Instruction Structure

pub struct Instruction {
    /// Program to execute
    pub program_id: Pubkey,

    /// Accounts required by the instruction
    pub accounts: Vec<AccountMeta>,

    /// Instruction data (serialized arguments)
    pub data: Vec<u8>,
}

pub struct AccountMeta {
    pub pubkey: Pubkey,
    pub is_signer: bool,     // Must sign transaction?
    pub is_writable: bool,   // Will be modified?
}

Transaction Limits

Limit Value
Max size 1232 bytes
Max accounts 64 (with lookup tables: 256)
Max instructions Limited by size
Blockhash validity ~150 blocks (~1-2 minutes)

8. Compute Units and Fees

Compute Units (CU)

Every operation costs compute units:

// Operation costs (approximate)
SHA256 hash:           ~100 CU
Ed25519 verify:        ~2000 CU
Account load:          ~100 CU per account
Cross-program invoke:  ~1000 CU base
Memory allocation:     ~1 CU per byte

Compute Budget

// Default: 200,000 CU per instruction
// Maximum: 1,400,000 CU per transaction

// Request more CU if needed:
ComputeBudgetInstruction::set_compute_unit_limit(400_000)

// Set priority fee (in micro-lamports per CU):
ComputeBudgetInstruction::set_compute_unit_price(1000)

Fee Calculation

Transaction Fee = Base Fee + Priority Fee

Base Fee = 5000 lamports (0.000005 SOL)
Priority Fee = Compute Units × Price per CU

Example:
- 200,000 CU used
- 1000 micro-lamports per CU priority
- Total: 5000 + (200,000 × 1000 / 1,000,000) = 5000 + 200 = 5200 lamports

Priority Fees and Congestion

Low Congestion:    Base fee only (~0.000005 SOL)
Medium:           + 1,000 micro-lamports/CU
High:             + 10,000 micro-lamports/CU
Very High:        + 100,000+ micro-lamports/CU

Typical transaction: 0.000005 - 0.001 SOL

9. Clusters

Available Clusters

Cluster Purpose RPC Endpoint
Mainnet-beta Production https://api.mainnet-beta.solana.com
Devnet Development https://api.devnet.solana.com
Testnet Validator testing https://api.testnet.solana.com
Localnet Local development http://localhost:8899

Cluster Selection

# CLI configuration
solana config set --url devnet
solana config set --url mainnet-beta
solana config set --url localhost

# Check current config
solana config get

Devnet vs Mainnet

Aspect Devnet Mainnet
SOL value Free (airdrop) Real money
Reset frequency Occasional Never
Program deployment Free to test Costs real SOL
Use case Development Production
# Get devnet SOL
solana airdrop 2

# Check balance
solana balance

10. Interactive: Explore Solana Explorer

Exercise: Explore Account Structure

  1. Open Solana Explorer

  2. Search for a token mint (e.g., paste any SPL token address)

  3. Observe the account structure:

  4. Lamports: Balance in lamports
  5. Owner: Token Program
  6. Data: Serialized mint data
  7. Executable: false (data account)

  8. Click on a transaction to see:

  9. Signatures
  10. Instructions
  11. Account changes
  12. Compute units used

Exercise: View a Program

  1. Search for the Token Program: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA

  2. Notice:

  3. Executable: true
  4. Owner: BPF Loader
  5. Data: Contains BPF bytecode

Exercise: Find a PDA

  1. Use the CLI to derive a PDA:

    # In a Rust project or using solana-keygen
    # Seeds: ["metadata", token_metadata_program_id, mint_pubkey]
    

  2. Search for that PDA on Explorer

  3. Verify the owner is the Metaplex Token Metadata program

Summary

Concept Key Takeaway
Proof of History Cryptographic clock for ordering without consensus overhead
Tower BFT Exponential lockout voting for fast finality
Accounts All data lives in accounts with owner, lamports, data
Programs Stateless code that operates on accounts
PDAs Deterministic addresses for program-owned state
Rent Storage costs, usually prepaid for exemption
Transactions Signed messages with instructions and account lists
Compute Units Measure of computational work, affects fees

Self-Assessment

Why can't PDAs sign transactions like regular keypairs?

PDAs are specifically derived to be off the Ed25519 curve. This means there's no valid private key that could produce that public key. Only the program that derived the PDA can "sign" for it using invoke_signed() with the original seeds.

What happens if an account doesn't have enough lamports for rent?

If an account falls below rent-exempt threshold and isn't rent-exempt, it will be charged rent each epoch. If the balance reaches zero, the account is purged from the ledger. Best practice: always fund accounts to be rent-exempt.

Why must transactions specify all accounts upfront?

Solana achieves parallelism by knowing which accounts each transaction will access before execution. Transactions touching different accounts can run simultaneously. This is why you must declare all accounts in the instruction.

Next Steps

Now that you understand Solana's architecture, let's learn Rust patterns for building programs:

Module 3: Rust for Solana


Further Reading