
I've audited over 100 Solana programs and looked through many more. I've found more bugs than I can count.
Some took days to understand. Others were critical and obvious within a few minutes of opening the repo.
When I first open a program, I usually start with four things: CPIs, account lifecycle, remaining_accounts, and signature verification. These are all places where the program is doing security-critical checks by hand. A lot of the obvious criticals end up in one of those four buckets.
Anchor validates the accounts declared in an instruction context before it runs the handler. Once the code drops down to raw AccountInfos, manual parsing, dynamic CPIs, or the Instructions sysvar, more of the security model becomes the developer's responsibility. Anchor documents that validation order here.
Before reading much code, I usually run something close to this:
rg -n 'invoke(_signed)?|CpiContext::new|new_with_signer|remaining_accounts|Instructions|load_(current_index|instruction_at)_checked|get_instruction_relative|ed25519|secp256(k1|r1)|UncheckedAccount|AccountInfo|try_from_unchecked|init(_if_needed)?|close\s*=|realloc'
Then I start opening matches.
invoke_signedI search for invoke, invoke_signed, CpiContext::new, new_with_signer, and any local wrappers around them.
A Solana Instruction contains the program_id of the program being called. invoke_signed also lets the caller's PDAs act as signers. Signer and writable privileges propagate into the CPI, while the runtime prevents the callee from escalating beyond the privileges it received. The Solana CPI docs describe those rules.
If the target program isn't pinned, those accounts and privileges go to whichever executable program the caller supplied. If a PDA signed the CPI, the attacker-selected callee receives that signer privilege too. It still has to obey the normal runtime ownership rules, but the authority you forwarded is usually enough to cause trouble.
For a fixed callee, use a typed Program<'info, T>, an appropriate Interface, or an explicit allowlist. Current Anchor docs say Program<'info, T> checks the expected program ID and the executable flag. A generic Program<'info> checks only that the account is executable. That difference matters.
Then I check:
Checking the program ID and ignoring the forwarded authority is only half the review.
init and close handlerNext I list every init, init_if_needed, realloc, migration, and close.
These handlers define most of the account lifecycle: creation, resizing, migration, and deletion. Small mistakes tend to survive because each handler looks reasonable on its own.
Anchor's init constraint creates an account and sets its discriminator. Its close constraint sends the lamports to a target and resets the account data. Those are the mechanics Anchor provides. The protocol still has to decide who may create or destroy the object, which relationships become permanent, and when the transition is allowed.
For every initializer, I ask:
I give init_if_needed its own pass. It combines a creation path and an existing-account path in one instruction. Anchor's derive documentation explicitly warns about reinitialization attacks and recommends separating initialization from use unless there is a good reason to combine them. Read both flows as different instructions, even when the code puts them in one.
For every close, I trace:
I treat reallocations and migrations the same way. Check the old schema, the new schema, size math, payer or refund destination, zeroing behavior, and every invariant that must survive the transition.
remaining_accountsAnchor's docs are explicit: remaining_accounts are not deserialized or validated.
So I treat every loop over that slice as a wire-format parser. I write down the expected schema, then check:
If the code parses accounts in groups of three, it must reject any length that isn't divisible by three. A chunks_exact(3) loop that never checks the remainder can silently ignore trailing accounts.
Position is not validation either. "The fourth account is the vault" only means something after the code proves that account is actually the expected vault.
Signature verification code is another place where I regularly find serious bugs.
Solana's Ed25519, Secp256k1, and Secp256r1 precompiles verify signatures as native transaction instructions. They cannot be called through CPI. A program that relies on one normally reads the transaction through the Instructions sysvar and checks that a specific precompile instruction verified the expected signer and message. The precompile formats are documented here.
A valid signature can still authorize the wrong key, the wrong message, or an action that can be replayed.
For example, the Ed25519 instruction contains separate offsets and instruction indexes for the signature, public key, and message. Each value can come from another instruction in the transaction. If the protocol parses the instruction as if everything were inline but never constrains those indexes and offsets, the precompile may verify different bytes from the ones the protocol thinks it verified.
I check:
Use the checked sysvar loaders where possible. load_instruction_at_checked loads an instruction at an absolute transaction index and rejects an account that is not the real Instructions sysvar. You still have to validate the instruction it returns.
A valid signature only authorizes the bytes that were signed. I want to know exactly which bytes those were, what they mean, and where the same signature can be used again.
Only after that do I start digging into protocol math, accounting, and economic invariants. The first pass gives me a map of where the program accepts raw input, forwards authority, creates or destroys state, and trusts transaction-level signature checks. It makes the deeper review much faster.