Session — Security
Closed 19 Sep 2026, tagged milestone-1. Fixes merged to master, not deployed to Sepolia by
choice. What remains is under Next.
What this session is for: a real security pass over the launchpad as it now stands — hook, modules, curve, token, factory — before anything touches mainnet. Its own branch, merged when done.
Read first
notes/sessions/hooks.md | What the hook and modules are, and the seam lessons already paid for |
contracts/src/hooks/LaunchHook.sol | Permanent, holds all four return-delta flags. The highest-stakes file |
contracts/src/modules/ModuleSet.sol | Transient-storage stash, bounded return copy, collected fees. S1 lives here |
contracts/src/curve/BondingCurve.sol | _askModule, _payModule, gas floors, graduation |
contracts/src/token/LaunchToken.sol | The observer seam: every transfer runs the module |
notes/sessions/mvp-local.md | The Slither triage the core already had |
The building-secure-contracts and uniswap-hooks plugins cover method and v4 hook security. Use
them rather than reasoning from memory.
Already decided, and binding
- The owner is trusted. Owner powers are not findings.
- Breaking and refusing are different (D32): a module that reverts is swallowed, one that rejects is honoured. A module that rejects every sell is the creator's rope, contained to their launch — isolation, not prevention, is the property to check.
- Nobody is exempt from a wallet cap or anti-snipe, the creator included. Structural holders (curve, migrator, PoolManager) and registered venues are.
- Slither is clean at the gate (18 Sep 2026): 0 medium or high, 36 low and 12 informational, all known classes. It found none of the three real bugs this thread fixed — do not treat it as the analysis.
Findings so far
S1 — one stash for every launch let a nested trade take another launch's split. Fixed on this branch, 19 Sep; not deployed. Low: it needs a quote that calls back on transfer, and quotes are owner-registered. The set held a curve trade's split in one transient slot shared by every launch. Mid-buy on launch A, a callback quote let the trader buy launch B, whose fee was then divided by A's split too: measured, B's treasury got 2.4 of a 50 fee and the trader 47.6.
- Each launch now has its own stash slot, emptied at the start of every curve trade.
test_aNestedTradeOnAnotherLaunchCannotTakeItsSplitdrives it withMockCallbackERC20; it fails on the old code with the numbers above.- The two WETH paths first written down are not reachable: a module fee rounds up, so it is never zero while a split is waiting, and the token transfer's 640k floor sits between stash and notify.
S2 — a starved ModuleSet failed open, all members at once. Fixed on this branch, 19 Sep;
not deployed (Sepolia's set still has it; ships with the next cohort upgrade). The set reverted
MemberStarved, and the curve, hook and token all swallow a module that reverts, so the trade ran
with no member applied. A one-member set did it on every trade: its share was the whole 600k and it
asked for 609k.
- Starvation now refuses the trade (
reject, ortrueon a transfer) instead of reverting. - Shares are sized so the last member gets its full share even if every member spends all of its own:
_share(n)reserves the set's own work (SET_OVERHEAD20k,MEMBER_OVERHEAD30k per member). Measured: 15k per member passes, 10k does not, so 30k is about 2x. MAX_MEMBERSfalls from 24 to 10: the old figure ignored the set's own work and the 64th.- At least 2 members (
TooFewMembers). One mechanic attaches directly. - The misleading test is gone. Four new ones fail on the old code: refusal on starvation, a full list of gas-burning members on curve, pool and token, and the one-member refusal.
S3 — the hook called its module with no gas floor. Fixed on this branch, 19 Sep; not deployed. Medium. The curve and token refuse to call a module without 640k in hand; the hook does not. The 63/64 rule stops a module that runs out of gas, but not one that gives up cheaply when short: its gas comes back and the swap finishes without it. A claims-only swap moves no ERC20, so the token's floor never applies.
- Live on Sepolia: the deployed
ModuleSetrevertsMemberStarvedwhen short, so every set launch's pool fees and limits can be skipped this way. The S2 fix already closes it — a starved set refuses — proven bytest_aClaimsOnlySwapCannotStarveTheSet, which fails on the live code. - Single modules today fail by running out of gas, so they are not exposed.
- Fixed:
MODULE_GAS_FLOOR(640k) before either callback asks the module, as the curve does; short of it the swap revertsModuleGasTooLow.test_aClaimsOnlySwapCannotStarveItsModuledrives a module that gives up when short, and failed before the floor. - The hook is permanent: this reaches new launches only, through a new hook. Existing pools keep the old one, so the module rule stands for them: starved, refuse, never revert.
H1 — a deferred debt followed the latest payee. Fixed on this branch, 19 Sep; not deployed. Low today. The hook keeps one
deferredPayee per pool and currency; a module that changes payTo while pushes fail hands earlier
debts to the latest payee. No current module varies payTo. Now owed per payee; sweep(id, currency, payee).
W1 — the launch page could not show a set. Fixed on this branch, 19 Sep. Web. ModuleCard looks the module up in the
catalogue, and the set is not in it, so every combined launch — ours included — reads "Custom
mechanic, not from this launchpad", and its members are never listed. A set with a hostile member
reads the same. Now: whether a module is ours comes from factory.isKnownModule, which remembers
every module we ever published; a set of ours has its members listed, each badged the same way. A
stranger's contract is never asked for members, since it could claim any. Checked against Sepolia's
live set launches: four members, each shown as an earlier mechanic of ours. Matters because members are not vetted:
the threat model had modules as ours, and they are not (D26).
Checked and holding: the swap matrix at 99%; graduation gas with a set (measured, now tested); held curve fees across graduation (now tested); outside LPs; module state keyed by launch. Known limits recorded in the threat model: caps do not bind claims; 640k per transfer.
Next
S1, S2, S3, H1 and W1 are fixed on this branch; none is deployed. The upgrade can now ship a hook
(SHIP_HOOK=1, 19 Sep): a new hook, a migrator with the old one's pool terms, and every quote on the
old migrator repointed. Proved locally — an old pool still trades and is taxed on the old hook, and
dev-smoke-modules passes on the new one — and rehearsed against Sepolia: ~18.4M gas, ~0.0057 ETH.
Broadcast is the developer's step: SHIP_HOOK=1 ./ops/sepolia.sh upgrade, then commit the book.
Still to decide: capping TaxModule's 99% sell tax (threat model, creator's rope).