Skip to content

Parsing Accounts and the Checks You Must Do by Hand

The previous page, The Native Program, showed you the whole skeleton: an entrypoint! macro that hands your process_instruction three arguments — a program_id, a slice of accounts, and a byte blob of instruction data. It stopped where the interesting part begins. This page is about the middle argument, accounts: &[AccountInfo], and the single most important sentence in native Solana development: that slice is untrusted, attacker-controllable input, and validating it is entirely your job.

Here is the throughline made concrete. The runtime gives you parallelism by making every transaction declare its footprint up front — the accounts it touches, in order. The price of that design is that the runtime hands your program exactly the accounts the caller named, in the order the caller chose, and guarantees almost nothing about them. A single global state machine stays consistent only if every program that mutates state first proves the accounts it was handed are the ones it expected. Skip a check and you have not written a slow program; you have written an exploitable one. This page is the checklist that stands between a correct program and a drained one.

Accounts arrive as an ordered, caller-chosen slice

Section titled “Accounts arrive as an ordered, caller-chosen slice”

Your handler’s signature is the entire contract with the runtime:

pub fn process_instruction(
program_id: &Pubkey, // the address this program was invoked as
accounts: &[AccountInfo], // the accounts the CALLER named, in the order they chose
instruction_data: &[u8], // the opaque argument blob
) -> ProgramResult

Read accounts carefully. It is a &[AccountInfo] — a flat, positional slice. The runtime did not pick these accounts; the transaction author did, when they built the instruction’s AccountMeta list (see The Instruction and Account Flags). Anyone can build a transaction. So the slice is adversarial input: an attacker can put whatever accounts they like in whatever positions they like, as long as they satisfy the runtime’s own coarse rules (a writable account is really writable, a signer really signed). Everything finer than that — is this the account I meant? — is unchecked until you check it.

The idiomatic way to pull accounts out of the slice is an iterator, so positions are consumed in the order the program declares:

use solana_program::account_info::{next_account_info, AccountInfo};
let account_info_iter = &mut accounts.iter();
let vault = next_account_info(account_info_iter)?; // position 0
let authority = next_account_info(account_info_iter)?; // position 1
let system_prog = next_account_info(account_info_iter)?; // position 2

next_account_info does exactly one thing for you: it returns Err(NotEnoughAccountKeys) if the slice ran out. That is the only check it performs. It does not verify identity, ownership, signing, or role — it just walks the list. The names vault, authority, system_prog are your intent; nothing about the bytes you received guarantees the caller shared that intent.

what the CALLER controls what the RUNTIME guarantees
───────────────────────── ──────────────────────────
• which pubkey sits in each slot • a writable slot is really writable
• the ORDER of the slots • a signer slot really signed
• the number of accounts • only owner may mutate data/lamports
• the instruction_data bytes
↑ everything else is YOUR job:
identity, owner, role, count

What the runtime actually guarantees — and what it does not

Section titled “What the runtime actually guarantees — and what it does not”

To know which checks are yours, you have to know precisely where the runtime stops. Its guarantee is narrow and mechanical: the only program permitted to mutate an account’s data or debit its lamports is that account’s owner program. That invariant is enforced in the loader after your handler returns — if your program tried to write an account it does not own, the whole transaction is rejected. That is the fence the runtime maintains for you.

Notice what that fence does not say:

  • It does not say the account in slot 0 is the specific vault you meant. Any account of the right shape fits the slot.
  • It does not say the account’s owner is your program. The runtime protects each account from other programs; it does not tell you what the owner is.
  • It does not say the account marked is_signer is the right signer. The runtime proved a signature exists; it did not prove the signature came from the authority you require.
  • It does not say the bytes in data are the type you think they are. To the runtime, data is an opaque Vec<u8>; the type is a convention only your code knows (see Stateless Programs Act on Accounts).

Everything in that list is a check you must perform in code. The runtime gives you a memory-safe sandbox and one ownership invariant; it hands you the accounts and trusts you to interrogate them.

The five manual checks, and how each one fails

Section titled “The five manual checks, and how each one fails”

There are five questions to ask of the accounts you were handed. Each maps to one field on AccountInfo, and each has a distinct failure mode when you forget it.

