DEV Community

Cover image for The Solana Program Security Checklist I Wish I'd Had on Day One
Lymah
Lymah Subscriber

Posted on

The Solana Program Security Checklist I Wish I'd Had on Day One

I spent the last two weeks thinking like an attacker.

I wrote tests whose only job was to make my own programs fail. I ran a fuzzer across thousands of generated inputs looking for the lamport value nobody would choose by hand. And I rebuilt the missing owner check that was at the center of the $326M Wormhole exploit, in a throwaway program, in a test, so I could watch it work and then watch the one-line fix stop it cold.

This checklist is what I would hand to past me on day one of that work.
Run it top to bottom before any Anchor program goes to mainnet.


Who this is for

You are writing Solana programs in Anchor. You understand accounts, PDAs, and CPIs. You have read the Anchor docs. What you do not yet have is a systematic way to check that you have not missed the failure modes that are specific to Solana's runtime, an account model where any account can be passed into any instruction, arithmetic that wraps silently in release builds without protection, and cross-program calls that trust whatever program ID you hand them.

This checklist is that systematic check. It is not a substitute for a professional audit on high-value programs. It is the thing you run before you even consider requesting one.


The Wormhole anchor

Before the list, the story that explains why account validation sits at the top.

In February 2022, an attacker drained $326M from the Wormhole bridge. The root cause was a single deprecated function, load_instruction_at
— that read a sysvar account's contents without first checking that the account was actually the real instructions sysvar. The attacker passed in a forged account they controlled. The program read it, trusted it, and authorized a mint it should have refused.

The fix was a single word: switch to load_instruction_at_checked, which verifies the account's address before reading it.

Every item in this checklist traces back to that same principle:
never read an account's contents until you have confirmed its
identity.
The items below are just that principle applied to every surface where you might forget it.

Sources: Ackee Blockchain Wormhole breakdown,
Halborn analysis


1. Account Validation

These are the checks Anchor helps most with, and the ones where
reaching past Anchor's types creates the most risk.

  • [ ] Every deserialized account has its owner verified. Use Account<'info, T> instead of UncheckedAccount<'info> or raw AccountInfo. Account<T> verifies the account is owned by your program and carries the right 8-byte discriminator before deserializing. A raw AccountInfo does neither.

  • [ ] No UncheckedAccount without a /// CHECK: comment that explains why skipping validation is safe. Anchor forces you to write this comment, treat every occurrence as a flag for manual review. If you cannot write a convincing sentence, switch to a typed account.

  • [ ] Account types are distinguished by their 8-byte discriminator. Anchor stamps every account with a discriminator derived from the type name. Account<'info, Vault> rejects a Config account passed in its place. Raw deserialization has no such guard.

  • [ ] PDA addresses are re-derived and compared, not trusted from the caller. Use seeds and bump constraints. An attacker can create a vault-shaped account at an arbitrary address; the seeds constraint proves the address is the canonical one.

  • [ ] The stored bump is used, not re-derived. Compute the bump once at initialization, store it on the account, and reuse it with bump = state.bump. Re-deriving adds compute and is unnecessary.

  • [ ] remaining_accounts are validated before use. Anchor does not automatically check accounts passed through remaining_accounts. If you use them, verify the owner discriminator and signer status manually.

What Anchor does automatically: owner check, discriminator check, signer flag, reinitialization guard (with init). What it does not: business logic, arithmetic, CPI target identity, or anything involving remaining_accounts.


2. Authority and Signer Checks

  • [ ] Every privileged instruction confirms the expected signer. Signer<'info> verifies the account signed the transaction.has_one = authority verifies the stored key matches. You need both: has_one without Signer lets anyone who knows the authority pubkey (which is public) call the instruction without their consent.

  • [ ] No instruction trusts a pubkey without also checking its
    signer flag.
    Knowing a public key is not the same as being able
    to sign for it.

  • [ ] Admin or upgrade authority accounts are explicitly constrained. Use #[account(address = EXPECTED_PUBKEY)] for hardcoded admins, not a manual pubkey comparison buried in handler logic.

  • [ ] has_one relationships are complete. If vault.authoritymust equal authority.key(), the has_one = authority constraint says so declaratively and fails before your handler runs. A manualrequire_keys_eq! in the handler is a second line of defense, not the first.


3. Arithmetic Safety

