Solana

Understanding Solana's Account Model

Solana's account model is unlike anything in Ethereum. Here's how it actually works and why it matters for program design.

If you’re coming from Ethereum, Solana’s account model will feel alien. That’s a good thing — it’s one of the main reasons Solana can process transactions in parallel.

Everything is an account

In Solana, everything lives in an account: your wallet balance, program code, program state, even the clock. An account is just a blob of bytes with some metadata:

pub struct Account {
    pub lamports: u64,       // SOL balance (in lamports)
    pub data: Vec<u8>,       // arbitrary byte storage
    pub owner: Pubkey,       // which program "owns" this account
    pub executable: bool,    // is this a program?
    pub rent_epoch: Epoch,   // for rent calculations
}

Account types

Not all accounts serve the same purpose. There are a few distinct categories:

  • Wallet accounts — owned by the System Program, hold SOL, executable: false, data is empty
  • Program accountsexecutable: true, data holds compiled BPF bytecode, owned by the BPF Loader
  • Data accounts — owned by a program, executable: false, data holds whatever serialized state the program puts there
  • Sysvar accounts — read-only runtime state like Clock, Rent, and EpochSchedule at fixed addresses

Everything else is just a variation on these. An SPL token mint is a data account owned by the Token Program. An associated token account is another data account owned by the Token Program. It’s accounts all the way down.

Programs don’t own their state

This is the key insight that confuses everyone at first: Solana programs are stateless. A program’s code is stored in one account, and the data it manages lives in separate accounts.

When you call a program, you pass in the accounts it needs to operate on. The program reads and writes those accounts. It doesn’t have global storage.

This is why Solana transactions include an explicit list of accounts — the runtime uses this to detect parallelism opportunities.

Signers and writable accounts

Every account in a transaction is tagged with two flags: is_signer and is_writable.

Account: [pubkey, is_signer, is_writable]

The runtime enforces these strictly. A program can’t write to an account unless it was marked writable. A transaction can’t debit an account unless its corresponding private key signed the transaction (or it’s a PDA the program controls).

This matters for security. If a user passes in an account but it wasn’t marked writable, the program can’t drain it — even if the program code tried. The runtime rejects the instruction before it executes.

In Anchor, these constraints are expressed as attribute macros:

#[derive(Accounts)]
pub struct Transfer<'info> {
    #[account(mut)]
    pub sender: Signer<'info>,

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

    pub system_program: Program<'info, System>,
}

Signer asserts is_signer = true. mut asserts is_writable = true. Anchor checks all of this for you before your handler runs.

Rent and rent exemption

Accounts on Solana take up space on validators. To pay for that, accounts owe rent — a fee proportional to their size.

In practice, almost nobody pays rent periodically. Instead, accounts are made rent-exempt by depositing enough lamports upfront (currently ~0.00203928 SOL per KB, roughly). A rent-exempt account is never charged and never deleted.

let rent = Rent::get()?;
let min_balance = rent.minimum_balance(space);

When you close an account and want to reclaim the lamports, you zero out its data, set lamports to 0, and reassign ownership back to the System Program. Anchor has a close constraint that handles this cleanly.

Forgetting to fund accounts correctly is one of the most common causes of InsufficientFunds errors when deploying programs for the first time.

PDAs — Program Derived Addresses

Since programs can’t store state themselves, they create PDA accounts that they control. A PDA is derived deterministically from a program ID and some seeds:

let (pda, bump) = Pubkey::find_program_address(
    &[b"user-stats", user.key().as_ref()],
    &program_id,
);

The bump ensures the address doesn’t land on the ed25519 curve (so no private key can sign for it). Only the owning program can sign for a PDA — via invoke_signed with the same seeds.

This is the mechanism behind almost all persistent state in Solana programs. Your “user profile”, “vault”, “config” — each of these is a PDA account the program created and controls.

Creating accounts

Accounts don’t exist until they’re created. Creating an account requires a cross-program invocation to the System Program:

system_program::create_account(
    CpiContext::new(
        ctx.accounts.system_program.to_account_info(),
        system_program::CreateAccount {
            from: ctx.accounts.payer.to_account_info(),
            to: ctx.accounts.new_account.to_account_info(),
        },
    ),
    lamports,   // enough for rent exemption
    space,      // bytes to allocate
    &program_id, // who will own this account
)?;

After this, the account exists and the owning program can serialize data into it. Anchor’s init constraint wraps this entire process:

#[account(
    init,
    payer = user,
    space = 8 + UserState::INIT_SPACE,
)]
pub user_state: Account<'info, UserState>,

The 8 + is for Anchor’s discriminator — 8 bytes at the start of every account that identify which struct owns the data.

Cross-Program Invocation (CPI)

Programs routinely call other programs — this is called a CPI. When a program makes a CPI, it passes along accounts from its own context. If it needs to sign for a PDA, it uses invoke_signed:

invoke_signed(
    &instruction,
    &[account_a, account_b, pda_account],
    &[&[b"vault", user.key().as_ref(), &[bump]]],
)?;

The runtime verifies that the seeds match a PDA of the calling program. If they do, the PDA is treated as having signed the instruction. This is how programs safely move funds out of vaults or modify accounts they own.

CPIs can nest up to 4 levels deep. Each level adds overhead but it’s enough for most composable program designs.

Why this design wins

The explicit account model means:

  1. Parallel execution — the runtime sees which accounts a transaction touches and can run non-overlapping transactions simultaneously
  2. Predictable costs — rent is based on account size, not compute
  3. Composability — programs can pass accounts between each other cleanly
  4. Security surface is visible — everything a transaction can touch is declared upfront, no hidden global state

It takes getting used to, but once it clicks, it’s a surprisingly elegant model. The verbosity of passing accounts everywhere isn’t a bug — it’s the feature that makes Solana’s parallelism and security model work.