1. Identity — is this the key / PDA I expected?

Section titled “1. Identity — is this the key / PDA I expected?”

AccountInfo carries .key: &Pubkey. Before you trust an account for a role, confirm its key is the one you require — either a hard-coded canonical address, or, far more often, a PDA you re-derive from seeds and compare:

// The vault must be the PDA for this user, owned by this program. Re-derive and compare.
let (expected_vault, _bump) =
Pubkey::find_program_address(&[b"vault", authority.key.as_ref()], program_id);
if vault.key != &expected_vault {
return Err(ProgramError::InvalidArgument); // wrong account in the vault slot
}

Failure mode: if you skip this, an attacker passes their own look-alike account into the vault slot. Your program then reads its balance, applies its authority, or writes to it as if it were the real vault. This is the heart of the account-confusion bug class: a substituted account slipping into a trusted slot. (PDA derivation itself is covered on Program-Derived Addresses and Cross-Program Invocation; here it is a validation tool.)

2. Ownership — is account.owner the program I expect?

Section titled “2. Ownership — is account.owner the program I expect?”

AccountInfo carries .owner: &Pubkey. For any account whose data you are about to trust, verify its owner is the program you expect — usually your own program_id, or a known program like the SPL Token program:

if vault.owner != program_id {
return Err(ProgramError::IllegalOwner); // this account's data was written by someone else
}

Failure mode: without this check, an attacker constructs an account they own, fills its data with bytes that decode to whatever your program wants to see (“balance: 1,000,000”, “is_initialized: true”), and hands it in. Because the runtime only stops other programs from writing an account — not you from reading one — you will happily deserialize attacker-authored bytes and act on them. The owner check is what proves the bytes were written by code you trust.

3. Signer — is is_signer true for the required authority?

Section titled “3. Signer — is is_signer true for the required authority?”

AccountInfo carries .is_signer: bool. For any privileged action, confirm the account that must authorize it actually signed:

if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// ...and it must be the RIGHT authority, not just SOME signer:
if authority.key != &vault_state.authority {
return Err(ProgramError::InvalidArgument);
}

Note the two-step: is_signer proves a valid signature exists (the runtime already verified the ed25519 signature before your handler ran), but you must still prove it is the authority this vault records. A program that checks is_signer alone accepts any signer.

Failure mode: skip the signer check and anyone can invoke the privileged path without proving they control the authority key — a withdrawal with no owner approval. Check only is_signer and not identity, and an attacker signs with their own key and passes.

4. Writability — is is_writable true before I mutate?

Section titled “4. Writability — is is_writable true before I mutate?”

AccountInfo gives you .is_writable and access to the data via try_borrow_mut_data(). If you intend to write an account, the caller must have flagged it writable in the instruction. Attempting to mutate a read-only account fails, but failing late (mid-mutation, after other state changed) is worse than failing early:

if !vault.is_writable {
return Err(ProgramError::InvalidArgument); // caller did not declare this write
}
let mut data = vault.try_borrow_mut_data()?; // now safe to mutate

Failure mode: the runtime will reject the transaction if you write a non-writable account, so this is less a security check than a correctness and clarity one — it turns a confusing late failure into an explicit early one, and documents which accounts your instruction mutates.

5. Count — is the number of accounts correct?

Section titled “5. Count — is the number of accounts correct?”

next_account_info returns NotEnoughAccountKeys if the slice is too short, which catches underflow. But a caller can also pass more accounts than you need, and if a later branch of your code reaches for “the next account” it may grab an attacker-supplied extra. Pull exactly the accounts you expect, and be deliberate about extras.

if accounts.len() != 3 {
return Err(ProgramError::InvalidArgument); // exactly vault, authority, system_program
}

Failure mode: the companion crate solmini models this precisely — its programs open with an explicit count guard:

if accounts.len() != 2 {
return Err(SolError::AccountCount { expected: 2, got: accounts.len() });
}

Positional parsing means a wrong count silently shifts every role by one slot. A missing count check is how “the authority” ends up being read from the position that actually holds a fee payer — a role confusion an attacker can engineer.

