How to Self-Audit Your Launchpad Protocol
A professional audit is the final check before mainnet. A good auditor will find bugs you cannot find yourself. But auditors are expensive and thorough audits take weeks. If you hand over a codebase full of easy bugs, the auditor spends time on things you could have caught yourself. That time costs money and it reduces the depth the auditor can go on the hard stuff.
A self-audit is preparation, not a replacement. You run it before the external review. You find the obvious bugs so the auditor finds the subtle ones. You timebox it to three days. The goal is not a clean report. The goal is a codebase that lets the auditor do their actual job.
This article is a practical guide to self-auditing a Solana launchpad protocol. It uses the Chaotic Markets codebase as a case study. Every vulnerability described here was found in real code. Every test shown is a real Rust file from the project's audit directory. The methodology is general, but the examples are specific to Solana and Anchor.
Why Self-Audit
The only reason to self-audit is to understand your own code before someone else does. You should know where every attack sits before you hand the codebase to an external auditor. You should know which accounts are unverified, which invariants hold the system together, and what a successful exploit looks like. After three days of trying to break your own protocol, you write better code. The auditor finds the bugs you could not find. That is how the split is supposed to work.
The Setup
A self-audit needs three things: a scope boundary, a test harness, and a methodology.
Scope boundary. Audit the code that can lose money. That means:
- Every instruction handler that moves tokens or SOL
- The state struct that stores treasury balances and protocol parameters
- The math module that computes prices, shock amounts, and allocations
- The off-chain client code that builds transactions and passes accounts to the program
You do not audit the frontend components. You do not audit the CSS. You audit the code that can lose money.
Test harness. Create a standalone Rust crate inside your project. It imports your program as a dependency but lives outside it. This gives you access to all the types, math functions, and constants without fighting the Solana runtime. The crate structure looks like this:
audit/
├── Cargo.toml # Standalone crate, depends on your program
├── REPORT.md # The audit report (write as you go)
└── src/
├── lib.rs # Invariant tests
└── exploits/
├── mod.rs
├── fake_pool_drain.rs # Account validation exploits
├── sandwich_tick.rs # Slippage exploits
├── fake_pool_create.rs # Pool state verification
└── keeper_vault_bug.rs # Off-chain client bugs
The key decision is making it a standalone crate. You do not need a validator. You do not need to deploy anything. You need the types, the math, and the PDA derivation logic. Everything else is unit-testable in pure Rust. The tests run in under a second. You iterate fast.
Methodology. Three passes. Pass one is code review. Sit with the code and ask one question for every line: what happens if the caller passes a malicious value here? Pass two is exploit writing. For every suspicion from pass one, write a test that proves the exploit works. Pass three is invariant testing. Write tests for the economic properties that must hold regardless of user behavior.
Pass One: The Code Review
Start with the accounts. Every UncheckedAccount is a question. Every missing address constraint is a finding until proven otherwise.
The Account Validation Pattern
In Anchor, you validate accounts with constraints. address = some_pubkey pins an account to an expected value. seeds = [...] derives the expected PDA. has_one = field checks that the account's stored value matches a field on another account. A bare UncheckedAccount with a /// CHECK: comment is the developer saying "trust me."
When you see this pattern, do not trust it. Verify it.
Here is the pool_state account from the original Tick instruction:
/// CHECK: Raydium pool state.
#[account(mut)]
pub pool_state: UncheckedAccount<'info>,
This account is passed directly to swap_on_raydium, which uses it as the target pool for the protocol's swap operations. Nothing stops the caller from passing their own pool. If the attacker creates a Raydium pool with extreme imbalance (1 token = 10,000 SOL), the protocol swaps into it and receives near-zero output. The attacker pockets the difference.
This became C01: Unverified pool_state enables fake-pool drain. It is the most expensive bug in the codebase. The fix is one line:
#[account(mut, address = chaotic_token.raydium_pool)]
pub pool_state: UncheckedAccount<'info>,
The chaotic_token.raydium_pool field was stored at creation time from the canonical PDA derivation. The data was already there. The constraint was missing.
Lesson: Every UncheckedAccount that touches value transfer must be pinned to an expected address. If the expected address exists on-chain (in a PDA field), use address =. If it does not exist on-chain, derive it in the handler and require! equality.
The Slippage Pattern
Every swap instruction needs a minimum output. If minimum_amount_out = 0, the protocol accepts any execution price. This is not a theoretical problem. MEV searchers run bots that watch the mempool for zero-slippage swaps and sandwich them.
Here is the original swap data construction in Chaotic Markets:
data.extend_from_slice(&0u64.to_le_bytes()); // minimum_amount_out = 0
Both the mint path (token to SOL) and burn path (SOL to token) used zero slippage. The protocol's treasury was systematically bleedable. A sandwich attacker front-runs the tick with a token sell that drops the price, lets the tick execute at the depressed price, then back-runs with a buy that restores the price. The attacker pockets the spread. The treasury loses 1-3% of shock_amount per tick.
A test in the sandwich exploit module models this. The attacker nets 221 million tokens per tick. After 100 ticks at 1.5% leakage, more than 75% of treasury value is extracted.
Lesson: Compute minimum_amount_out from pool reserves using the constant-product formula. Apply a slippage tolerance (95% for a 5% cap). Pass it in the CPI data. Never ship a swap with zero minimum output.
The init_if_needed Anti-Pattern
init_if_needed on an ATA is a known footgun. The Anchor team themselves discourage it. It was the vector for the Nirvana Finance hack. The specific risk is that an attacker front-runs the transaction, initializes the same ATA with lamports but not the token program, and the init_if_needed skips initialization because the account already exists. But the account is not a valid TokenAccount. Subsequent CPIs fail or behave unexpectedly.
#[account(
init_if_needed,
payer = creator,
associated_token::mint = mint,
associated_token::authority = creator,
)]
pub creator_ata: Account<'info, TokenAccount>,
The fix is to use init instead. The client already creates the ATA in a pre-instruction. init_if_needed was defensive code that introduced a vector.
Lesson: Never use init_if_needed in a financial program. Use init and have the client create the account beforehand. The one exception is when you control both the program and the account creation in the same transaction. Even then, prefer init.
The Economic Invariant Review
Beyond account validation, you read the math. For a launchpad, the math is the product. The invariants you check depend on what your protocol actually does. Here is what that looked like for Chaotic Markets.
Chaotic Markets is a token launchpad where token supply is driven by the logistic map, a chaotic dynamical system. Anyone can call tick() once per hour to advance the map one step. If the state variable x exceeds 0.8, the program mints 1% of treasury tokens and swaps them into the Raydium pool. If x drops below 0.2, the program swaps SOL from treasury to buy and burn tokens. The treasury is a self-funding chaotic central bank. No human controls it.
For that specific mechanism, these are the invariants we checked:
-
Treasury allocation is exact. Does the treasury always receive 10% of initial supply? Check for integer division rounding that could zero out small allocations.
-
Supply shocks are bounded. Is the mint amount capped at 1% of treasury? Does the cap actually work when treasury is near zero?
-
Burns never exceed the treasury. Can a burn instruction withdraw more tokens than the treasury holds? Does the
min()call protect against this? -
The logistic map is deterministic. Given the same r-value and the same x, does the function always produce the same next x? This sounds obvious. It is not when fixed-point math is involved.
-
Tick cooldown is enforced. Can the same caller tick twice in the same block? Can two callers tick in rapid succession? The cooldown is per-token, not per-caller. Verify the timestamp comparison uses the right clock.
-
Supply does not spiral. The map at high r-values visits the mint zone frequently. If mints compound without a cap, supply inflates exponentially. This may or may not be acceptable, but you need to know the number.
The rounding check (M04) caught an edge case: for small total_supply values, total_supply * 1000 / 10000 truncates to zero. The treasury gets nothing. The fix is an assertion: require!(treasury_amount > 0, ChaosError::TreasuryTooSmall).
The supply inflation check (M03) caught a compounding problem. At r values near 4.0, the map exceeds 0.8 about 20% of the time. At 24 ticks per day, that is roughly 5 mint events daily. After one year of 1% compound mints, total supply grows by a factor of 77 million. The protocol needs a supply cap.
Every launchpad has its own set of invariants. A standard bonding curve checks that buy and sell prices are symmetric around the midpoint. A graduated pool checks that LP tokens are burned or locked, not held by the creator. Your invariants come from your mechanism. The point is to write them down and test them. Most developers skip this step. It is where design bugs become visible before they become exploits.
Pass Two: Exploit Modules
After the code review, you write exploit tests. These are not unit tests. They are miniature attack simulations. Each test sets up the preconditions, executes the exploit, and asserts that an unacceptable outcome occurred.
Structuring an Exploit Module
Each module targets one class of vulnerability. The fake pool drain module has nine tests. It starts with the two core exploits (mint path and burn path), then adds three invariant tests for the logistic map math, then two regression tests for edge cases that the fix should not break.
Here is the structure of a single exploit test:
#[test]
fn exploit_c01_fake_pool_drain_mint_path() {
// 1. Setup: create a token with a known state
// 2. Create an attacker's pool with extreme imbalance
// 3. Simulate a tick where x > MINT_THRESHOLD
// 4. Execute swap through attacker's pool
// 5. Assert: treasury receives near-zero SOL
// 6. Assert: attacker's pool captured the minted tokens
}
The test does not deploy to a validator. It creates the types in memory, calls the math functions, simulates the swap outcomes using the constant-product formula, and checks the results. The entire module runs in under 100 milliseconds.
What Makes a Good Exploit Test
A good exploit test does three things.
First, it proves the vulnerability exists. The assertion must be specific. "Treasury loses money" is not an assertion. "After swapping 1,000 tokens through the attacker's pool, treasury receives 0.000001 SOL instead of the expected 0.01 SOL from the fair pool" is.
Second, it computes the expected outcome from the fair pool as a baseline. The exploit test runs two scenarios: one through the canonical pool, one through the attacker's pool. The delta is the exploit profit. If the delta is zero, you have not found a vulnerability. You have found a rounding error.
Third, it includes a test for the fix. The same scenario, but with the constraint applied. The exploit should fail because the program now rejects the attacker's pool. This prevents regressions when someone removes the fix six months later.
Running the Numbers
The sandwich test module computes actual profitability. Here is the math:
Attacker front-runs tick: sells tokens → pool price drops 2%
Tick executes at manipulated price: treasury gets 2% less SOL
Attacker back-runs: buys tokens back at lower price → nets tokens
Per tick: attacker profit = shock_amount * slippage_percent
Cumulative after 100 ticks: (1 - slippage_percent)^100 of treasury extracted
At 1.5% leakage per tick, 100 ticks extract 78% of treasury value. This is not a rounding error. This is a systematic drain.
Pass Three: Invariant Tests
Invariant tests are different from exploit tests. An exploit test proves that a specific attack works. An invariant test proves that a property holds for all inputs within a range. You do not need exhaustive coverage. You need coverage of the boundary conditions and the high-probability regime.
The Invariants
For a launchpad with a chaotic price function, these are the invariants:
| Invariant | Test | Why it matters |
|---|---|---|
| Logistic map stays in [0, 1] | Loop 10,000 iterations at r=4.0, assert 0 ≤ x ≤ 1 | The fixed-point math must not overflow or escape the domain |
| Logistic map is deterministic | Run 100 iterations from the same seed, assert all equal | The PDA state must be reproducible from on-chain data |
| Chaos divergence at high r | Two seeds differing by 1e-9, run 50 iterations, assert divergence | Verifies the chaotic property is real, not an artifact |
| Treasury always gets 10% | For amounts 1 lamport to 1000 SOL, assert exact allocation | Integer division must not zero out small amounts |
| Shock is exactly 1% | For treasury balances 100 to 1,000,000,000, assert exact 1% | Rounding must not accumulate over time |
| Burn never exceeds treasury | For all burn paths, assert burn_amount ≤ treasury_balance | The min() guard must actually work |
| Tick cooldown enforced | Simulate two ticks within 3600s, assert second fails | Timestamp comparison must use the right clock |
| Supply inflation modeled | Over 1,825 ticks (1 year), model total supply growth | Informs whether a supply cap is needed |
These are not edge cases. They are the contract the protocol makes with its users. If any of them fail, the economics are broken.
Supply Inflation: An Invariant That Informed Design
The supply inflation test is worth examining because it is not a bug. It is a design property that becomes a problem at scale. The test runs this loop:
let mut supply = initial_supply;
for _ in 0..1825 { // 1 year of ticks
// 20% chance of mint event (x > 0.8 at high r)
if rng.gen_bool(0.2) {
let shock = supply * 100 / 10000; // 1% shock
supply += shock;
}
}
// Assert: supply grew by factor of ~77,000,000
The test does not fail. It reports a number. That number (77 million) tells you that a supply cap is not a nice-to-have. It is essential. The invariant test did not find a code bug. It found a design bug.
This is why you write invariant tests. They surface problems that code review misses because code review asks "does this do what the programmer intended?" and invariant tests ask "does this do what the economics require?"
Findings from the Chaotic Markets Audit
The self-audit found 11 issues. All four criticals are the same class of bug: the code trusts user-supplied accounts and data without verifying them on-chain.
Critical: Trusting Unverified Accounts (C01, C02, C03, C04)
C01 and C04 are unverified pool states. C02 is unverified vault accounts. C03 is zero slippage, which is the same pattern applied to swap output — the program trusts that the swap returns a fair price without setting a minimum or checking the post-swap balance. Four findings, one mistake: accepting whatever the caller hands you.
Three of the four let an attacker pass a manipulated pool and drain the treasury through bad swaps. The pool_state was unverified. The vault accounts were unverified. The pool state after CPI creation was unverified. Each one is a different entry point to the same attack.
What this teaches: Account validation is not boilerplate. It is the security boundary. On Solana, the program does not know which accounts are legitimate. It knows what the caller tells it. Every account that touches value must be pinned to an expected address. Every CPI that moves value must have a post-condition check. Trust nothing the caller provides. Verify everything on-chain.
High: Supply Chain and Economic (H01, H02, H03)
H01 (init_if_needed) is a known Anchor anti-pattern. H02 (no post-swap balance check) means the protocol could mint tokens without receiving SOL in return. H03 (vault ordering bug in the keeper) means the protocol's liveness mechanism silently fails for half of all tokens.
What H02 teaches: After every CPI that moves value, check the balance. Do not trust that the CPI succeeded just because it returned Ok(()). With zero slippage, a swap can return Ok(()) and give you zero output. The balance check catches this.
What H03 teaches: The off-chain code is part of the protocol. The keeper script is not a convenience. It is a liveness dependency. If the keeper fails, tokens stop ticking. If the keeper has a vault ordering bug, half the tokens on the platform are stuck. Audit the TypeScript the same way you audit the Rust.
Medium: Economics and Edge Cases (M01, M02, M03, M04)
M01 (no tick incentive) means the protocol depends on altruistic callers. M02 (symbol-based PDA seeds) means token symbols can be front-run. M03 (no supply cap) means unbounded inflation over time. M04 (integer division rounding) means small liquidity amounts lose their treasury allocation.
What M02 teaches: PDA seeds that include user-chosen strings are a front-running surface. If your launchpad lets creators pick a symbol that becomes part of the PDA seed, a front-runner can observe the pending transaction and claim the symbol first. Either add a salt to the seeds or document that symbols are first-come-first-served.
The Audit Infrastructure
The audit crate is the deliverable. It lives alongside the program, runs in CI, and grows with every new finding. Here is how to set it up.
Cargo.toml
[package]
name = "my-program-audit"
version = "0.1.0"
edition = "2021"
[dependencies]
my-program = { path = "../programs/my-program" }
That is it. The audit crate imports the program. Every type, constant, and math function is available. No Solana validator. No test harness. Pure Rust.
Running the Tests
cargo test --manifest-path audit/Cargo.toml
Twenty nine tests. All passing. Under one second. You run this before every commit. You add a test for every bug you find. The audit crate becomes the protocol's immune system.
When to Write the Report
Write the report during the audit, not after. Every time you find something, add it to the report immediately. The report is a running log of what you checked and what you found. By the end of the three days, the report is done. You do not spend a fourth day formatting.
What a Self-Audit Does Not Cover
A self-audit is not a substitute for a professional audit. It does not cover:
Economic game theory at scale. An invariant test can model 1,825 ticks. It cannot model strategic behavior by rational actors who know the mechanism and optimize against it. A professional auditor brings a team that has seen thousands of protocols and knows the patterns.
Novel attack classes. The exploit modules test for known vulnerability patterns: fake pools, sandwiches, reinitialization. They do not test for novel attacks specific to your mechanism. The logistic map has properties (periodic windows, intermittency, crisis points) that could be exploitable in ways a three-day self-audit will not find.
Formal verification. Invariant tests sample the input space. Formal verification proves properties for all inputs. A professional audit may include formal verification of the critical paths.
Race conditions and concurrency. Solana transactions are concurrent. Two ticks in the same block might interact in ways that sequential testing misses. A professional audit simulates block-level ordering and checks for interleaving bugs.
The self-audit is not the final check. It is the minimum bar for handing your code to someone else. If you cannot pass your own exploit tests, do not pay an auditor. Fix the bugs first.
The Five-Step Process
Here is the process condensed into five steps. Do this for any Solana program that moves money.
Step 1: Draw the account flow. On a piece of paper or a whiteboard, draw every account that enters your program. Draw arrows for where they go (to a CPI, to a state read, to a PDA derivation). Mark every account that is not verified by an Anchor constraint. Those are your primary targets.
Step 2: List the economic invariants. Write them down. Treasury always gets X%. Shock never exceeds Y%. Burns never exceed the treasury balance. The curve stays bounded. Cooldown is enforced. These are the promises your protocol makes. If one of them breaks, it is a finding.
Step 3: Write the exploit tests. For every unverified account from step 1, write a test that passes a malicious version and measures the damage. If the damage is nonzero, it is a finding. If the damage is zero because of some other guard, document that guard. It might be load-bearing in ways you did not realize.
Step 4: Write the invariant tests. For every invariant from step 2, write a test that checks it holds across a range of inputs. Focus on boundary conditions (zero, maximum, just above zero). Run the tests. If any fail, it is a finding.
Step 5: Write the report. One markdown file. Executive summary at the top. Findings organized by severity. Each finding has a title, the file and line numbers, the root cause, an exploit scenario, and the fix. Tests referenced by name. This is what you hand to the external auditor alongside the code.
The Payoff
The Chaotic Markets self-audit found four critical bugs, three high-severity issues, and four medium findings. The fixes for three of the four criticals were one-liners. The time investment was three days.
An external audit of the same scope would have cost twenty to forty thousand dollars and found the same bugs. But the auditor would have spent the first week writing exploit tests that should have existed already. They would have spent time on findings that a grep for minimum_amount_out would have surfaced in ten seconds.
By self-auditing first, you give the auditor a clean codebase and a written report of what you already checked. They start from your blind spots, not from zero. That is when an external audit is worth the money. Not when it finds the easy bugs. When it finds the bugs you could not have found yourself.
The audit crate lives alongside the program in the project repo. Same structure, same test layout, same methodology. The three days you spend on your own self-audit will tell you more about your protocol than any external review possibly can — because by the end of it, you will know exactly where the attacks are.