# Audit — Launchpad (Phase 2)

Hostile self-review of `contracts/src/launchpad/` (`StocksBankLaunchFactory.sol`,
`StocksBankLaunchToken.sol`, `LaunchRouter.sol`, `libraries/LaunchMath.sol`,
`libraries/LiquidityAmounts.sol`), covering the Phase 1 surface plus the
Phase 1.5 additions (`launchFor`, `LaunchRouter`, native-AVAX dev buy).
56 tests, `contracts/test/launchpad/`, real Avalanche mainnet fork
(`FORK_BLOCK=95834900`), all passing — two of them fuzz tests
(`LaunchMathFuzz.t.sol`), run here at 10,000 iterations each.

Scope explicitly requested: extraction de LP, vol de fees, quote malveillant
ajouté au registre, réentrance via le wrapper ERC-4626, token0/token1
inversé, arrondis, griefing de création, owner malveillant, quote ERC-4626
gelé.

## Findings

### F-1 (Medium, fixed) — tick rounding could make graduation permanently unreachable

`LaunchMath.deriveCurve` originally rounded each boundary tick to the
*nearest* spacing multiple independently. The primary position's full-drain
quote capacity depends only on the *product* of the two boundary
`sqrtPriceX96` values, not their individual rounding direction — rounding
each one to whichever side was numerically closer could land the product
**below** the target `graduationThreshold`. Measured on the real WAVAX
config: capacity ≈ 697.9 AVAX against a 700 AVAX target. Once the primary
position is fully drained, `quoteInPrimary` is capped at that fixed value
forever — no further buying can ever push it past a threshold the position
was never sized to reach. `isGraduated`/`checkGraduation` would then be
permanently `false` for that token.

Caught by the Phase 1.5 fork test suite itself (`test_accumulate_*`),
before this audit even started — see the `launchpad: port Kaiten
factory/token...` commit.

**Fix**: round both boundary ticks in the direction that can only grow the
product (ceil both for `tokenIsToken0`, floor both otherwise) — see
`LaunchMath.sol`'s derivation comment for the algebra. Guarantees capacity
≥ threshold by construction, not by approximation.

**Tests**: `LaunchMathFuzz.t.sol` — `testFuzz_deriveCurve_capacityAtLeastThreshold_noOverlap`
and `testFuzz_deriveCurve_minimalGap` (10,000 runs each, extreme
`startFdv`/`graduationThreshold` ratios from barely-above-1 to ~1e28,
absolute values from 1e6 to 1e34 wei), plus a direct regression pinning the
original bug report's numbers (`test_regression_wavaxConfig_capacityMeetsThreshold`).
Also confirms the fix doesn't overshoot into a new problem: capacity is
bounded above (≤ threshold × 1.1 + a small constant — the two roundings are
each at most one 200-tick spacing step, ~2%, so the worst-case overshoot is
bounded, not open-ended) and the primary/continuation ranges never overlap
or leave a gap (`continuationLower == primaryTickUpper` exactly, checked in
every fuzz run, not just at the two configured quotes).

### F-2 (Low, fixed as defense-in-depth) — `LaunchRouter` had no reentrancy guard

No concrete exploit was found: the router holds no balance between
transactions, every amount is a local variable (not shared state a
reentrant call could corrupt), and `StocksBankLaunchFactory`'s own
`launch`/`launchFor`/`claimFees` already share one contract-wide
`ReentrancyGuard` lock, so a reentrant call back into the factory from a
hostile quote token's transfer hook would revert there regardless. Still,
a multi-hop swap through an attacker-influenceable token (the quote) is
exactly the shape of external call this class of guard exists for, and the
cost is negligible. Added `nonReentrant` to both `launchAndBuyFromNative`
and `launchAndBuyFromUsdc`.

### Non-findings (checked, not vulnerabilities — recorded so they aren't re-litigated)

**LP extraction.** No code path calls `decreaseLiquidity` or `burn` on
either position anywhere in the contract set. `claimFees` only ever calls
`collect()`, which moves accrued fees, never principal. Confirmed by
`test_claimFees_*` (both quotes) explicitly re-checking
`npm.ownerOf(primaryId)/(continuationId) == address(factory)` after a
claim.

**Fee theft / redirect.** `claimFees` is permissionless but its two
destinations (`l.creator`, `treasury`) are read from storage, never from
the caller — a non-creator caller gets nothing extra and cannot redirect
the creator's share (`test_claimFees_nonCreatorCannotRedirect`). Integer
division in the 50/50 split never loses a wei: the creator gets
`floor(fee/2)`, the treasury gets `fee - floor(fee/2)` (the remainder), so
every wei of every claim is accounted for.

**Quote malveillant ajouté au registre.** Only the owner can call
`enableQuote`, so a hostile quote can only enter via a malicious/compromised
owner — see the owner-malicious section below for the resulting blast
radius (bounded to future launches only; the malicious-quote-itself case,
e.g. fee-on-transfer or reentrant/ERC-777-style hooks, is a documented
owner-trust boundary, not something code can eliminate: only add
well-behaved ERC20s as quotes).