Why these checks exist at all — the runtime’s minimal contract

Section titled “Why these checks exist at all — the runtime’s minimal contract”

Step back and ask why the runtime leaves so much to you. The answer is the same reason Solana is fast. For the Sealevel scheduler to run thousands of transactions in parallel, the runtime’s per-account bookkeeping must be tiny and uniform: it tracks an owner, a writable bit, a signer bit, and a lamport balance, and it enforces exactly one rule — only the owner writes. Any richer guarantee — “this account is a valid vault,” “this signer is the correct authority” — would require the runtime to understand application semantics, which it deliberately refuses to do. Meaning lives in your program; the runtime only moves bytes and enforces ownership.

So the manual checks are not an oversight. They are the deliberate boundary between a small, fast, uniform runtime and the application logic that gives accounts meaning. The runtime keeps the state machine sound (no program corrupts another’s data); you keep it correct (the right accounts play the right roles).

RUNTIME'S JOB (uniform, tiny) YOUR JOB (semantic, per-program)
─────────────────────────── ───────────────────────────────
only owner mutates data/lamports is this the account I expected? (key/PDA)
verify declared signatures exist is it owned by whom I think? (owner)
enforce writable declarations did the RIGHT party sign? (signer + identity)
move opaque bytes are these bytes the type I mean? (owner + discriminator)
did I get the right COUNT? (len check)

Owner plus discriminator — stopping the valid-but-wrong account

Section titled “Owner plus discriminator — stopping the valid-but-wrong account”

Two of the checks combine into the strongest single defense against account confusion. An attacker’s best move is to hand you an account that is structurally valid — the right size, deserializes cleanly — but is the wrong instance or wrong type. Two checks together defeat this:

  1. Owner check proves the bytes were written by a program you trust (your own, or SPL Token).
  2. Type tag / discriminator check proves the bytes are the type you expect, not a different struct of the same length that your program also uses.

In native Solana you carry an explicit tag yourself — a first byte or first field that says “I am a Vault” versus “I am a Config” — and you check it after deserializing. Anchor formalizes this as an 8-byte discriminator (a hash of the account’s type name) prepended to every account’s data, checked automatically. Either way, the pattern is: verify who wrote the bytes, then verify what the bytes are.

attacker hands you: an account of the exact right size, valid encoding
└─ but it is a DIFFERENT type, or a DIFFERENT instance
owner check: was this written by a program I trust? ──► no → reject
discriminator check: do the first bytes say it is a Vault? ──► no → reject
│ both yes
now, and only now, trust the deserialized state

Without both, “it deserialized without error” is the only thing your program knows — and successful deserialization is a property an attacker can trivially arrange.

Under the hood — a validated account load, end to end

Section titled “Under the hood — a validated account load, end to end”

Here is the shape of a well-guarded instruction, folding all five checks into the order they should happen: parse positionally, then validate identity and ownership before trusting any bytes, then check authorization, then mutate.

pub fn process_withdraw(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
// COUNT: exactly the accounts this instruction expects.
if accounts.len() != 2 {
return Err(ProgramError::NotEnoughAccountKeys);
}
let iter = &mut accounts.iter();
let vault = next_account_info(iter)?; // position 0
let authority = next_account_info(iter)?; // position 1
// IDENTITY: the vault must be the PDA for this authority.
let (expected, _bump) =
Pubkey::find_program_address(&[b"vault", authority.key.as_ref()], program_id);
if vault.key != &expected {
return Err(ProgramError::InvalidArgument);
}
// OWNER: the vault's data must have been written by THIS program.
if vault.owner != program_id {
return Err(ProgramError::IllegalOwner);
}
// Now the bytes are trustworthy: deserialize and check the type tag.
let mut state = VaultState::try_from_slice(&vault.try_borrow_data()?)?;
if state.tag != VAULT_TAG { // DISCRIMINATOR: valid-but-wrong defense
return Err(ProgramError::InvalidAccountData);
}
// SIGNER: the RIGHT authority must have signed.
if !authority.is_signer || authority.key != &state.authority {
return Err(ProgramError::MissingRequiredSignature);
}
// WRITABILITY: declared writable before we mutate.
if !vault.is_writable {
return Err(ProgramError::InvalidArgument);
}
// ...only now is it safe to apply the withdrawal and reserialize state.
Ok(())
}

