# Audit report

> Scope: src/ (README has the map). Known issues listed in the README are already found, skip them. Priorities: (1) funds held by BondingCurve and graduation through PoolMigrator — any path that takes more quote than owed or strands trader funds; (2) LaunchHook fee math both sides and exact-in/out, afterSwapReturnDelta, reentrancy via modules/observers, anything that bricks swaps; (3) owner, creator, module and fee-recipient powers over existing launches and holders; (4) TaxSet/modules gas floors, griefing, RedistributeFeature payouts, FlowBurnFeature accounting; (5) FeeVault cuts and syncFor attribution; (6) storage layout and initializers of the upgradeable contracts.

| | |
|---|---|
| Repository | https://github.com/ch3rryhq/launchpad-audit.git |
| Commit | `47cd6638f5ca8e567e2bab87ce250c0ada00ecf0` |
| Job | `5a705d5e-00a8-463d-afbb-7dada1a075ba` |
| Judged | 2026-10-16 12:27 UTC |
| Findings | 2 medium · 4 low · 1 info |

Four agents audited the code as it is at `47cd663`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: LaunchHook.setModule (and setTradeFees) accept any allowed initializer, not the pool's opener, so a module can be attached to a live module-less pool

`src/launchpad/LaunchHook.sol:347`

```
        if (!isInitializer[msg.sender]) revert NotAnInitializer(msg.sender);
```

setModule checks only that msg.sender is some allowed initializer and that moduleOf[ctx.poolId] is unset; ctx is wholly caller-supplied and openerOf[ctx.poolId] (recorded in beforeInitialize) is never compared with msg.sender, nor is the pool's live/seeded state checked. setInitializer's own NatSpec says several migrators (one per LP fee/tick spacing, D16) share the hook, so a second initializer is the expected configuration. Any such initializer (or one the owner registers) can attach a module to an existing, already-traded pool that launched without one; that module then runs inside every swap and can take up to MAX_MODULE_FEE_BPS (99%) of both legs or reject every sell. This contradicts the README rule 'Permanent: every token, every pool ... New behaviour reaches new launches only' and the function's own comment ('One shot ... a module chosen later would reach a pool people are already trading'). It is distinct from known issue #1 (adjustCreatorFees, bounded by the launch's creator-fee cap): different function, impact bounded only by the 99% module cap and can freeze exits. setTradeFees (line 366) has the same missing opener check, reachable for any pool whose tradeFees were never set. Fix: require openerOf[ctx.poolId] == msg.sender in setModule (and openerOf[id] == msg.sender in setTradeFees), and refuse once the pool is open/seeded.

**Reproduction**

Reproduced with the attached test (merged from audit_flow and audit_math, which reported the same defect): real PoolManager, LaunchHook, PoolMigrator, LaunchFactory; creator launches a plain token (no module), so openerOf[id] == migrator and moduleOf[id] == 0. Owner calls hook.setInitializer(secondInitializer, true). secondInitializer calls hook.setModule(ctx{poolId: id, token, quote,...}, greedyModule, ""). Expected: revert (not the opener; pool already live). Actual: succeeds, moduleOf[id] == greedyModule. forge test --match-path test/scratch/Proof_c32a0ab20fd4.t.sol -> [FAIL: next call did not revert as expected]. With an openerOf check the call reverts and the test passes.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";

import {PoolManager} from "v4-core/src/PoolManager.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {Hooks} from "v4-core/src/libraries/Hooks.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId} from "v4-core/src/types/PoolId.sol";
import {HookMiner} from "v4-periphery/test/shared/HookMiner.sol";

import {LaunchHook} from "src/launchpad/LaunchHook.sol";
import {PoolMigrator} from "src/launchpad/PoolMigrator.sol";
import {LaunchFactory} from "src/launchpad/LaunchFactory.sol";
import {BondingCurve} from "src/launchpad/BondingCurve.sol";
import {FeeVault} from "src/fees/FeeVault.sol";
import {IFeeSink} from "src/interfaces/IFeeSink.sol";
import {IMigrator} from "src/interfaces/IMigrator.sol";
import {ILaunchModule} from "src/interfaces/ILaunchModule.sol";

