# Audit 2 — Independent hostile review (launchpad, `contracts/src`)

Written as an independent auditor: no prior familiarity with this codebase
assumed, no trust extended to its comments, and `contracts/AUDIT.md` was not
read until this document was otherwise complete (see the comparison note at
the end). Scope: `contracts/src/launchpad/StocksBankLaunchFactory.sol`,
`StocksBankLaunchToken.sol`, `LaunchRouter.sol`,
`libraries/LaunchMath.sol`, and `contracts/src/StocksBankWrapper.sol`,
branch `launchpad`. Every finding below has a real fork test proving the
attack (or proving it fails), `contracts/test/launchpad/LaunchMathTruncation.t.sol`
and `contracts/test/launchpad/LaunchpadHostileAudit2.t.sol`, run against
Avalanche mainnet at `FORK_BLOCK=95834900`. Nothing was signed or broadcast.

**Update — F1 through F4 fixed and re-tested** (same session, on request,
before SB Phase S1 began): each finding below now also has a `test_fix_*`
regression test proving the correction. F5 stayed documentation-only, as
originally recommended (no code change). See each finding's "**Fix
applied**" line for specifics, and the "Corrections applied" section at the
end for the full before/after test rundown and one correction to this
document's own original PoC 2 (see Finding 2).

## Summary

| # | Finding | Severity | Status | PoC (attack) | PoC (fix) |
|---|---|---|---|---|---|
| 1 | `LaunchRouter`'s USDC acquire-leg can partially fill against the real (thin) sbNVDA/USDC pool, permanently stranding the unspent input with no recovery path | **High** | **Fixed** | `test_FINDING_usdcAcquireLeg_partialFill_leavesUsdcPermanentlyStuckInRouter` (superseded, see below) | `test_fix_usdcAcquireLeg_partialFill_refundsLeftoverToCaller`, `test_fix_zeroAcquireMinOut_rejectedForSwapRequiringQuote` |
| 2 | `LaunchMath._deriveEconomicSqrtPrices` casts a division result to `uint160` with a bare, silently-truncating cast; an extreme (owner-set) ratio produces corrupted, non-monotonic tick output | **Medium** | **Fixed** | `LaunchMathTruncation.t.sol` (`test_PoC1_...` — PoC 2 corrected, see Finding 2) | `test_fix_directLibraryCall_extremeRatioRevertsInsteadOfCorrupting`, `test_fix_endToEnd_extremeThresholdRevertsInsteadOfMispricingAPool`, `test_fix_doesNotAffectRealisticConfigs` |
| 3 | `claimFees` makes both recipients' transfers in one atomic call with no isolation; a quote with its own transfer restrictions can permanently freeze fees for BOTH the creator and the treasury if either gets blocked | **Medium** | **Fixed** | `test_claimFees_quoteBlocksCreator_freezesFeesForBothParties` (superseded, see below) | `test_fix_claimFees_quoteBlocksCreator_treasuryStillPaidCreatorQueued` |
| 4 | `setLaunchRouter` accepts any nonzero address with no check that it is a contract; wiring a plain EOA (typo or malice) collapses the router's entire "always forward msg.sender" safety argument | **Medium** | **Fixed** | `test_setLaunchRouter_wiredToEOA_enablesDirectCreatorImpersonation` (superseded, see below) | `test_fix_setLaunchRouter_rejectsEOA` |
| 5 | Anti-snipe exemption is keyed on the transfer's recipient only, not on who pays — a third party can execute an uncapped buy during the snipe window as long as `recipient == creator` | **Low / informational** | **No code change** (documentation only, as originally recommended) | `test_antiSnipe_thirdPartyCanBuyUncappedIfRecipientIsCreator` | n/a |

Everything else specifically asked for was tested and found to hold —
listed under "Non-findings" below, each with its own PoC.

---

## Finding 1 — `LaunchRouter` acquire-leg partial fill strands funds permanently (High)

**Where**: `LaunchRouter.launchAndBuyFromUsdc` (`LaunchRouter.sol:103-134`).

**What happens**: the router pulls the caller's full `usdcAmount` into its
own balance (`safeTransferFrom`, line 113), then calls
`SwapRouter02.exactInputSingle(amountIn: usdcAmount, amountOutMinimum:
acquireMinOut)`. Uniswap V3 exact-input swaps do **not** revert when a
pool's liquidity is exhausted before the full declared input is consumed —
they fill what they can and return early, and the swap's own callback only
pulls the *actually-consumed* amount from the payer. Since the router
already holds the caller's full amount (pre-funded, not paid
incrementally), the unconsumed remainder is simply left sitting in the
router's balance. `LaunchRouter` has no function anywhere that can move it
— not to the original caller, not to anyone.

This is not a contrived extreme: the real sbNVDA/USDC pool is exactly the
thin, ~$16 TVL pool documented in `contracts/DEPLOYMENTS.md` and already
flagged (LiveTest.s.sol's "Too little received" episode) as easy to
exhaust. `acquireMinOut=0` — the value a caller who doesn't already know
the pool will partially fill has no principled way to avoid — is what lets
the partial fill go through silently instead of reverting.

**PoC** (`LaunchpadHostileAudit2.t.sol`,
`test_FINDING_usdcAcquireLeg_partialFill_leavesUsdcPermanentlyStuckInRouter`):
an ordinary $20 `launchAndBuyFromUsdc` call. Trace evidence: the pool's own
`Swap` event shows `tick: -887272` — literally Uniswap's `MIN_TICK`, i.e.
the pool's liquidity was fully exhausted — with `amount0: 8262646` (only
$8.262646 actually swapped). The callback pulls exactly that amount from
the router. Measured, reproducible result: **$11.737354 of the caller's
$20 (~59%) permanently stuck**, confirmed by the test passing (asserts
`stuck > 0`).