Read the ordering as a rule of thumb: validate before you trust, trust before you mutate. Every return Err above is a slot an attacker can no longer control. The verbosity is the point — this is what Anchor generates from a #[derive(Accounts)] struct, and seeing it by hand is exactly what makes the next page’s constraints legible instead of magical.

  • Why does it exist? Because the runtime deliberately keeps its per-account guarantees minimal — an owner, two flags, a balance — so the scheduler can stay tiny and parallel. Manual account validation is the layer that restores application meaning the runtime refuses to track.
  • What problem does it solve? It closes the account-confusion gap: it stops an attacker from substituting a look-alike, wrong-type, or wrong-owner account into a trusted positional slot and having your program act on it as if it were genuine.
  • What are the trade-offs? You gain full control and a small runtime; you pay in boilerplate and, more dangerously, in easy-to-forget checks — every omission is a latent exploit, and the compiler cannot tell you which check you missed.
  • When should I avoid it? Avoid hand-rolling these checks when you can declare them: Anchor’s #[account(...)] constraints enforce owner, signer, key, and PDA checks from attributes, turning “remember to check” into “the framework checks.” Write raw checks when you need control Anchor does not express, and audit them ruthlessly.
  • What breaks if I remove it? Remove the checks and the runtime’s ownership fence is your only defense — which protects other programs’ accounts from you but does nothing to stop you from trusting an attacker-chosen account. That is precisely the hole the Wormhole exploit fell through.
  1. Why is the accounts: &[AccountInfo] slice described as “untrusted, attacker-controllable input”? Name two things the caller fully controls about it.
  2. State the one invariant the runtime enforces about account mutation, and then list three things it explicitly does not guarantee that are therefore your responsibility.
  3. Give the five manual checks by name, the AccountInfo field each inspects, and one distinct failure mode for each.
  4. Why do the owner check and a discriminator/type-tag check together defeat a “valid-but-wrong” account when neither alone does? What can an attacker trivially arrange that makes “it deserialized” worthless as a guarantee?
  5. In the Wormhole hack of 2 February 2022, which specific check was missing, on which account, and what single comparison would have stopped it? Which bug class does it exemplify?
Show answers
  1. Because the transaction author — who can be anyone — builds the instruction’s AccountMeta list, so they choose which pubkey sits in each slot and the order and number of slots. The runtime passes your program exactly those accounts; it does not vet them for semantic correctness. (Also attacker-controlled: the instruction_data bytes.)
  2. The one invariant: only an account’s owner program may mutate its data or debit its lamports. It does not guarantee that the account in a slot is the specific instance you meant (identity), that an account’s owner is your program, that an is_signer account is the correct authority, or that the data bytes are the type you expect — all of which are your checks.
  3. Identity (.key) — an attacker substitutes a look-alike account into a trusted slot (account confusion). Owner (.owner) — an attacker hands you an account they wrote, filled with bytes that decode to whatever you want to see. Signer (.is_signer) — a privileged action runs with no authorization, or with some signer who is not the required authority. Writability (.is_writable) — a late, confusing mid-mutation failure instead of an early explicit one. Count (accounts.len() / next_account_info) — a wrong count shifts every positional role by a slot, letting an attacker land the wrong account in an authority position.
  4. The owner check proves who wrote the bytes (a program you trust); the discriminator proves what the bytes are (the type you expect, not a same-length different struct). An attacker can trivially craft an account whose bytes deserialize cleanly into your target struct — successful deserialization is something they arrange, not something that proves authenticity — so it is worthless alone. Owner without type lets a wrong-type account owned by you slip through; type without owner lets an attacker-authored account of the right shape slip through. Both together close it.
  5. An identity check was missing on the instructions sysvar account: the program used it to verify guardian signatures but never confirmed the account handed to it was the real sysvar. A single if account.key != &<instructions-sysvar address> { return Err(...) } would have rejected the spoofed account before the signature logic ran. It exemplifies the account-confusion class — a spoofed account substituted for a genuine one to bypass a guard.