My Solana Program Security Checklist
This is for anyone shipping an Anchor program to mainnet — especially if you're coming from a web2 background and looking for the Solana equivalent of a PR template. Run it top to bottom before every deploy. Every item here came out of a bug I reproduced myself: I wrote adversarial tests that tried to rob my own vault, fuzzed the arithmetic until proptest handed me a counterexample, and rebuilt the exact missing-owner-check bug that drained Wormhole and Cashio. Nothing below is theoretical.
A quick note on how to read this: each item should be answerable with a plain yes or no by someone looking at the code, not a vague reminder to "be careful." If you can't verify an item just by reading the account struct and handler, rewrite it until you can.
- Account validation Every deserialized account has its owner verified. A typed Account<'info, T> does this for you automatically; a raw AccountInfo or UncheckedAccount does not, and manual deserialization off one of those is exactly how Wormhole lost roughly $326M — the program trusted the instructions sysvar without confirming the account's real address, and a forged account deserialized cleanly because nobody checked who owned it. Every #[account] type is distinguished by its 8-byte discriminator, so one account type can never be passed where another is expected. Any remaining_accounts are individually validated before use — they arrive with zero automatic checks. Every /// CHECK: comment in the codebase is treated as a flag, not a formality. Run grep -rn "UncheckedAccount|AccountInfo|/// CHECK" programs/*/src and account for every hit.
- Authority and signer checks Every privileged instruction confirms the expected signer with Signer<'info>, not just a Pubkey comparison. Comparing a stored public key to a passed-in account proves someone knew a public key — public keys are public — it does not prove the holder of the matching private key approved the transaction. Every account named authority, owner, admin, or creator is either typed Signer<'info> directly, or bound to one via has_one against an account that is itself a Signer. PDAs that gate access are re-derived and checked with seeds = [...] , bump = state.bump, not just accepted as whatever address the caller supplied. A wrong signer or a look-alike decoy account should fail here with ConstraintSeeds.
- Arithmetic safety Every balance or supply change uses checked_add / checked_sub / checked_mul, never raw +, -, *, on any value influenced by a caller. No silent truncating cast (e.g. u64 to u32) sits anywhere in a balance or supply path. The property actually holds under generated input, not just the examples you thought of by hand. I pulled the arithmetic into a pure function and ran a proptest property against it — "a deposit either grows the balance or refuses honestly, never a silent wraparound" — over hundreds of generated u64 pairs, not just the numbers I happened to type. Rounding and precision bugs are real and expensive even without a headline heist: Neodyme's 2021 disclosure on the SPL token-lending program put roughly $2.6B of deposited value at risk from a rounding-direction bug, and Certora's audit of Kamino caught a fixed-point precision issue that could have let a user redeem more collateral than they deposited. Both were caught by exactly this kind of testing before anyone lost money. overflow-checks = true is set under [profile.release] in the workspace-root Cargo.toml (Anchor sets this by default on anchor init, but verify it if you assembled the workspace by hand).
- Cross-program invocation (CPI) safety Every CPI verifies the target program ID is the one you actually intend, not whatever account the caller happened to supply. Any account whose data is read again after a CPI is reloaded first, since a CPI can mutate account state your local copy no longer reflects. Token accounts passed into an instruction are constrained with token::mint = ... and token::authority = ... rather than trusted at face value — this is the exact substitution class the Sealevel attacks catalog documents, and it's the same shape of bug that let Cashio mint roughly $52M in fake tokens against unvalidated collateral.
- Account lifecycle Closed accounts are emptied and marked so they can't be revived or reused within the same transaction. No instruction allows reinitializing an already-initialized account. init_if_needed is used deliberately, not defensively — know exactly which instructions are allowed to create state and which should fail if the account doesn't already exist.
- Testing and pre-deploy hygiene The test suite includes adversarial tests, not just happy-path tests. For every constraint you rely on, there's a test that builds the exact malicious transaction that constraint is supposed to reject, and asserts the specific error code — not just "the transaction failed." A transaction can fail for boring reasons unrelated to your security logic, and a test that only checks for failure can hide a broken defense behind an unrelated error. At least one property-based test or fuzz run has been pointed at the program's core invariants (balances never go negative, a close always returns exactly its rent, two operations in sequence equal the combined operation done once). Tools like Trident drive full instruction sequences against live account state and will hand you the exact failing input if an invariant breaks. Dependencies are current and free of known advisories. The exploit you're most worried about has actually been reproduced against your own program, end to end, and the fix has been confirmed to turn a passing (exploited) test into a failing (rejected) one. Don't just read about Wormhole and Cashio — rebuild the smallest honest version of the bug, watch it succeed, apply the owner-check fix, and watch the same transaction get rejected. It's a different kind of confidence than reading a post-mortem. What Anchor gives you for free — and what it doesn't
Worth stating plainly, because it's the single most common source of false confidence: typing Account<'info, T> and Signer<'info> automatically gets you an owner check, a discriminator check, and a signer check. That's real and it's most of the mechanical work. What Anchor cannot do is reason about your business logic — it doesn't know if the right authority is attached to the right vault unless you write the has_one, it doesn't know if a CPI target is the program you meant, and it has no opinion on whether your arithmetic is safe. The moment a program reaches for UncheckedAccount to make something compile, all of that goes away and the developer has personally taken on the promise Anchor would otherwise have kept.
A closing note
This list is not exhaustive — it's a representative core built from the bugs in front of me, not every bug that exists. If you run it against your own program and find a gap it doesn't cover, that's the system working as intended: a security checklist is a living document, and the next person who reads it should leave it better than they found it. Corrections and additions welcome.
Top comments (0)