contract QuoteToken is ERC20 {
    constructor() ERC20("Quote", "Q") {}

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

/// A module that accepts any attach. What it would do inside swaps is irrelevant here: the defect is
/// that it can be attached at all to a pool whose launch never chose it.
contract GreedyModule {
    function onAttach(ILaunchModule.LaunchContext calldata, bytes calldata) external {}

    function usesPoolOps(PoolId) external pure returns (bool) {
        return false;
    }
}

/// `LaunchHook.setModule` checks `isInitializer[msg.sender]`, not `openerOf[ctx.poolId] == msg.sender`.
/// So any address the owner registers as an initializer can attach a module to a pool it did not
/// open: an existing launch with no module gets one after people already hold it, against the
/// "new behaviour reaches new launches only" rule. The test opens a launch's pool through the real
/// migrator, registers a second initializer, and expects that second initializer to be refused.
contract HookSetModuleByStrangerTest is Test {
    PoolManager internal manager;
    LaunchHook internal hook;
    PoolMigrator internal migrator;
    LaunchFactory internal factory;
    FeeVault internal vault;
    QuoteToken internal quote;

    address internal owner = makeAddr("owner");
    address internal creator = makeAddr("creator");
    address internal secondInitializer = makeAddr("secondInitializer");

    uint160 internal constant FLAGS = uint160(
        Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_ADD_LIQUIDITY_FLAG | Hooks.AFTER_ADD_LIQUIDITY_FLAG
            | Hooks.BEFORE_REMOVE_LIQUIDITY_FLAG | Hooks.AFTER_REMOVE_LIQUIDITY_FLAG | Hooks.BEFORE_SWAP_FLAG
            | Hooks.AFTER_SWAP_FLAG | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG
            | Hooks.AFTER_ADD_LIQUIDITY_RETURNS_DELTA_FLAG | Hooks.AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA_FLAG
    );

    function setUp() public {
        vm.warp(1_700_000_000);
        manager = new PoolManager(address(this));
        quote = new QuoteToken();
        vault = new FeeVault(owner);

        (, bytes32 salt) = HookMiner.find(
            address(this), FLAGS, type(LaunchHook).creationCode, abi.encode(IPoolManager(address(manager)), owner)
        );
        hook = new LaunchHook{salt: salt}(IPoolManager(address(manager)), owner);

        migrator =
            new PoolMigrator(IPoolManager(address(manager)), IFeeSink(address(vault)), IHooks(address(hook)), 10_000, 200);

        UpgradeableBeacon beacon = new UpgradeableBeacon(address(new BondingCurve()), owner);
        factory = LaunchFactory(
            address(
                new ERC1967Proxy(
                    address(new LaunchFactory()),
                    abi.encodeCall(
                        LaunchFactory.initialize,
                        (
                            owner,
                            address(beacon),
                            LaunchFactory.BaseConfig({
                                feeSink: IFeeSink(address(vault)),
                                observer: address(0),
                                totalSupply: 1_000_000_000e18,
                                curveBps: 6_000
                            })
                        )
                    )
                )
            )
        );
        migrator.bindFactory(address(factory));

        vm.startPrank(owner);
        hook.setInitializer(address(migrator), true);
        factory.setQuote(
            address(quote),
            LaunchFactory.QuoteConfig({enabled: true, migrator: IMigrator(address(migrator)), threshold: 69_000e18})
        );
        factory.setLaunchingOpen(true);
        vm.stopPrank();
    }

    function test_anInitializerThatDidNotOpenThePoolCannotAttachAModuleToIt() public {
        // A plain launch: no module, a pool opened by `migrator`.
        vm.prank(creator);
        (address token,) = factory.launch("Plain", "PLN", address(quote));
        PoolKey memory key = migrator.poolKeyFor(token, address(quote));
        PoolId id = key.toId();
        assertEq(hook.openerOf(id), address(migrator), "pool opened by the migrator");
        assertEq(address(hook.moduleOf(id)), address(0), "launched without a module");

        // Later, the owner registers another initializer (a second migrator, say).
        vm.prank(owner);
        hook.setInitializer(secondInitializer, true);

        GreedyModule greedy = new GreedyModule();
        ILaunchModule.LaunchContext memory ctx = ILaunchModule.LaunchContext({
            poolId: id,
            token: token,
            quote: address(quote),
            curve: address(0),
            migrator: secondInitializer,
            poolManager: address(manager),
            quoteIsCurrency0: false
        });

        // Expected: refused, because `secondInitializer` is not `openerOf[id]`.
        // Actual on this code: accepted, and the live pool now runs `greedy` inside every swap.
        vm.prank(secondInitializer);
        vm.expectRevert();
        hook.setModule(ctx, address(greedy), "");

        assertEq(address(hook.moduleOf(id)), address(0), "a stranger's module must not reach a live pool");
    }
}
```

### 2. Medium: EvolvableTaxSet re-adding a removed RedistributeFeature revives stale per-wallet holdings: ex-holders are paid out of current holders' share

`src/fees/EvolvableTaxSet.sol:117`

```
            uint256 slot = _slotOf(fs, module);
```

setFeatures switches a feature off by zeroing its shares and Needs (lines 84-88), so TaxSet._onTransfer (TaxSet.sol:420, `if (ns[i].transfer == 0) continue;`) stops forwarding transfers to it. RedistributeFeature caches each wallet's holding in accountOf[token][wallet].held, updated only in _move on forwarded transfers. While the feature is off the caches freeze while real balances move. When the controller re-adds it, _place finds the old slot (line 117) and only restores shares and gas needs; onAttach is not re-run and nothing resynchronises the caches with LaunchToken.holderOf. _settle (RedistributeFeature.sol:265, `full = a.held * (perHeld - a.paid) / PER_HELD_SCALE`) then pays wallets on their stale held, while streamOf.total is refreshed from the token's live totalHeld on the next transfer. A wallet that sold out while the feature was off keeps earning as a full holder, taking current holders' share; because the sum of stale held exceeds total, payouts can exceed the feature's entitlement and the last claimants hit OverEntitlement (claim swallows it and keeps them owed). Conversely a wallet that bought during the gap earns nothing until it moves again. Merged from audit_permissions (medium) and audit_math (low), same root cause. Fix: when a slot is re-enabled, treat it as a fresh attach (or forbid re-adding features that keep per-transfer ledgers), or have RedistributeFeature re-read LaunchToken.holderOf(wallet).held in claim/deliver before settling.

**Reproduction**

test/scratch/RedistReAdd.t.sol (extends the repo's test/helpers/ModuleHarness): EvolvableTaxSet launch, Tax{buyBps 1000, sellBps 1000}, features [RedistributeFeature 10000/10000], controller creatorWallet. alice buys 1,000e18 ARB on the curve (held cached = 11,634,097.74e18). creatorWallet calls setFeatures(token, [], []). alice sells her whole balance (token.balanceOf(alice) == 0, cached held still 11,634,097.74e18). creatorWallet re-adds [Redistribute 10000/10000]. bob buys 1,000e18; entitlementOf(red) == 200e18. warp +2h. alice.claim -> paid 100e18; bob.claim -> 99.99e18. Expected: alice 0 (she holds nothing). Actual: [FAIL: ex-holder paid on a stale cached holding: 100000000000000000000 != 0].

### 3. Low: FeeVault.syncFor credits every unsynced arrival (bare transfers, LaunchHook.sweep payouts, swallowed syncs) to whichever launch reports next

`src/fees/FeeVault.sol:151`

```
        receivedFrom[launch][token] += received;
```

syncFor attributes the whole delta measured by sync() (balance - lastBalance) to the reporting launch, not the amount that launch's trade paid. The NatSpec on receivedFrom (lines 66-69) and syncFor (143-145) promise that a sweep, bare transfer or deferred fee flushed later 'stays unattributed' and that nobody can 'inflate their own'. Real sources of unsynced arrivals: LaunchHook.sweep(id, quote, feeVault) of a deferred protocol fee (plain safeTransfer, no syncFor), hook/curve syncFor calls that ran out of their gas cap and were swallowed, and direct donations. receivedFrom is the D61 per-launch ledger; nothing in src/ pays out from it yet, so today the impact is a wrong (and trivially inflatable) on-chain figure, but any creator split or rebate built on it would pay launch B for launch A's fees. Merged from three specialist reports (audit_economics, audit_permissions, audit_flow) and the matching audit_math entry. Fix: have the curve/hook pass the amount it just pushed and credit min(amount, received), leaving the remainder unattributed (optionally tracked in an explicit bucket).

**Reproduction**

test/scratch/FeeVaultAttribution.t.sol: FeeVault with setReporters(registry mapping launchB->curveB, hook). Mint 1,000e18 quote straight to the vault (stand-in for LaunchHook.sweep of launch A's deferred fee), then 10e18 (B's fee) and call syncFor(quote, launchB) from curveB. Expected receivedFrom(launchB, quote) == 10e18; actual 1,010e18 -> [FAIL: B credited with the stranger's 1,000: 1010000000000000000000 != 10000000000000000000].

### 4. Low: Exact-output pool buys charge protocol and creator fees on the pool's net quote input, not on what the trader pays (buy-side counterpart of known issue #3)

`src/launchpad/LaunchHook.sol:866`

```
        protocolFee = amount * PROTOCOL_FEE_BPS / BPS;
```

On an exact-output buy the quote is the unspecified leg, so afterSwap computes the fees on `amount` = the quote the pool itself took (_unspecified), and adds them on top via the after-swap delta: the trader pays amount*(1+r), an effective rate of r/(1+r) on their gross. An exact-input buy of the same gross is charged r of the gross in beforeSwap. README known issue #3 names exact-output sells only; the buy side is not listed and needs the same fix. Impact: protocol and creator under-collect by a factor 1/(1+r) on every exact-output buy (about 9.3% of the creator fee at a 10% rate), and traders can pick the cheaper exactness. Fix: when the quote is the unspecified leg of an exact-output buy, charge ceil(amount * r / (BPS - r_total)) so the fee is r of the trader's gross, consistently with exact-input.

**Reproduction**

test/scratch/ExactOutBuy.t.sol (on the repo's ModuleHarness): launch with CreatorFees(wallet, 1000, 1000, fixed), graduate, then on the pool an exact-output buy of 1,000,000e18 tokens: trader pays 192.5837e18 ARB, creator receives 17.4679e18. An exact-input buy of the same 192.5837e18 pays the creator 19.2584e18. Expected 10% of gross (19.258e18) in both; actual exact-output 9.07% -> [FAIL: creator rate on what the trader paid: 17467912204747158527 !~= 19258373205733742276 (real delta 9.297%)].

### 5. Low: De-registering a migrator as initializer freezes every adjustCreatorFees (including lowerings) on all of its launches, curve phase included

`src/launchpad/LaunchHook.sol:392`

```
        if (!isInitializer[migrator]) revert NotAnInitializer(migrator);
```

LaunchHook.adjustCreatorFees checks the live owner-controlled allow-list isInitializer[migrator] instead of the permanent record openerOf[id]. BondingCurve.adjustCreatorFees (BondingCurve.sol:730) calls the hook without a try, even before graduation, so once the owner calls setInitializer(M, false) (the natural step when retiring a migrator, D16) every launch quoted through M can no longer move its creator rates, including the immediate lowering the README promises for lower-only and adjustable modes. Swaps are unaffected. Fix: authorise against openerOf[id] (the opener that recorded the pool) rather than isInitializer, which also removes the caller-supplied migrator argument behind known issue #1.

**Reproduction**

test/scratch/MiscRepro.t.sol::test_retiringAMigratorFreezesCreatorFeeLowering (ModuleHarness): factory.launch with CreatorFees(wallet, 500, 500, adjustable); owner calls hook.setInitializer(migrator, false); wallet calls curve.adjustCreatorFees(0, 0). Expected: rates drop to 0. Actual: [FAIL: NotAnInitializer(migrator)] and rates stay at 500.

### 6. Low: Trust assumption not in the README: StakingVault is owner-upgradeable (UUPS, no delay) while custodying every launch's staked tokens

`src/launchpad/StakingVault.sol:109`

```
    function _authorizeUpgrade(address) internal override onlyOwner {}
```

The README's trust model lists the upgradeable contracts as 'the factory, the router, and the curve logic' and the owner's powers as factory, curve beacon, module allow-lists, quotes and fee vault. StakingVault is also a UUPS proxy upgradeable by the owner alone, with no timelock, and it holds the staked balance of every holder of every launch (one vault keyed by token); its header promises 'a stake can only ever leave to the wallet that made it'. A single upgradeToAndCall can move all stakes or rewrite stakeOf (which FeatureGovernor votes rely on). This is a privileged power, not a permission bypass, reported because it is outside the stated trust model. Fix options keeping the design: make the vault non-upgradeable, or put upgrades behind a timelock longer than typical locks, and document it in the trust model. Specialist rated medium; downgraded since the owner role is intentional and only the disclosure is missing.

**Reproduction**

test/scratch/MiscRepro.t.sol::test_ownerUpgradeTakesStakes: StakingVault behind ERC1967Proxy with owner; alice stakes her 12,917,498.5e18 tokens; owner calls vault.upgradeToAndCall(Drain, sweep(token, owner)). Expected (per header): stake can only leave to alice. Actual: token.balanceOf(vault) == 0 -> [FAIL: stake moved by the owner: 0 != 12917498516160362596449576].

### 7. Info: Trust assumption: an unregistered creator-chosen module can refuse every sell and transfer for the life of a launch (honeypot)

`src/launchpad/LaunchFactory.sol:331`

```
        if (isKnownModule[module] && !moduleEnabled[module]) revert ModuleNotEnabled(module);
```

The factory refuses only a registered-then-disabled module; any address never registered is accepted (D26). The module is fixed for life and is also the token's observer. BondingCurve._askModule reverts on reject (BondingCurve.sol:888), LaunchHook reverts on reject in before/afterSwap (LaunchHook.sol:588, 609), and LaunchToken reverts the transfer when the observer answers reject (LaunchToken.sol:164); a cheap refusal is not a gas-floor failure, so it is not swallowed. A creator module that rejects only sells and non-curve transfers lets everyone buy and nobody exit, before and after graduation. This is documented design, reported as the largest module power over holders (README priority 3). Mitigations that keep the design: surface isKnownModule / the ModuleAttached `ours` flag to integrators prominently, or have the router require opt-in for unknown modules.

**Reproduction**

Code trace: launch(name, symbol, ARB, fees, M, "") with M unregistered passes line 331; M.onCurveTrade returning Action{reject: !isBuy} makes curve.sell revert ModuleRejected at BondingCurve.sol:888 while buys succeed; after graduation beforeSwap returning reject reverts LaunchHook.ModuleRejected (line 588); afterTokenTransfer returning true reverts LaunchToken.TransferRejected (line 164). Not run as a test (design behaviour).

---

Judge's submission `e548e20d0a1ef0d97d0290d64eb49fce2c53e2b07584fc0d8e6c76fd755e70bf`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