I also tried to reproduce the same effect via `launchAndBuyFromNative`
(which routes through the same thin leg via an extra WAVAX→USDC hop) at
2, 10, 30, and 200 AVAX — none left a residual balance. SwapRouter02's
multi-hop `exactInput` appears to handle a bottlenecked *later* hop
differently from a standalone `exactInputSingle` (plausibly scaling back
how much of the first hop's output it actually requests once a later hop
can't absorb it all). I did not fully characterize why, and did not claim
it in the test suite — the USDC-path finding stands on its own regardless.

**Impact**: any caller of `launchAndBuyFromUsdc` risks losing a material
fraction of their funds to the router with zero warning and zero recovery,
under real, current, non-adversarial market conditions — no attacker
needed, no owner error needed, just ordinary use against a currently thin
pool.

**Fix applied**: after each acquire-leg swap (both `launchAndBuyFromNative`'s
non-WAVAX branch and `launchAndBuyFromUsdc`'s non-USDC branch),
`_refundLeftoverAndClearApproval` reads the router's own balance of the pay
token and `safeTransfer`s whatever's left back to `msg.sender`, then resets
the `SwapRouter` allowance to `0` so a partially-consumed approval doesn't
sit there between calls. Both swap branches also now `revert
ZeroAcquireMinOut()` if the caller passes `acquireMinOut == 0` — the second
rail recommended below, since a refund alone doesn't stop a caller from
*intending* a bigger buy than the pool can support, only from losing the
difference silently.

**Verified**: `test_fix_usdcAcquireLeg_partialFill_refundsLeftoverToCaller`
re-runs the exact same $20 scenario and confirms the caller now receives
the 11.737354 USDC that used to be stranded, the router ends at exactly 0,
and the `SwapRouter` allowance is cleared.
`test_fix_zeroAcquireMinOut_rejectedForSwapRequiringQuote` confirms the new
guard reverts for a swap-requiring quote and is correctly skipped when
`quote == WAVAX` (no swap happens there at all). The native-path
non-reproduction noted above stands as originally written — the fix closes
the USDC-path leak regardless of that open question on the native side.

---

## Finding 2 — Silent `uint160` truncation in `LaunchMath` (Medium)

**Where**: `LaunchMath._deriveEconomicSqrtPrices`
(`LaunchMath.sol:145-157`), specifically
`sqrtEconB = uint160(numeratorPart / sqrtEconA);` — a bare Solidity
downcast, which truncates silently rather than reverting on overflow.

**Why it's reachable**: `enableQuote` only requires `startFdv > 0` and
`graduationThreshold > startFdv` (`StocksBankLaunchFactory.sol:173-178`).
Nothing bounds their *magnitude* relative to each other. For a small
`startFdv` and a `graduationThreshold` many orders of magnitude larger
(both individually well within `uint256` range, and both passing
`enableQuote`'s check trivially), `numeratorPart / sqrtEconA` regularly
exceeds `type(uint160).max` by ten-plus orders of magnitude. This can only
be reached via `enableQuote`, which is `onlyOwner` — not attacker-facing —
but the failure mode (silent corruption, not a revert) is exactly the
class of bug this project has repeatedly flagged as its worst case (see
`DEPLOYMENTS.md`'s own "pre-flight: fork rehearsal caught a real bug" for
an earlier, unrelated instance of the same *shape* of mistake).

**PoC 1** (`LaunchMathTruncation.t.sol`,
`test_PoC1_tickUpperIsNonMonotonicInGraduationThreshold`): calling
`LaunchMath.deriveCurve` directly (bypassing the registry, to isolate the
math) with a fixed `startFdv=1` and five *monotonically increasing*
`graduationThreshold` values produces a **non-monotonic** `tickUpper`
sequence (871000 → 884800 → 855400 → 869200 → 851200) — the unambiguous
signature of integer wraparound. Correct math can only ever produce a
non-decreasing tick as the target raise grows.

**PoC 2** (`test_PoC2_endToEnd_largerThresholdProducesOppositeSignPriceRange`):
end-to-end, through the real `enableQuote` + `launch()` path, against real
Avalanche-mainnet Uniswap V3 contracts. Two configurations sharing the same
`startFdv`, differing only in `graduationThreshold` (one ~1% above
`startFdv`, one ~1000% above — both economically unremarkable, ordinary
inputs), **both get accepted** (real pool created, no revert) — but the
larger-threshold case's tick range lands entirely on the *opposite side of
tick zero* from the smaller one's. This is not a boundary-rejection
artifact: both pools exist on-chain in the test; the larger-raise-target
case is priced lower than the smaller one, which is impossible for correct
math and is direct evidence the accepted pool's price does not reflect the
configured curve.

I did not attempt to fully map which `(startFdv, graduationThreshold)`
pairs get silently accepted-but-wrong versus rejected by Uniswap's own
bounds checks (I found both outcomes across a broad sweep) — PoC 2 is
sufficient to show "silently accepted and wrong" is a real, reachable
outcome, not merely "always caught downstream."

**Correction (found while re-testing the fix, not by the original
reviewer of this fix — by me, re-checking my own prior work): PoC 2's
methodology was flawed.** The "smaller" and "bigger" threshold launches
were two SEPARATE factory deployments, and `tokenIsToken0` is decided by
the real CREATE2 address versus `WAVAX` — which differed by chance between
the two runs (unpinned salt inputs). Re-checked directly against the
library with orientation held FIXED: `deriveCurve(1e34, T, true)` for
`T` from `1.01e34` to `1.1e35` produces a perfectly monotonic `tickUpper`
sequence (189000 → 202600 → 221000 → 234800 → 236800), and the mirror
sequence for `tokenIsToken0=false` is equally monotonic in the opposite
direction. The "opposite sign" observation was an artifact of comparing
two *different, independently-correct* orientations, not evidence of
corruption — this specific `(1e34, ~1e35)` pair never actually overflowed
`uint160` in the first place. The genuine defect was always PoC 1's
fixed-orientation, same-library-call non-monotonicity — that evidence
was solid and is what the fix (and its regression test) targets.

**Impact**: an owner who fat-fingers `graduationThreshold` by too many
zeros (plausible: it's a raw wei-scale `uint256` typed by hand or by a
script) does not get a clean revert warning them of the mistake in every
case — some inputs silently deploy a real, permanently-live pool at a
nonsensical price.

**Fix applied**: both casts in `_deriveEconomicSqrtPrices`
(`sqrtEconA`/`sqrtEconB`) and the two derived from them in
`deriveCurve`'s `tokenIsToken0 == false` branch now go through
OpenZeppelin's `SafeCast.toUint160`, which reverts
(`SafeCastOverflowedUintDowncast`) instead of truncating.

**Verified**: `test_fix_directLibraryCall_extremeRatioRevertsInsteadOfCorrupting`
re-runs PoC 1's exact five-threshold sequence and confirms every one now
reverts with `SafeCast`'s own overflow error (decoded from the returned
selector, not just "some revert"). Using the corrected, fixed-orientation
understanding above, `test_fix_endToEnd_extremeThresholdRevertsInsteadOfMispricingAPool`
confirms the genuinely-overflowing `(startFdv=1, graduationThreshold=1.4e45)`
pair now reverts end-to-end through `enableQuote` + `launch()` instead of
minting a real pool. `test_fix_doesNotAffectRealisticConfigs` confirms the
live WAVAX and sbNVDA configs are completely unaffected by the stricter
cast.

---

## Finding 3 — `claimFees` fund-lock via a transfer-restricted quote (Medium)

**Where**: `StocksBankLaunchFactory.claimFees` (`StocksBankLaunchFactory.sol:379-409`).

**What happens**: all four transfers (`quote`→creator, `quote`→treasury,
`token`→creator, `token`→treasury) execute in one call, no `try`/`catch`.
Plain ERC20 `transfer` never calls the recipient's code, so a "hostile
contract creator/treasury" cannot revert this on its own — but if `quote`
is a token with its **own** transfer restriction (blocklist, sanctions
list, KYC gate — not the currently-registered WAVAX or sbNVDA, neither of
which has this property: sbNVDA's own share token is a plain unrestricted
ERC20, its restrictions only ever apply to the wrapped dShare, which this
protocol never touches directly) and either the creator or the treasury
gets blocked by *that token's own issuer* after the launch, the entire
`claimFees` call reverts — permanently, since the block is outside this
protocol's control. The non-blocked party's share freezes right along with
the blocked party's, even though nothing prevents transferring one leg
without the other.

**PoC** (`LaunchpadHostileAudit2.t.sol`,
`test_claimFees_quoteBlocksCreator_freezesFeesForBothParties`): a mock
quote with an owner-settable per-address block list, registered like any
other quote, a real launch + trade generating fees, then the creator gets
blocked on the quote (simulating a future compliance-gated quote's own
issuer action, nothing this protocol does). `claimFees` reverts
(`Blocked()`); unblocking restores it, confirming the block — not
something else — was the cause.

**Impact**: bounded to quotes the owner might add in the future with this
property (not exploitable against the two quotes live today), but it's a
real design gap: one party's problem (getting blocked) shouldn't be able
to freeze the other party's already-earned fees.

**Fix applied**: `claimFees` now routes every leg through `_sendOrQueue`,
which attempts a plain `transfer()` in a `try`/`catch` (catches a revert,
a `false` return, and malformed/missing return data alike — covering both
"reverts on failure" and "returns false on failure" non-compliant-token
behaviors) and, on any of those, credits the amount to a new
`pendingClaims[asset][recipient]` mapping instead of reverting the whole
call. A new permissionless `withdrawPendingClaim(asset)` lets the
affected party retrieve their own queued balance once/if the block is
lifted — it only ever pays `msg.sender` their own entry, never anyone
else's.

**Verified**: `test_fix_claimFees_quoteBlocksCreator_treasuryStillPaidCreatorQueued`
re-runs the same blocked-creator scenario and confirms `claimFees` no
longer reverts: the treasury receives both its quote and token shares
directly, the (unrelated, not itself blocked) token leg still pays the
creator directly, and only the blocked quote leg is queued. Withdrawing
while still blocked correctly reverts without losing the queued balance;
unblocking and withdrawing pays out exactly the queued amount and drains
the entry.

---

## Finding 4 — `setLaunchRouter` doesn't require a contract (Medium)

**Where**: `StocksBankLaunchFactory.setLaunchRouter` (`StocksBankLaunchFactory.sol:162-167`).

**What happens**: the only check is `router_ != address(0)`. Nothing
requires `router_.code.length > 0`. If the owner (deploy-script typo, copy
-pasted the wrong address, or malice) wires a plain EOA instead of the real
`LaunchRouter` contract, `launchFor`'s entire safety argument — "the
router never lets its caller choose an arbitrary `creator`, because it
always forwards its own `msg.sender`" — evaporates, because there is no
router *logic* in the path at all: the EOA is simply the direct caller of
`launchFor` and can pass any `creator` address of its choosing.

**PoC** (`LaunchpadHostileAudit2.t.sol`,
`test_setLaunchRouter_wiredToEOA_enablesDirectCreatorImpersonation`): wire
a plain `makeAddr`-generated EOA as `launchRouter` (succeeds — no
rejection), then call `launchFor(victim, ...)` directly from that EOA —
succeeds, `victim` (an address that never signed anything) is recorded as
the creator.

**Impact**: same blast radius as the "malicious router" scenario already
covered in `contracts/AUDIT.md` (new tokens only, cannot touch an existing
LP or existing fees — `launchFor` has no `token` parameter) — the
distinguishing point here is that this specific failure mode (wiring an
EOA, not a hostile *contract*) is a plain input-validation gap that costs
nothing to close.

**Fix applied**: `require(router_.code.length > 0, "router must be a contract");` added to `setLaunchRouter`, right after the existing zero-address check.

**Verified**: `test_fix_setLaunchRouter_rejectsEOA` confirms a plain
`makeAddr`-generated address is now rejected and `launchRouter` stays
unset after the attempt (still settable correctly afterward with a real
contract — covered by the pre-existing `test_setLaunchRouter_cannotBeChangedOnceSet`
and every other test in the suite that wires a real `LaunchRouter`).

---

## Finding 5 — anti-snipe exemption is recipient-based, not payer-based (Low / informational)

**Where**: `StocksBankLaunchToken._update` (`StocksBankLaunchToken.sol:64-82`), condition `to != creator`.

**What happens**: the cap is skipped whenever the transfer's *destination*
is the creator's address, regardless of who initiated or paid for the
swap. A third party can therefore execute an uncapped buy during the
5-minute snipe window as long as they set the swap's `recipient` to the
creator's address.

**PoC** (`LaunchpadHostileAudit2.t.sol`,
`test_antiSnipe_thirdPartyCanBuyUncappedIfRecipientIsCreator`): `bob`
(not the token's creator) funds and executes a swap sized well past
`MAX_BUY`, with `recipient: creator` — succeeds, no revert. The resulting
tokens land with `creator`, not `bob` — confirmed in the same test
(`bob`'s own balance stays `0`).

**Impact**: this is not a way for a third party to extract value (the
tokens go to the creator, not the payer) — it's flagged because "the
creator is exempt from the cap" and "any transfer landing at the creator's
address is exempt from the cap, regardless of payer" are two different
guarantees, and only the second one is what's actually implemented. Worth
the team being explicit about rather than assuming the narrower reading.
Unchanged from Kaiten's own original condition (`to != creator`) — not
something this port introduced, but new-relative-to-Kaiten review was
explicitly asked for, and Kaiten's own design carries this same property.

**Proposed fix**: none required — document the actual guarantee, or (if
the narrower guarantee is truly intended) change the condition to also
check that `tx.origin`/an explicit payer parameter equals `creator`,
which has its own, worse tradeoffs (breaks smart-contract-wallet
creators). Recommend documentation over a code change.

---

## Corrections applied — summary

All four fixes and their regression tests live in the same commits/files
as the rest of the launchpad (`StocksBankLaunchFactory.sol`,
`LaunchRouter.sol`, `libraries/LaunchMath.sol`,
`test/launchpad/LaunchMathTruncation.t.sol`,
`test/launchpad/LaunchpadHostileAudit2.t.sol`,
`test/launchpad/LaunchpadDevBuy.t.sol` — the last needed several call
sites updated from `acquireMinOut=0` to a nonzero placeholder once F1's
second rail landed, since those tests were never about that value). Full
suite re-run after all four fixes, `FORK_BLOCK=95834900`: every
`test/launchpad/*` file passes, including the fuzz suite re-run at 10,000
iterations. One pre-existing, unrelated failure remains
(`test/LiveNvdaSequence.t.sol`, a stale fork-rehearsal test from the
earlier NVDA live-test work whose assumptions no longer match real
mainnet state — not part of this scope, not touched here).

---

## Non-findings (checked, held up — PoC for each)

| Target checked | Result | PoC |
|---|---|---|
| `launch{value}` with a non-WAVAX quote | Reverts (`"native payment only for the WAVAX quote"`) | `test_nativeLaunch_nonWavaxQuote_reverts` |
| `launch{value}` with `quote==WAVAX` not yet enabled | Reverts (`UnknownQuote`), zero AVAX/WAVAX left in the factory | `test_nativeLaunch_quoteNotEnabled_revertsCleanly_noStuckAvax` |
| `msg.value` and `initialBuyAmount` both set | Reverts (`"initialBuyAmount must be 0 when paying natively"`) | `test_nativeLaunch_valueAndInitialBuyAmountBothSet_reverts` |
| Reentrancy during the WAVAX wrap | Not applicable: the real WAVAX contract's `deposit()` makes no external call, and `launch`/`launchFor` are `nonReentrant` regardless | code review, no PoC needed |
| `LaunchRouter` residual balance, WAVAX-quote native path | Zero residual (full amount used directly, no swap) | `test_router_noResidualBalance_afterSuccessfulNativeLaunch_wavaxQuote` |
| `LaunchRouter` residual balance, sbNVDA-quote native path (2 AVAX) | Zero residual at this size | `test_router_noResidualBalance_afterSuccessfulNativeLaunch_sbNvdaQuote` |
| Caller-supplied `quote` used to inject an arbitrary/malicious token into the router's swap path | Whole transaction reverts atomically (no real pool at the hardcoded fee tiers, or `UnknownQuote` inside the factory); zero residual either way | `test_router_unregisteredQuoteInjection_revertsAtomically_noResidual` |
| `launchFor` called by anyone other than the wired router | Reverts (`OnlyLaunchRouter`) | `test_launchFor_directCallByThirdParty_reverts` |
| `launchFor` called before any router is wired | Reverts (`OnlyLaunchRouter`) for any caller — fails closed | `test_launchFor_directCall_whileRouterUnset_reverts` |
| Revert on one leg (acquire or dev-buy) leaving a token created without its dev buy | Impossible: no `try`/`catch` anywhere in the path, a revert unwinds the CREATE2 deploy, position mints, and `Launch` storage write together with the swap | covered by Phase 1.5's own atomicity tests, re-confirmed by code review here |
| Multi-hop buy trying to exceed the anti-snipe cap via the router's generic `exactInputSingle` interface | Still reverts (`"TF"`, i.e. `AntiSnipeCap()` re-wrapped by the pool's TransferHelper) | `test_antiSnipe_routedBuy_stillEnforcesCap` |
| `checkGraduation` spammed repeatedly within one transaction/block | Never latches — `block.timestamp` doesn't advance within a transaction, so the hold-duration check can never pass no matter how many times it's called | `test_checkGraduation_repeatedSameBlockCalls_neverLatch` |
| Quote disabled, then re-enabled with different `startFdv`/`graduationThreshold` | An existing launch's snapshotted `graduationThreshold` is untouched; it keeps trading and claiming fees normally | `test_quoteReconfiguredAfterLaunch_doesNotRetroactivelyChangeIt` |
| Quote decimals ≠ 18 | Not separately re-tested this pass (the math is decimals-agnostic by construction — see `LaunchMath.sol`'s own derivation, which only ever uses the quote's raw wei amounts, never assumes 18dp) | reasoned, not re-proven with a fresh PoC this pass |
| Reentrancy via the ERC-4626 wrapper (`StocksBankWrapper`) | The launchpad never calls `deposit`/`redeem`/`mint`/`withdraw`/`convertToAssets` on any quote — confirmed by grep (every call site touching `quote` is `safeTransfer`/`safeTransferFrom`/`approve`/`forceApprove`, plain ERC20 only) | code review; the frozen-redeem scenario in `contracts/AUDIT.md` already has a dedicated fork PoC (`LaunchpadFrozenQuote.t.sol`), not repeated here |

---

## Comparison with `contracts/AUDIT.md` (read after finishing the above)

Independently reached the same conclusion on LP extraction (no
`decreaseLiquidity`/`burn` path exists) and on the general owner-power
boundary (quotes/treasury/router can't reach an existing launch's LP or
fees). This pass went further on three fronts `AUDIT.md` didn't cover,
because they're specific to the Phase 1.5 surface (`launch{value}`,
`launchFor`, `LaunchRouter`) that postdates it:

- Finding 1 (`LaunchRouter` partial-fill fund loss) is new and, on
  reflection, more serious than anything in `AUDIT.md` — it's the only
  finding across both documents reachable by an ordinary user with no
  owner error and no extreme configuration, under real current mainnet
  conditions.
- Finding 2 (the `uint160` truncation) sharpens `AUDIT.md`'s F-1 (the tick
  *rounding* bug, already fixed) into a genuinely separate defect in the
  same function family: rounding-direction was about approximation error;
  this is about an unchecked cast that can silently corrupt, not just
  approximate, the result.
- Findings 3 and 4 are new surface: `AUDIT.md` predates `claimFees`'s
  interaction with a hypothetically-restricted quote being examined this
  specifically, and predates `setLaunchRouter` existing at all in a
  version reviewed for exactly this gap.

`AUDIT.md`'s F-2 (LaunchRouter reentrancy guard, added defense-in-depth)
and its owner-malicious table are consistent with what this pass found
independently and are not repeated here.

---

## Re-test indépendant (new pass — hostile, independent, distrusts this
## document's own claims)

Separate reviewer, separate session, zero trust extended to "Fixed"/
"Verified" as written above — every claim in F1-F4 was independently
re-attacked with fresh fork tests before being accepted. Also covers new
ground this document never scoped: `libraries/AcquirePath.sol`, the
configurable per-quote acquisition-path mechanism, and its integration
with `enableQuote`/`LaunchRouter`. No production code was changed by this
pass — findings only, each backed by a real `vm.createSelectFork` test,
`FORK_BLOCK=95834900`. All new test files live under
`contracts/test/audit-independent/scope1/` (F1-F4 re-test) and
`contracts/test/audit-independent/scope2/` (AcquirePath). Full run:
`forge test --match-path "test/audit-independent/scope1/**/*"` → 22/22
pass; `forge test --match-path "test/audit-independent/scope2/**/*"` →
29/29 pass. Combined with the rest of the suite in one shared build
(`forge build`, no per-scope cache dirs), all 88 new tests across both
scopes plus the SB scope (below) pass together: `forge test --match-path
"test/audit-independent/**/*"` → 88/88.

### Verdict summary

| Finding | Result of re-test |
|---|---|
| F1 (LaunchRouter partial-fill refund) | **Holds**, including on the native-AVAX path (not just USDC) and under a deterministically-forced partial fill, not just real sbNVDA's incidental thinness. One new Informational behavior documented (below). |
| F2 (LaunchMath `uint160` SafeCast) | **Holds** — broad fuzzing (256 runs/orientation) over every `enableQuote`-reachable input found no silent corruption, only clean reverts. One correction to this document's own framing (below): overflow can also revert via `Math.mulDiv`'s own guard, upstream of the `SafeCast` this document credits — same "always reverts, never corrupts" outcome, different selector. |
| F3 (`claimFees`/`pendingClaims`) | **Holds** — no double-claim, no reentrant double-withdraw, no cross-asset contamination, no permanent loss on a reverted retry. |
| F4 (`setLaunchRouter` contract check) | **Holds** for what it actually claims to prevent (EOA wiring) and for the real, shipped `LaunchRouter.sol` (verified non-proxyable: immutable `FACTORY`/`SWAP_ROUTER`, no owner, no fallback). One Informational finding on what the check does *not* and structurally *cannot* guarantee (below). |
| AcquirePath / configurable paths (new scope) | **No exploitable finding.** Every documented fail-safe (existence-only pool checks, atomic revert on a dead/thin pool, `acquireMinOut` protection against mid-flight reconfiguration) was independently reproduced rather than taken on faith. Two Informational observations (below). |

### Informational — LaunchRouter refund sweeps stray donations to an unrelated later caller

**Where**: `LaunchRouter._refundLeftoverAndClearApproval` (`LaunchRouter.sol:160-166`).

`_refundLeftoverAndClearApproval` refunds *whatever balance currently sits
in the router* at the end of a call, not a value scoped to that caller's
own swap. The router holds no balance between its own transactions in
normal use, but nothing stops a third party from directly `transfer`ing
WAVAX or USDC to the router's address outside of any router call. That
donation sits inert (no code path ever sweeps it proactively) until a
later, unrelated caller happens to take a swap-requiring-quote branch —
at which point their own refund logic sweeps the donation out too, as an
unearned windfall for them, and a permanent loss for the original donor.

**PoC**: `contracts/test/audit-independent/scope1/ScopeF1Router.t.sol::test_informational_strayDonationIsSweptToAFutureUnrelatedCaller`
— donates 3 AVAX-worth of WAVAX directly to the router, confirms it
survives an intervening WAVAX-quote call untouched (that branch never
calls the refund helper at all, since there's no swap for `quote ==
WAVAX`), then confirms a third, unrelated caller's native-path refund
receives exactly the donation amount stacked on top of their own
(fully-filled, zero-leftover) leg.

**Impact**: no legitimate caller who only ever interacts with the router
through its own entry points is harmed — the loss is entirely
self-inflicted by whoever sends funds to a contract address outside its
documented interface, and the "victim" of that mistake has no reasonable
expectation of recovery from any contract in this pattern. Not worth a
code change; noted for completeness since "leftover" is easy to misread
as "isolated to this call" when it is really "whatever the router
currently holds."

### Correction to F2's framing — a second, independent revert path, not new corruption

**Where**: `LaunchMath.sol:157-158` (`sqrtPoolStart`/`sqrtPoolGrad` in
`deriveCurve`'s `tokenIsToken0 == false` branch) and `LaunchMath.sol:205`
(`numeratorPart` inside `_deriveEconomicSqrtPrices`).

AUDIT-2.md's F2 write-up credits `SafeCast.toUint160` as *the* backstop
against silent overflow. Independently re-checking every step of the
derivation for a second, unrelated failure mode found one: `numeratorPart
= Math.mulDiv(graduationThreshold, Q192, primaryAmount)` executes
*before* the `SafeCast`-protected cast, and OpenZeppelin's `Math.mulDiv`
carries its own overflow guard — for a `(startFdv, graduationThreshold)`
magnitude extreme enough that the mulDiv quotient itself cannot fit in
256 bits (not just 160), the revert fires as a plain `Panic(0x11)`
(arithmetic overflow) from inside `Math.mulDiv`, never reaching the
`SafeCast` call at all. This is **not a new corruption path** — the
end-to-end guarantee ("every overflowing input reverts cleanly, none
silently corrupt") still holds, reproduced at two different overflow
magnitudes with two different revert selectors. It's a correction to
which specific guard fires first, not a new weakness.

**PoC**: `contracts/test/audit-independent/scope1/ScopeF2LaunchMath.t.sol::test_tokenZeroBranch_directCast_overflowsCleanlyAtDifferentSupplyScale`
and `test_tokenOneBranch_ownCast_overflowsCleanlyWithTinySupply` — both
orientations, both revert paths, reproduced directly against the
library. `testFuzz_everyEnableQuoteReachableInput_neverSilentlyCorrupts`
(256 runs) and `testFuzz_roundedCurve_neverUndershootsGraduationThreshold`
(256 runs) additionally fuzz across the full `enableQuote`-reachable input
space and the rounded-tick graduation-capacity invariant respectively —
no silent corruption found in either sweep, and wide monotonicity sweeps
(`test_monotonicity_token0_wideSweep`, `test_monotonicity_token1_wideSweep`)
held in both orientations.

### Non-findings re-confirmed with new PoCs (F1, F3, F4)

| Target | Result | Test |
|---|---|---|
| F1: native-path (`launchAndBuyFromNative`) partial fill, deterministically forced (not relying on real sbNVDA's incidental live liquidity) | Refunds correctly, zero residual — generalizes AUDIT-2's own inconclusive native-path result rather than it being sbNVDA-specific luck | `test_fix_nativePath_multiHop_deterministicPartialFill_noResidual` |
| F1: USDC-path partial fill, deterministic | Refunds fully | `test_fix_usdcPath_deterministicPartialFill_refundsFully` |
| F1: full-fill case (leftover == 0) | No stray transfer, allowance still correctly reset to 0 | `test_fix_fullFill_noResidual_allowanceStillReset` |
| F1: reentrancy via a hostile quote token's `transferFrom` during the refund | Blocked (`nonReentrant`) | `test_reentrancy_maliciousQuote_cannotReenterFactoryDuringLaunchFor` |
| F3: `claimFees` called twice with no new fees between calls | Second call is a clean no-op, no double payout | `test_claimFees_calledTwiceWithNoNewFees_secondCallIsCleanNoop` |
| F3: reentrancy into `withdrawPendingClaim` during its own transfer | Blocked — `pendingClaims[...] = 0` lands before the transfer | `test_reentrancy_withdrawPendingClaim_cannotDoubleWithdraw` |
| F3: a `safeTransfer` failure inside `withdrawPendingClaim` itself (still blocked) | Whole call reverts, queued balance is NOT lost, retriable once unblocked | `test_withdrawPendingClaim_stillBlocked_revertsWithoutLosingQueuedBalance` |
| F3: quote-leg vs. token-leg queued amounts for the same recipient | Never cross-contaminate (different `asset` keys) | `test_pendingClaims_quoteAndTokenLegsNeverMixed` |
| F3: a malicious quote whose `transfer()` deliberately burns gas | Degrades to ordinary queueing, exactly-once accounting, no inconsistency | `test_gasHungryTransfer_degradesToOrdinaryQueueing_noInconsistency` |
| F4: plain EOA wiring attempt | Still rejected | `test_fix_stillRejectsPlainEOA` |
| F4: self-destructing an already-wired router in a later transaction | Does not clear `launchRouter.code.length` on this fork (Avalanche C-Chain at `FORK_BLOCK=95834900` runs EIP-6780/Dencun SELFDESTRUCT semantics: code persists unless destroyed in the same transaction as creation) and does not reopen the impersonation hole either way | `test_selfDestructAfterWiring_codeLengthBehavior_andNoImpersonationReopens` |

### Informational — `code.length > 0` is a point-in-time bytecode check, not a behavior guarantee

**Where**: `StocksBankLaunchFactory.setLaunchRouter` (`StocksBankLaunchFactory.sol:180-191`).

Built a minimal delegatecall proxy that passes `setLaunchRouter`'s check
honestly at wiring time (it has code, and at that moment its delegate
target forwards `msg.sender` as `creator` exactly like the real
`LaunchRouter` does), then swapped the proxy's delegate target afterward
to logic that lets its OWN caller pick an arbitrary `creator`. The
factory has no way to detect this — `launchFor`'s only defense is
`msg.sender == launchRouter`, an address-equality check, not a guarantee
about what code answers at that address or whether it can change.
`code.length > 0` proves "not an EOA" (the specific gap F4 closed) and
nothing more; it was never capable of proving "this address's behavior
is fixed forever," which is the property the surrounding safety argument
actually needs.

**Confirmed not applicable to this specific deployment**: the real,
shipped `LaunchRouter.sol` is a plain, non-upgradeable contract — no
proxy pattern, no owner, no admin function, no fallback, immutable
`FACTORY`/`SWAP_ROUTER` set once in its constructor. This finding is
about the *check's* actual guarantee in general, not about a live
vulnerability in the deployed system today.

**PoC**: `test_informational_codeLengthCheck_isAPointInTimeCheck_notABehaviorGuarantee`
(the proxy-swap demonstration) and `test_realLaunchRouter_isPlainImmutableContract_notProxyable`
(confirming the real contract's immutability), both in
`contracts/test/audit-independent/scope1/ScopeF4SetLaunchRouter.t.sol`.

**Impact**: none today — flagged so a future re-deployment of
`LaunchRouter` (or of any contract wired the same way elsewhere in this
codebase) isn't assumed safe purely because it passed `code.length > 0`
once; that check is necessary, not sufficient, and should never be
treated as proof of ongoing honest behavior for anything more complex
than a plain immutable contract.

### AcquirePath / configurable-path router — attack results

**Scope**: `contracts/src/launchpad/libraries/AcquirePath.sol`,
`StocksBankLaunchFactory.enableQuote`, and `LaunchRouter`'s live read of
`acquirePathFromNative`/`acquirePathFromUsdc` at call time. Never
previously audited (postdates both AUDIT.md and AUDIT-2.md's original
pass). All tests under `contracts/test/audit-independent/scope2/`.

**No exploitable finding.** Two Informational observations:

1. **`AcquirePath.validate` accepts cyclic/self-revisiting paths.** A
   path like `WAVAX→(fee)→USDC→(fee)→WAVAX→(fee)→quote`, or a same-pool
   "hop out and back," passes validation and executes correctly end to
   end through `LaunchRouter` — proven, not just theorized
   (`AcquirePathValidateTest.test_cyclicThreeHopPath_revisitingStartToken_isAcceptedByValidate_andExecutesCorrectlyThroughRouter`,
   `test_cyclicTwoHopPath_backToStartToken_isAcceptedByValidate`). Impact
   is bounded to the owner wasting gas/fees on a needlessly circuitous
   route they configured themselves — `enableQuote` is `onlyOwner`, so
   there's no attacker-facing path into this at all.

2. **Reconfiguration mid-flight is bounded by ordinary slippage
   protection, nothing more.** A quote's acquisition path is read live
   from factory storage at `LaunchRouter` call time, never cached or
   committed to by the caller in advance. True same-transaction
   reentrancy into `enableQuote` isn't reachable (it's `onlyOwner`, and
   a swap only calls into Uniswap contracts, never back into the
   factory) — the real exposure is ordinary front-running: an owner's
   `enableQuote` landing between when a caller computed their
   `acquireMinOut` off-chain and when their transaction actually
   executes. Tested directly: a caller whose `acquireMinOut` was set
   against the old path either reverts cleanly (protected) or, with a
   deliberately loose `minOut`, succeeds at worse-but-still-correctly-
   accounted economics — no accounting corruption, no loss beyond what
   the caller's own `minOut` choice already exposed them to. Also
   confirmed: reconfiguring a quote's path to `""` or disabling the
   quote entirely, in-flight, produces a clean `NoAcquirePathConfigured`/
   `UnknownQuote` revert, never stale execution or stuck funds (the
   pre-swap fund pull is unwound atomically along with everything else).
   (`AcquirePathReconfigTest`, 4 tests.)

**Non-findings, each independently reproduced (not assumed from the
code's own doc comments):**

| Target checked | Result | Test |
|---|---|---|
| Path length boundaries: 0, 1-19, 20 (bare token), 21-42, 43 (1 hop), 66 (2 hops), 89 (3 hops), 112 (4 hops) | Exact `MalformedPath`/`TooManyHops` boundaries hold, both via direct library call and via `factory.enableQuote` | `AcquirePathValidate.t.sol`, 17 tests |
| Wrong start token, wrong end token, nonexistent intermediate hop pool | `WrongStartToken`/`WrongEndToken`/`HopPoolDoesNotExist` fire correctly | same file |
| A pool that "exists" (`getPool != 0`) but was never `initialize`d | Registers fine (existence-only check is deliberate); an actual `LaunchRouter` call through it reverts atomically end to end, zero funds ever leave the caller's wallet (pre-swap pull is unwound with everything else) | `AcquirePathPoolState.t.sol` |
| A pool initialized with zero liquidity | Same: atomic revert, zero residual left in the router | same file |
| 6-decimal quote (real USDC, registered as a launchpad quote in its own right, not just as the router's alternate pay-asset), 0/1/2/3-hop paths, both router entry points (native and USDC) | All work correctly end to end — right decimals throughout, curve prices sensibly, no scaling bug; 3-hop case constructed with a synthetic intermediate quote since no real 3-hop route exists on mainnet at this fork block | `AcquirePathHopsAndDecimals.t.sol`, 5 tests |

Full scope2 run: `forge test --match-path "test/audit-independent/scope2/**/*"` → **29 passed, 0 failed**.

---

## EIP-170/EIP-3860 size restructuring (2026-09-22) — `StocksBankLaunchFactory` exceeded the deployed-contract size limit

Discovered at the first real canary dry-run (`forge script script/CanaryDeploy.s.sol --sender $WALLET`, no broadcast), NOT by `forge test`: `forge build --sizes` showed `StocksBankLaunchFactory`'s runtime bytecode at **39,818 bytes**, 62% over the EIP-170 24,576-byte limit every standard EVM chain enforces (Avalanche C-Chain included) — it would have reverted on any real deployment attempt. Neither `forge test` (243+ tests, including `CanarySimulation.t.sol`) nor any prior audit round caught this: `forge test`'s local EVM doesn't enforce this limit the way a real broadcast does, and the factory had never actually been deployed for real before this dry-run.

### Root causes (two independent contributors)

1. **Internal library functions get inlined into every caller.** `LaunchMath.deriveCurve`/`deriveCurveWithAmounts`, `AcquirePath.validate`, `LiquidityAmounts.getAmountsForLiquidity`, and `TickMath.getSqrtPriceAtTick`/`getTickAtSqrtPrice` were all declared `internal` — Solidity copies an `internal` library function's full bytecode into every contract that calls it, rather than deploying it once. Between them (`TickMath`'s tick math especially — dense, assembly-heavy) this was the majority of the overage.
2. **`new StocksBankLaunchToken{salt}(...)` was called directly from `_launch`**, a RUNTIME function (invoked on every launch, not just once at deploy time) — Solidity embeds a contract's full creation bytecode into every OTHER contract that instantiates it with `new`. Embedding the token's entire creation code inside a function that runs on every call, rather than once at construction, meant it counted against the factory's own deployed (runtime) size.

### Fix (behavior unchanged, only where the bytecode lives)

1. **Library functions changed from `internal` to `public`** (`LaunchMath.sol`, `AcquirePath.sol`, `LiquidityAmounts.sol`, `lib/uniswap-v4-tickmath/TickMath.sol`). A `public` library function deploys once, as its own contract, reached via `DELEGATECALL` — completely transparent to every existing call site (Solidity resolves `Library.func(...)` the same way regardless of visibility; nothing else changed). Cut the factory from 39,818 to 29,844 bytes on its own.
2. **The token `new` call moved to a dedicated, permissionless `StocksBankLaunchTokenDeployer`** (`src/launchpad/StocksBankLaunchTokenDeployer.sol`), deployed once in the factory's own constructor (a one-time `new` there only costs *initcode* size, a separate, much larger EIP-3860 budget the factory has ample headroom for) and called from `_launch` via a small external call instead of an inline `new`. Cut the factory to 22,196 bytes — **under the limit, with 2,380 bytes of margin.**

`StocksBankLaunchToken`'s constructor gained an explicit `factory_` parameter (it used to read `msg.sender`, which is now the deployer helper, not the real factory, for both the supply mint and the `factory` immutable used by `onlyFactory` gates).

### Independent security review of the restructuring itself (requested explicitly, not self-certified)

Making the token deployer **permissionless** (anyone can call it, matching the "holds no state, no owner" design of every other helper in this codebase) initially reintroduced a real vulnerability that the ORIGINAL inline-`new` design never had:

- **First draft took `factory_` as a caller-supplied argument.** `_launch`'s CREATE2 salt (`keccak256(abi.encode(creator, block.timestamp, allTokens.length, attempts))`) is fully computable off-chain by anyone who observes a pending `launch`/`launchFor` call and can land their own transaction in the same block (an ordinary same-block MEV scenario). An attacker could replicate the exact salt + `name`/`symbol`/`imageURI`/`creator` a legitimate launch is about to use, pass the real factory's own address as `factory_`, and deploy to that address FIRST. CREATE2 to an address that already has code reverts — the real factory's subsequent call (not wrapped in try/catch, since a revert here is meant to propagate for every other reason it could fire) would then revert too. Pure gas-cost griefing of a specific pending launch, with no way for `_launch` to route around it.
- **Fix**: `factory_` is now bound to `msg.sender` INSIDE the deployer, never a caller-supplied argument. An attacker calling `deploy` directly still supplies their own `msg.sender`, producing a completely different CREATE2 `initCode` hash (hence a completely different address) no matter how precisely every other argument is replicated — they cannot collide with a real factory-issued address without literally being the factory, which is impossible (`msg.sender` cannot be spoofed). A third party calling `deploy` directly only ever mints the resulting token's entire supply to themselves, never to the real factory, and the factory's own registry (`launches[token]`) only ever reflects addresses returned by ITS OWN calls — there is no way to launder a third-party deployment into it.

**Tests** (`test/launchpad/TokenDeployerGriefing.t.sol`):
- `test_REGRESSION_thirdParty_cannotSquatFactorysPredictedTokenAddress` — the exploit attempt run directly: an attacker replicates the exact salt/name/symbol/imageURI/creator a pending launch will use and front-runs the deployer; the resulting address does NOT collide with the factory's predicted address, the attacker's token mints to the attacker (not the factory), and the real `launch()` call, immediately after, still succeeds and lands exactly where predicted.
- `test_deployerHasNoFactoryParameter_msgSenderIsAlwaysUsed` — confirms `factory_` always equals the direct caller for two different callers (no collision between them, since `factory_` differs), and confirms the SAME caller reusing the SAME salt DOES collide (ordinary CREATE2 semantics, no special-casing) — isolating that the protection comes specifically from `msg.sender` binding, not some other implicit uniqueness.

Every pre-existing CREATE2 address-prediction site elsewhere in the test suite (`test/launchpad/LaunchpadBase.sol`'s `_factoryForOrientation`, and two griefing/retry scenarios in `test/launchpad/Launchpad.t.sol`) was auditing this exact salt/deployer/constructor-arg shape and had to be updated to predict against `factory.tokenDeployer()` instead of the factory itself, and to include the new `factory_` constructor argument — confirmed these now predict the same real addresses the live contracts produce (not just updated to compile).

Full run after the restructuring: see STATUS.md for the current count. `forge build --sizes` confirms both `StocksBankLaunchFactory` (22,196 / 24,576 bytes) and `SBLauncher` (38,469 / 49,152 initcode bytes — see AUDIT-SB.md for that side of the same restructuring) now have positive margin.
