T10 · Solana Programs
Without contract storage, how does a program remember state?
- Capabilities practiced
- Builder
- Hands on
- Write a counter program in Anchor, deploy it to Devnet, call it from a client, and read back its state.
- AI Lab
- Have AI review your account validation logic. Verify yourself whether each issue it flags really exists.
A Real-World Problem
You plan to move the deposit contract from T7 to Solana. You get stuck on the first line.
You want to write balanceOf[user] += amount, but a program has no storage. There are no state variables or mappings, and the documentation offers no location for “this program’s own data.”
Then comes a second surprise: when submitting a transaction, the caller must list in advance which accounts the transaction will read and write. The program does not search for data. The caller hands it over.
A third surprise follows: if the caller supplies the accounts, what happens if one is wrong? What if they hand you someone else’s account and you modify it?
All three point to the same fact: on Solana, the chain does not guarantee that “this data belongs to this person” for you; you must prove it yourself. This chapter explains where state lives and how to prove it is the right state.
Thought Experiment
Imagine a Solana program as a pure function with no hard drive.
It contains code but no private storage. On each call, someone hands it a stack of folders (accounts). The function reads and edits the folders, then ends. The folders do not belong to the function; it is merely authorized to change them.
Each folder has four labels:
address Where this folder lives
owner Which program may change its contents
data The bytes inside
lamports The money inside (also its “rent deposit”)The key rule: only the owner program can modify a folder’s contents. Other programs may read them but cannot change them. This is the foundation of the model.
Consider two questions.
Question one: can callers hand over the wrong folder?
Yes, and easily. Your program should “increment this user’s counter,” but the caller supplies someone else’s counter. The function modifies it because all it sees is a stack of bytes.
Therefore, the program must check that “the folder you handed me really is the one I expect.” On the EVM, balanceOf[msg.sender] inherently selects the caller’s entry. On Solana, you must write that guarantee yourself.
Question two: if callers supply folders, how can a program create a folder “belonging only to itself”?
Derive the folder’s address from the program ID and a label you choose. Such an address has two properties: anyone can calculate it (so clients know which account to pass), but no one has its private key (so only the deriving program can act on its behalf).
Thus EVM’s balanceOf[user] becomes the account derived from “[label, user address]” on Solana. Each entry of the mapping becomes a separate, addressable account.
- The caller supplies accounts
- The program must validate them
- Derived addresses make ownership computable and verifiable
Your Decision
You want a simple counter: each user has their own count, and increment adds one. How do you ensure a user can increment only their own?
What Happens
Together, the four designs show how Solana’s account model determines both security and performance:
| Design | What proves ownership? | Transferable? | Parallelism | Typical use |
|---|---|---|---|---|
| No validation | Nothing | — | — | Incident reports only |
| Authority stored in data | Program compares it at runtime | Yes | High | Resources with transferable ownership |
| Derived address | The derivation itself | No | High | Per-user ledgers, settings, vaults |
| Global singleton | Ownership is not distinguished | — | Low; writes block each other | Global settings, statistics |
Make this conclusion second nature:
On the EVM, the chain ensures that “you only change your own entry.” On Solana, you write the check that makes that true.
This is not about one model being better. It is an exchange: Solana requires “which data will this transaction access?” to be declared in advance, enabling parallel execution. The price is moving validation responsibility from the platform to program authors.
That is why Solana and EVM security audits look so different. EVM reviews focus on reentrancy, overflow, and permissions. Solana reviews concentrate on missing account validations.
Build a Model
Five Account Fields
Account
├─ address 32-byte address
├─ owner Program allowed to change data; ordinary wallets are owned by the system program
├─ data Arbitrary bytes, interpreted by the program
├─ lamports Balance; must also reach the rent-exemption threshold or the account is reclaimed
└─ executable Whether this is a program or dataThree account categories correspond to three roles:
| Type | Owner | What is in data? | Who can change it? |
|---|---|---|---|
| Wallet account | System program | Empty | Holder of the private key (signature) |
| Data account | Your program | A structure you define | Only your program |
| Program account | Loader | Executable code | Holder of upgrade authority |
Account Validation Checklist
This is the part of the chapter to print and post beside your monitor. For every supplied account, check:
- Is the owner correct? Is this account owned by my program? If not, its data cannot be trusted.
- Is the address correct? If it should be derived, repeat the derivation with the same labels and compare it to the supplied address.
- Did it sign? Did an account whose authorization is needed actually sign this transaction? Checking its address without checking a signature is no check at all.
- Is the type correct? Different structures can have the same byte length. You need a marker to distinguish them, or an attacker can pass type A as type B.
- Are they distinct? When two different accounts are required, confirm their addresses differ. Many “transfer from yourself to yourself” bugs start here.
- Is it writable? Did the caller mark an account that must change as writable?
Every omitted check corresponds to a real attack class. Points 3 and 1 have historically been exploited most often.
Derived Addresses and Cross-Program Calls
Derived addresses use a program ID and chosen labels to produce an address outside the elliptic curve. In other words, there is mathematically no corresponding private key. No human can sign for it, but the deriving program can “sign” on its behalf during a call.
This has two direct uses:
- Treat it as a mapping entry:
[b"counter", user address]identifies that user’s counter. - Treat it as a vault: assets sit in a derived account, and only program logic can move them out; there is no private key to steal.
Cross-program invocation lets one program call another during execution. It can include the labels of its own derived account so the callee knows “this program authorized it.” Token transfers, account creation, and calls to other protocols all use this path.
Turn Validation into Declarations
Writing all six checks by hand is long and easy to get wrong. A mainstream Rust framework turns them into declarative constraints on account structures:
use anchor_lang::prelude::*;
// Replace this with your real program ID from `anchor keys list` before deployment; do not copy it unchanged
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod counter {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.authority = ctx.accounts.user.key();
counter.count = 0;
Ok(())
}
pub fn increment(ctx: Context<Increment>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
// Production code should use checked_add and return an error on overflow, not wrap around
counter.count = counter.count.checked_add(1).unwrap();
Ok(())
}
}
#[account]
pub struct Counter {
pub authority: Pubkey, // 32 bytes
pub count: u64, // 8 bytes
}
#[derive(Accounts)]
pub struct Initialize<'info> {
// init This program creates the account and becomes its owner
// payer Who pays the rent-exemption deposit
// space 8-byte type marker + 32 + 8
// seeds/bump Address is derived from a fixed label and user; anyone can calculate it, only this program can write it
#[account(
init,
payer = user,
space = 8 + 32 + 8,
seeds = [b"counter", user.key().as_ref()],
bump
)]
pub counter: Account<'info, Counter>,
#[account(mut)]
pub user: Signer<'info>, // The Signer type itself enforces a signature
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Increment<'info> {
// seeds/bump Derive the address again and compare it with the supplied one
// has_one The authority in account data must equal the authority account below
#[account(
mut,
seeds = [b"counter", authority.key().as_ref()],
bump,
has_one = authority
)]
pub counter: Account<'info, Counter>,
pub authority: Signer<'info>,
}The framework handles five of the six checks here:
- The
Accountwrapper automatically checks that this program is the owner and the type marker matches (the extra eight bytes are that marker). seedsandbumpautomatically derive and compare the address.Signerautomatically checks the signature.mutdeclares writability.has_onechecks consistency between a field and an account.
The framework enforces the constraints you wrote, not the ones you forgot. If increment omits has_one and seeds, it will still compile, run, and pass every happy-path test, until someone uses it to change another person’s counter. The AI Lab will test this directly.
What It Is Called
The container for all data on Solana: wallets, token balances, programs, and settings are all accounts.
Four key fields: address, owner (which program may modify it), data (its bytes), and lamports (balance and rent-exemption deposit). Remember that only the owner program can change data, and half the confusion disappears.
A Solana “contract.” It is stateless: code without its own storage.
All state lives in data accounts it owns, which callers must explicitly list in transactions.
An address derived from a program ID and labels that lies outside the elliptic curve, so it has no private key.
It can be an addressable storage entry (in place of a mapping) or a vault controlled only by program logic. The deriving program can sign for it during cross-program calls.
A call from one program to another. Token transfers, account creation, and interaction with other protocols use it.
The caller can include its derived-account labels so the callee knows authorization came from that program. The callee is external code and should be treated as untrusted.
Proof that “the account the caller supplied is the one I expected.”
Six checks: owner, address derivation, signature, type marker, distinctness, and writability. This is central to Solana security; many incidents omit one of these checks.
An account needs at least a threshold number of lamports, proportional to its size, to be retained permanently; otherwise it can be reclaimed.
In practice, onchain storage requires a deposit owned by the account holder, refundable when the account closes. T11 turns this into the question of who pays for token accounts.
Hands-on
The purpose is not complex logic. It is to complete the Solana development loop and personally verify that account validation works.
Install the toolchain and initialize a project.
solana --version
anchor --version
anchor init counterVersions keep changing. Do not copy a tutorial with fixed version numbers; use documentation for the version you installed.
Type in the program above and obtain your own program ID.
anchor build
anchor keys list # Prints this project's program IDPut the resulting ID into declare_id! and your configuration, then build again. Almost everyone forgets this step once. The symptom after deployment is a “program ID mismatch” error.
Run local tests. First write two cases: normal initialization and increment, and a second wallet trying to increment the first wallet’s counter.
The second case must fail. If it succeeds, your constraints are wrong. This matters more than the first case.
Switch to Devnet, request faucet funds, and deploy.
solana config set --url devnet
solana airdrop 2
anchor deployFaucets rate-limit requests; try another supported method if needed. Stay on Devnet, and do not use a wallet holding real assets.
Call it from a client and read back state. Derive the counter account address from the same labels in your client, submit initialize and several increment calls, then fetch and decode the account. Confirm that count matches.
The key experience: the client calculates the account address rather than looking it up. That is the engineering value of derived addresses.
Inspect that account directly from the command line.
solana account <the account address you derived>You will see your program ID as owner and bytes as data. The first eight bytes are a type marker, the next 32 the authority, and the last eight the count. Check them manually and the account model becomes concrete.
Run a destructive experiment. In Increment, comment out has_one and seeds, redeploy, and repeat the test from step three in which one wallet changes another wallet’s counter.
This time it succeeds. After seeing that, you will never dismiss account validation as boilerplate again.
AI Lab
Give the model your complete program and ask three questions.
Step one:
For every instruction and every account in this Solana program, list what the account is
expected to be, which validations the code actually performs, and which are missing.
Use a table and do not suggest fixes yet.
Step two:
Choose the most serious suspected omission and describe a concrete attack:
which accounts the attacker supplies, which instructions they send and in what order,
and what they gain. Be specific about every account argument.
Step three:
Write a test in which the attack succeeds against the current code and fails after the fix.The model can usually produce a plausible checklist. It may confidently name checks the framework already performs. For example, it may claim “owner validation is missing” even though your account wrapper enforces it. Following that false positive can leave you with redundant checks while a real omission remains unnoticed.
It can also miss checks. Common omissions are supplying the same account for two roles that must be distinct, and failing to mark an account writable.
Steps two and three are the core of this lab. As in the previous chapter, this matters even more on Solana: an attack test that succeeds on vulnerable code is the only concrete proof of a vulnerability. If the model cannot produce one, its claimed flaw may not exist.
Finally, search the documentation for every constraint name it suggests. Invented constraint names are more common than you might think, and they often sound semantically perfect. The compiler catches names that do not exist; names that compile but mean something else are more dangerous.
Verify these points yourself after using AI
- For every vulnerability it identifies, you wrote an attack transaction that really succeeds; if you cannot prove it, the claim may be invented
- You looked up every constraint name and macro in the framework documentation; models can invent plausible names
- Did it call something a vulnerability even though the framework checks it automatically, such as owner or type-marker validation?
- Did it miss distinct-account checks or accounts that must be marked writable?
- Its repair code compiles, and your previously successful attack test now fails
- Compilation, passing tests, and security are three different things; do not equate them
Real-World Case
One of the most exploited Solana bug classes is astonishingly simple: a program checks that an account’s address equals the administrator address, but not that the account signed this transaction.
An attacker merely lists the public administrator address in the transaction. Anyone can do that, so the check passes.
This is hard to make with a framework’s signer type, but it has occurred repeatedly in hand-written programs. A matching address does not prove the person is present.
Two account types with different purposes have the same byte length. A program decodes supplied bytes as the expected structure without verifying the account’s actual type.
An attacker supplies a “configuration account” where a “vault account” is expected. The program interprets the same bytes under the wrong structure and reads or changes fields it should not touch.
That is why the framework puts an eight-byte type marker at the front of each account. In a hand-written program, you must add and verify that marker yourself.
A bridge program needed to read a special account maintained by the runtime to confirm that a valid signature-verification instruction appeared in a transaction.
It read the account but did not check that its address was the actual system account. An attacker supplied an account they controlled with contents imitating a successful signature check.
The bridge accepted the forged proof and minted unbacked assets, losing hundreds of millions of dollars.
The root cause is point 2 of this chapter’s checklist: the address was not verified. Each line of that checklist has a real loss behind it.
An instruction “move funds from account A to account B” fails to check that A and B differ.
An attacker supplies the same account for source and destination. The program reads a balance, subtracts and writes it, then reads a balance, adds and writes it. The second write overwrites the first, creating money out of nothing.
This can happen on EVM too, but is especially common on Solana because callers supply accounts externally. There is no basis for assuming “these are two different things.”
Change One Variable
An attacker deploys their own program, creates a fake account with the same byte layout and their address as authority, and supplies it to you.
Your program reads data that appears consistent: structure, authority, and signature all match. It then accepts state entirely controlled by the attacker.
That is why owner validation comes first: establish that data is trustworthy before discussing what its fields say.
The counter changes from “one per person” to “one for the whole network.”
It still works, but ownership distinctions disappear (anyone can increment), and every transaction writing it must queue behind the others.
Account partitioning determines both the security boundary and the available parallelism. It is one of the most important Solana design choices to make early, and harder to change later than EVM storage layout.
Transactions have a size limit, and the account list counts toward it. Too many accounts will not fit.
Write locks are another problem: the more accounts one transaction writes, the more likely it is to conflict with another, losing the parallelism advantage.
Common responses include splitting instructions, using lookup tables to compress account lists, or redesigning account structures. Needing many accounts is often a sign to rethink how you partition state.
Include the vault’s derived-account labels in the cross-program call so the token program knows your program authorized the transfer.
Once that path exists, vault security depends entirely on which code paths can reach the call. If any entry point can trigger it with one missing validation, the vault is open.
That is why a Solana program audit begins by finding every signed cross-program call and tracing backward through each path that can reach it.
Takeaway Questions
Why do we need a blockchain? Solana’s answer includes a concrete engineering choice: declare in advance which data a transaction touches, enabling parallel execution. It trades validation work for throughput. Understanding a chain means understanding exchanges like this.
Who bears the risk? On Solana, much of it sits with program authors. Some errors that the EVM model prevents automatically need explicit defenses here. The account validation checklist is a delivery requirement, not an optional best practice.
Who bears a loss when AI is wrong? This chapter’s AI Lab offers a small, real example: a model may identify a vulnerability that does not exist or invent a constraint name that may or may not compile. AI review of security code is useful only to the extent that you verify every claim yourself.
Chapter Check
In separate accounts. A program owns these accounts (it is their owner), but each is an independent entity with its own address and balance.
On a call, the client must list all accounts that the transaction will read or write. The program receives references to accounts, not private memory.
Because callers supply them, the program must check that they are the expected accounts.
No one can bypass the program’s logic to control that account.
An ordinary account is controlled by whoever holds its private key. A PDA is off-curve, so mathematically no corresponding key exists. Only the deriving program can act for it, and that program’s behavior is public code.
That is why vaults use PDAs: control moves from a private key to logic anyone can audit.
No. The framework turns handwritten checks into declarations, but an omitted declaration means an omitted check.
An instruction without ownership constraints can still compile, deploy, and pass every normal test. The framework handles fixed checks such as owner and type-marker validation, while you must declare business-specific ownership relationships.
For each account, walk through the six checks and ask who guarantees each one. A missing answer signals a flaw.
Callers can put any public address in a transaction’s account list, including the administrator’s.
The real proof of authorization is a signature by that account on this transaction. An address match alone is like admitting someone because they wrote a familiar name on a form.
Use the framework’s signer type to avoid this mistake, or check the signature flag explicitly in a hand-written program.
There is no standard answer. Ask:
- Who checks each account’s owner: the framework’s wrapper or your code?
- Does the program rederive every derived account using the same labels at runtime?
- Does every account whose authorization is needed actually have to sign, beyond matching an address?
- Could two accounts that should differ be supplied as the same one?
- Is there an account for which you cannot explain why it must be this exact account?
- Which entry points can trigger signed cross-program calls? Draw the call paths.
Point 5 is especially valuable: an account you cannot explain reflects a design you have not settled, which is even more dangerous than an obviously wrong check.
Key idea
A PDA is a program's own address space; CPI is how programs call one another.