**Réentrance via le wrapper ERC-4626.** The launchpad never calls
`deposit`/`redeem`/`mint`/`withdraw`/`convertToAssets` on a quote —
confirmed by code review (grep for every call site touching `quote`: only
`safeTransferFrom`/`safeTransfer`/`approve`/`forceApprove`, all plain
ERC20) and now by `LaunchpadFrozenQuote.t.sol`, which forces a real
`StocksBankWrapper` instance's own `redeem()` to revert and shows
launch/trade/claimFees against it are completely unaffected. The
wrapper's *own* internal reentrancy safety (its `_deposit` override
reading balance-before/after the underlying transfer) is that contract's
existing, separately-covered property (`test/DinariAvalancheFork.t.sol`,
`test/StocksBankWrapperNaming.t.sol`) — out of scope here since the
launchpad never reaches it.

**Token0/token1 inversé.** Every orientation-dependent branch
(`LaunchMath.deriveCurve`, `_mintPosition`, `quoteInPrimary`, `claimFees`)
re-reviewed line by line this pass; each correctly reads `l.tokenIsToken0`
(or the local `isT0`) to pick which side is the launched token vs. the
quote. Covered for both registered quotes × both orientations by
`LaunchpadOrientation.t.sol` and the `LaunchMath.t.sol` unit tests.

**Griefing de création.** Front-run-init and pool-pre-init "bricking"
attempts are covered (`test_frontRunInit_*`, `test_bricking_*`, both
quotes) — inherited unchanged from Kaiten's own CREATE2-salt-retry design.
One inherited, accepted cost noted explicitly rather than silently passed
over: a griefer who pre-creates hostile pools at all `MAX_LAUNCH_ATTEMPTS`
addresses makes the honest caller pay gas for every failed attempt before
the eventual revert (or the eventual success on a later block) — an
existing Kaiten design tradeoff, not something this port changed or is in
scope to fix.

**Anti-snipe cap split across many fresh wallets.** The cap is keyed
per-recipient; a contract that routes sub-cap buys to several addresses it
controls, all within one transaction, can acquire more than the cap in
total. Documented in `StocksBankLaunchToken.sol`'s own comment, inherited
verbatim from Kaiten — inherent to any address-keyed cap, not new here.

## Owner-malicious analysis

Three owner-only functions exist: `enableQuote`/`disableQuote`,
`setTreasury`, `setLaunchRouter` (one-shot). None of them, individually or
combined, can reach `launches[token]`'s stored addresses, an LP NFT, or a
creator's fee share for a token that already exists — verified by grep (no
owner function reads or writes the `launches` mapping) and by
`LaunchpadOwnerPowers.t.sol`:

| Owner action | Can it touch an existing launch? | Test |
|---|---|---|
| `disableQuote` on a quote an existing token was launched against | No — only gates new `_launch` calls; trading and `claimFees` for the existing token are untouched | `test_disabledQuote_existingLaunchStillTradesAndClaimsFees` |
| `enableQuote` reconfiguring a quote's `startFdv`/`graduationThreshold` after a launch | No — the launch's own `graduationThreshold` was snapshotted at launch time, never re-read from the registry | `test_maliciousQuoteConfig_cannotRetroactivelyChangeExistingLaunch` |
| `setTreasury` to an attacker address | Only ever redirects the *protocol's* future 50% share; the creator's share is computed and sent identically regardless | `test_maliciousTreasuryRedirect_neverTouchesCreatorShare` |
| `setLaunchRouter` a second time | Reverts — settable exactly once | `test_setLaunchRouter_cannotBeChangedOnceSet` |
| Worst case: owner wires a maximally malicious router | Can spam-launch *new* tokens with impersonated creators (`launchFor` has no `token` param to target an existing launch, and can only spend quote it already holds/approved) — cannot touch any existing LP, fee share, or third party's funds | `test_maliciousLaunchRouter_cannotTargetExistingLaunch` |

`setTreasury` redirecting the protocol's own revenue share is intended,
spec'd behavior (LAUNCHPAD_SPEC.md §2), not a finding — the table confirms
it never touches the creator's half.

## Kaiten-inherited mechanisms re-confirmed, not re-derived

CREATE2-salted deploy + pre-init griefing guard + `BadStartPrice` invariant,
per-tx EIP-1153 anti-snipe (0.5%, 5 min, creator+factory exempt),
sustained-confirmation graduation (10 min, two-call arm/latch), LP locked
forever with permissionless 50/50 `claimFees` — all ported unchanged from
`HyperLauncherDev/kaiten` and re-exercised by this port's own fork tests
against the real Avalanche deployment, not re-audited from scratch (that
audit is Kaiten's own, referenced in LAUNCHPAD_SPEC.md §0).

## Summary

1 fix applied (F-1, tick rounding — the capacity-shortfall bug), 1
hardening applied (F-2, router reentrancy guard, defense-in-depth). No
open findings. 56/56 tests passing, including 20,000 combined fuzz runs
across extreme curve-parameter ratios and both orientations.
