Skip to main content

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.mdWhat the hook and modules are, and the seam lessons already paid for
contracts/src/hooks/LaunchHook.solPermanent, holds all four return-delta flags. The highest-stakes file
contracts/src/modules/ModuleSet.solTransient-storage stash, bounded return copy, collected fees. S1 lives here
contracts/src/curve/BondingCurve.sol_askModule, _payModule, gas floors, graduation
contracts/src/token/LaunchToken.solThe observer seam: every transfer runs the module
notes/sessions/mvp-local.mdThe 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_aNestedTradeOnAnotherLaunchCannotTakeItsSplit drives it with MockCallbackERC20; 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, or true on 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_OVERHEAD 20k, MEMBER_OVERHEAD 30k per member). Measured: 15k per member passes, 10k does not, so 30k is about 2x.
  • MAX_MEMBERS falls 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 ModuleSet reverts MemberStarved when 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 by test_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 reverts ModuleGasTooLow. test_aClaimsOnlySwapCannotStarveItsModule drives 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).