DEV Community

Mubarak Yakubu
Mubarak Yakubu

Posted on

My Solana Program Security Checklist

A pre-deploy checklist I run top to bottom before any Anchor program goes to mainnet. Built from the bugs I reproduced myself.

This checklist is for Anchor developers about to deploy to mainnet. Run it top to bottom before every deploy. It's built from the bugs I reproduced myself over the last few days: forged accounts, arithmetic overflows, and CPI trust failures. Each item is a yes-or-no check. If you can't answer yes, stop and fix it.

1. Account Validation

  • Every deserialized account has its owner verified. Typed Account<'info, T> does this automatically. Raw AccountInfo or UncheckedAccount does not.

  • Account types are distinguished by their 8-byte discriminator, so one account type cannot be passed where another is expected.

  • Any remaining_accounts passed in are validated before use.

  • Any PDA is verified with seeds and bump, not just accepted by address.

Why this matters: In February 2022, $326M left the Wormhole bridge because the program trusted a forged account without verifying it was the real one. The fix was to check the owner before reading the data.

2. Authority and Signer Checks

  • Every privileged instruction confirms the expected signer with Signer<'info> or has_one / constraint checks.

  • No instruction trusts a pubkey without also checking its signer flag. Comparing a public key only proves someone knew a public key. Public keys are public.

  • UncheckedAccount for an authority is a red flag. If you see it, you have no signer check.

  • Admin-only instructions check the admin against stored state AND require the signer.

  • The vault's owner is set on first deposit and never silently overwritten.

Why this matters: Comparing a public key without checking the signer flag lets an attacker pass any public key. The instruction must require a signature.

3. Arithmetic Safety

  • Every balance or supply change uses checked_add, checked_sub, or checked_mul, never raw +, -, or * on untrusted values.

  • No silent cast that could truncate (e.g., u64 to u32).

  • Overflow and underflow return a named error, not a panic.

  • Property tests prove that a deposit never shrinks a balance and a withdrawal never increases it.

Why this matters: A u64 that overflows wraps silently to a tiny number. Without checked math, a deposit of 2 lamports into a balance one lamport below u64::MAX wraps the balance back to 1. The attacker destroys the vault with a tiny deposit.

4. CPI Safety

  • Every CPI verifies the target program ID is the one expected, not whatever account the caller supplied.

  • Program<'info, T> is used where possible, so Anchor checks the program ID.

  • Accounts are reloaded after a CPI if their data is read again. The callee may have changed the account.

  • Signer seeds used for PDA-signed CPIs match the seeds in the #[account(seeds = ...)] constraint exactly. One byte off and the runtime rejects the PDA.

  • No CPI trusts the callee to validate its own accounts. The caller is responsible for passing the right ones.

Why this matters: A CPI that accepts whatever program ID the caller supplies can be redirected to an attacker's program. The attacker's program returns success and the caller believes the work was done.

5. Account Lifecycle

  • Closed accounts are emptied and marked so they cannot be revived or reused within the same transaction.

  • No instruction allows reinitializing an already-initialized account. init fails on existing accounts. init_if_needed succeeds silently. Use it deliberately.

  • Closing an account returns the rent to the correct wallet. Never to an arbitrary signer.

  • close = authority is used, not manual lamport draining.

Why this matters: An account that is closed but not marked can be reinitialized and reused. An account that is reinitialized loses its old state and can be repurposed by an attacker.

6. Pre-Deploy Hygiene

  • cargo audit has been run and reports no known advisories in dependencies.

  • Adversarial tests pass, not just the happy path. Every defense has a test that proves it works.

  • Property tests pass. Rules like "a deposit never shrinks a balance" hold for random inputs.

  • Fuzz tests pass. Trident has run the program through thousands of random instruction sequences without a panic.

  • anchor keys sync has been run. The program ID in lib.rs matches Anchor.toml.

  • The overflow-checks = true flag is set under [profile.release] in the workspace root Cargo.toml.

  • grep -rn "UncheckedAccount\|AccountInfo\|/// CHECK" programs/*/src returns only accounts you have manually verified.

Why this matters: The best defense is a test that proves it works. A constraint you cannot see fail is a constraint you are only hoping is there. Run the checklist before every deploy, not just the first one.

This checklist is a living document. Run it before every mainnet deploy, top to bottom. If you find something it misses, open an issue or reach out. Security is a community effort.

This post draws from Days 75-82 of #100DaysOfSolana.

Top comments (0)