I fuzzed a deposit function with a property test that ran hundreds of generated u64 pairs. The property: a deposit either grows the balance or returns None honestly. With raw +, the property fails on overflow inputs, the balance wraps to a tiny number, and the test catches it. With checked_add, every overflow returns None and the test passes.

  • [ ] Every balance or supply change uses checked_add,
    checked_sub, checked_mul, or checked_div.
    Never raw +, -, * on untrusted values. Solana's runtime sets overflow-checks = true for release builds, which turns arithmetic overflow into a panic rather than a silent wrap — but checked_* lets you return a named error instead of a panic, which is cleaner for users and easier to test.

  • [ ] Overflow and underflow are handled explicitly, not suppressed. .ok_or(VaultError::InsufficientFunds)? is correct..unwrap_or(u64::MAX) is not.

  • [ ] No silent cast that could truncate. A u64 → u32 cast
    silently drops the upper bits. Use u64::try_from(value)? and
    handle the error.

  • [ ] Division by zero is guarded. If a denominator can be
    user-supplied, check it before dividing.


4. CPI Safety

  • [ ] Every CPI verifies the target program ID is the expected one. Do not accept the program ID from the caller without checking it. Use Program<'info, Token> or Interface<'info, TokenInterface> rather than passing a raw AccountInfo for the program.

  • [ ] Accounts are reloaded after a CPI if their data is read again. A CPI can modify account state. Anchor does not automatically refresh your local view. Reload the account before reading fields that a CPI may have changed.

  • [ ] PDA signer seeds are correct and complete. When a PDA signs a CPI with .with_signer(signer_seeds), the seeds must include every component you used at initialization, including the bump byte. A mismatch causes a silent authorization failure.

  • [ ] CPIs from a PDA do not propagate the wrong authority. The PDA signs on its own terms. If your CPI passes additional signers, confirm each one is intentional.


5. Account Lifecycle

  • [ ] Closed accounts cannot be revived within the same transaction. Closing an account in instruction N and reopening it in instruction N+1 of the same transaction can reuse the zeroed data. Use Anchor's close = destination constraint, which zeroes the data, transfers lamports, and marks the account in a way that prevents reuse within the transaction.

  • [ ] No instruction allows reinitializing an initialized account. Use init (not init_if_needed) when you mean "create exactly once." init_if_needed silently does nothing if the account already exists, which can mask a caller reusing an account they do not own. If you must use init_if_needed, add an explicit check that the stored authority matches the current signer before trusting the existing data.

  • [ ] Rent-exempt deposits are accounted for. Accounts must maintain a minimum lamport balance. If your program moves lamports, confirm the source account remains rent-exempt after the transfer.


6. Pre-Deploy Hygiene

  • [ ] anchor keys sync has been run and the declared ID matches the keypair. A program deployed with a mismatched ID will fail every transaction.

  • [ ] Dependencies are current and free of known advisories. Run cargo audit before deploying. A single vulnerable dependency can compromise an otherwise correct program.

  • [ ] Adversarial tests pass, not just the happy path. If your test suite only verifies that valid inputs succeed, it proves nothing about what invalid inputs do. You need at least one test per security-critical constraint that proves the constraint actually fires when violated.

  • [ ] Property tests cover arithmetic functions. If you have a function that modifies a balance, a property test with a fuzzer (proptest, Trident) will find inputs your examples missed. A hand-picked example list cannot cover all of u64.

  • [ ] The program has been reviewed by someone who did not write it. Familiarity blindness is real. A reviewer who reads the code cold will ask questions the author stopped asking weeks ago.


Where Anchor helps and where it does not

Anchor's typed accounts automatically handle:

  • Program ownership verification (Account<'info, T>)
  • Discriminator check (rejects the wrong account type)
  • Signer flag verification (Signer<'info>)
  • Reinitialization guard (init constraint)
  • PDA address verification (seeds + bump)

Anchor cannot help with:

  • Your business logic (who is allowed to call what, under what conditions)
  • Arithmetic correctness (checked vs unchecked operations)
  • CPI target identity (you must verify the program ID yourself)
  • remaining_accounts (no automatic validation)
  • Anything you expressed as an UncheckedAccount

The checklist exists because the second list is where every real
exploit lives.


How to use this checklist

Print it or keep it open in a browser tab. Before any mainnet deploy, go through it top to bottom. For each item, answer yes or no against the actual code, not the code you remember writing. The items are written to be verifiable line by line.

This is a living document. If you find a gap, open an issue or leave a comment. A security checklist that never gets updated is just technical debt with a nice format.


Resources


Built from bugs I reproduced myself during Arc 12 of
*
#100DaysOfSolana*. If something is wrong, tell me in the comments.

Top comments (0)