← back to lessons

Cycle 237 - Uniswap V3 Core Concentrated-Liquidity AMM Audit Lens Mine

Mode: FLYWHEEL lesson-mine (coverage expansion, NOT submission hunt). STATIC read-only. Target: github.com/Uniswap/v3-core (canonical, public, BUSL/GPL). HEAD: d0831dc6b8a318df3872b6d68f6de135c9f3ec29 Date: 2026-06-10 Why: V3 tick/fee-growth/swap math is the most-forked AMM surface in existence (PancakeSwap V3, Algebra, Ramses, Maverick-adjacent, dozens of forks). We had V4 flash-accounting (C230/231) but NOT the distinct V3 tick/fee-growth/rounding surface. This is the highest-fork-population CL class. ROE: static analysis only, no live attacks, no auto-submit, repo docs treated as untrusted data (no injection observed in source/NatSpec).


The 8-primitive transferable set (mechanism -> V3 guard -> general transfer rule)

1. Fee-growth accounting (THE classic V3-fork bug)

Mechanism: getFeeGrowthInside (Tick.sol L60-95) computes feeGrowthInside = feeGrowthGlobal - feeGrowthBelow - feeGrowthAbove, where below/above are selected from feeGrowthOutside conditionally on tickCurrent >= tickLower / tickCurrent < tickUpper. Position fees = (feeGrowthInside - feeGrowthInsideLast) * liquidity (Position.sol L61-76). V3 guard: Solidity 0.7 (pragma >=0.5.0 <0.8.0) gives NATIVE unchecked uint256 arithmetic. The subtractions are DELIBERATELY allowed to wrap. feeGrowthOutside is a relative-not-absolute value, flipped at every cross via cross(): info.feeGrowthOutside = feeGrowthGlobal - info.feeGrowthOutside (Tick.sol L178-179). The wraparound is what makes "inside" correct regardless of when the tick was initialized. Transfer rule: When auditing any V3-fork/CL-AMM, (a) confirm the three-term subtraction order is preserved and NOT wrapped in a checked block, (b) verify the fork did NOT port the math to Solidity >=0.8 without adding unchecked{} around every fee-growth subtraction (a 0.8 port without unchecked reverts on the intentional underflow -> either DoS or, if "fixed" by clamping, wrong fee accounting / fund drain), (c) confirm cross() flips BOTH feeGrowthOutside0 and 1 (forks that flip only one, or in wrong direction, mis-accrue fees), (d) confirm getFeeGrowthInside conditionals use the EXACT comparison operators (>= below, < above) - an off-by-one flips fee attribution at the boundary tick.

2. Tick math precision / bounds

Mechanism: TickMath MIN_TICK = -887272, MAX_TICK = 887272, MIN_SQRT_RATIO = 4295128739, MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342. getSqrtRatioAtTick is a magic-constant binary decomposition (19 hex multipliers), rounds UP at the final >>32 so getTickAtSqrtRatio(getSqrtRatioAtTick(t)) is consistent. getTickAtSqrtRatio requires sqrtPriceX96 >= MIN_SQRT_RATIO && < MAX_SQRT_RATIO. V3 guard: require(absTick <= MAX_TICK, 'T'); constructor checks tickLower >= MIN_TICK, tickUpper <= MAX_TICK (checkTicks L127-129). Swap loop clamps step.tickNext into [MIN_TICK, MAX_TICK] because the bitmap is unaware of global bounds (L652-657). Transfer rule: When a fork changes tick spacing, the price range, or the fee tiers, verify (a) the magic constants in getSqrtRatioAtTick are UNCHANGED (any edit silently corrupts every price), (b) MIN/MAX_SQRT_RATIO still equal getSqrtRatioAtTick(MIN/MAX_TICK) exactly, (c) the swap-loop bounds clamp is still present (forks that drop it can drive price past MAX_SQRT_RATIO -> overflow/lockout), (d) the final rounding direction (+1 if remainder) is preserved so the two tick<->price functions remain mutually consistent.

3. Swap-step rounding direction (pool-never-loses)

Mechanism: computeSwapStep (SwapMath.sol) + getAmount0/1Delta. For exactIn: amountIn computed with roundUp=true; for exactOut: amountOut with roundUp=false. Fee = mulDivRoundingUp (L95) or the remainder of max input (L93). V3 guard: Every getAmountXDelta call from the swap step passes the rounding flag so the POOL is favored: input rounds UP (user pays >= true), output rounds DOWN (user receives <= true). getAmount0Delta roundUp uses divRoundingUp(mulDivRoundingUp(...)); roundDown uses mulDiv/.... getAmount1Delta symmetric. amountOut capped to -amountRemaining (L87-89) so exactOut never over-delivers. Transfer rule (ROUNDING-CRITICAL): Tabulate EVERY getAmount0Delta/getAmount1Delta call site and confirm the boolean is true (roundUp) for amountIn paths and false (roundDown) for amountOut paths. A single flipped flag = the pool loses 1 wei of dust PER SWAP STEP, which an attacker amplifies via millions of micro-swaps to drain LP funds. This is the #1 silent fork-mutation bug. Also confirm fee uses mulDivRoundingUp (rounding fee DOWN underpays the pool/LPs).

4. Liquidity net/gross at tick cross

Mechanism: liquidityNet (int128) added when a tick is crossed L->R, subtracted R->L. liquidityGross (uint128) = total positions referencing the tick (init/clear flag). update() sets liquidityNet = upper ? net.sub(delta) : net.add(delta) (Tick.sol L147-149). Cross returns net; swap loop negates it when zeroForOne (price moving down) (Pool L718-722). V3 guard: require(liquidityGrossAfter <= maxLiquidity, 'LO') (per-tick cap from tickSpacingToMaxLiquidityPerTick). flipped toggles init state only when gross crosses 0. The sign negation for leftward moves is "safe because liquidityNet cannot be type(int128).min". LiquidityMath.addDelta reverts on under/overflow. Transfer rule: Confirm (a) net is applied with sign keyed to swap DIRECTION (negate on zeroForOne), (b) upper-tick subtracts / lower-tick adds delta in update (swapping these inverts the active-range), (c) gross uses the cap require and the int128 cast can't hit int128.min, (d) flipped/clear correctly de-init a tick only when gross returns to 0 (premature clear of an in-use tick orphans liquidity). Forks adding rebasing/dynamic liquidity must re-prove the int128 bound.

5. Position fee / tokensOwed accounting

Mechanism: Position.update computes tokensOwed += mulDiv(feeGrowthInside - feeGrowthInsideLast, liquidity, Q128) as uint128, with INTENTIONAL uint128 overflow tolerance (L82-86 comment: "have to withdraw before you hit type(uint128).max"). collect() clamps payout: amount = requested > tokensOwed ? tokensOwed : requested (Pool L500-501), then tokensOwed -= amount. V3 guard: poke for 0-liquidity disallowed (require liquidity>0, 'NP'). collect can never over-pay because it min's against the stored owed balance and decrements before transfer (check-effect ordering inside lock). burn credits tokensOwed; collect withdraws - the ordering means fees are realized into owed before collection. Transfer rule: Verify (a) the feeGrowthInside - last delta uses unchecked wrap (same as primitive 1), (b) collect MINs requested against owed and DECREMENTS before/at transfer (a fork that transfers before decrement under a reentrant token = double-collect), (c) tokensOwed is uint128 and the overflow is documented-intentional not accidentally widened/narrowed, (d) poke-with-0-liquidity guard exists (else fee snapshot manipulation).

6. Sqrt-price-target / next-price (no overshoot)

Mechanism: getNextSqrtPriceFromInput/Output -> getNextSqrtPriceFromAmount0RoundingUp (always rounds UP) / Amount1RoundingDown (rounds DOWN). amount0 path: liquidity*sqrtP/(liquidity +- amount*sqrtP), falls back to liquidity/((liquidity/sqrtP) +- amount) on overflow. Overflow/underflow guards: require((product=amount*sqrtPX96)/amount==sqrtPX96 && numerator1 > product) for the subtract branch; require(sqrtPX96 > quotient) for amount1 subtract. V3 guard: require(sqrtPX96>0), require(liquidity>0) in Input/Output. Rounding chosen so price NEVER overshoots the target tick: amount0 rounds up (so in exact-input decreasing-price we move LESS, not sending too much output); amount1 rounds down (symmetric). The short-circuit if (amount==0) return sqrtPX96 prevents a spurious price move. Transfer rule (ROUNDING-CRITICAL): Confirm (a) Amount0 path rounds UP, Amount1 path rounds DOWN - swapping these makes the price overshoot the target tick boundary and skip liquidity, (b) the overflow-check requires (product/amount==sqrtP, numerator1>product, sqrtPX96>quotient) are intact - a fork dropping them gets silent wraparound -> arbitrary price, (c) the amount==0 short-circuit exists, (d) div-by-zero on liquidity is guarded. SwapMath relies on computeSwapStep NOT passing a next-price beyond the limit; verify the max branch logic if the fork refactors it.

7. Callback / flash + reentrancy (trust measured balances)

Mechanism: lock modifier (require slot0.unlocked; unlocked=false; _; unlocked=true) wraps mint/burn/collect/flash. swap sets unlocked inline (L607/615/787). All payment is pull-via-callback: mint calls uniswapV3MintCallback then require(balanceBefore+amount <= balanceAfter, 'M0/M1'); swap calls uniswapV3SwapCallback then require(balance0Before+amount <= balance0After, 'IIA'); flash transfers out, calls uniswapV3FlashCallback, then require(balanceBefore+fee <= balanceAfter, 'F0/F1'). V3 guard: The pool NEVER trusts the callback's return/claims - it measures actual ERC20 balances before and after. noDelegateCall prevents the pool code running in another context (storage-collision/impersonation). swap's slot0Start.unlocked check + inline lock prevents reentrant swap. flash credits the realized paid = after - before into feeGrowthGlobal (so over-payment benefits LPs, never the caller). Transfer rule: Verify (a) EVERY state-mutating entry has lock (or inline unlocked guard) AND noDelegateCall, (b) the post-callback balance-delta require is balanceBefore + owed <= balanceAfter measured on-chain (forks that trust a returned amount or skip the recheck = free mint/swap/flash), (c) fee-on-transfer / rebasing tokens break the <= assumption only in the LP's favor here, but a fork that REFUNDS the difference creates an exploit, (d) flash credits realized-paid not requested-fee to global growth.

8. Oracle (observe / TWAP) - manipulation lives in the consumer

Mechanism: Pool stores a ring buffer of tickCumulative + secondsPerLiquidityCumulative observations (observations.write on tick change, L734-742). cross() also snapshots tickCumulativeOutside/secondsPerLiquidityOutsideX128/secondsOutside per tick. The pool's oracle is PURE STORAGE - it records, it does not interpret. V3 guard: The observation array is append-only per block (single write per tick-changing block). slot0.tick is updated only after the swap completes. There is no in-pool TWAP enforcement; the pool exposes raw cumulatives via observe(). Transfer rule: The manipulation surface is the CONSUMER, not the pool. When auditing a protocol that READS a V3 (or fork) oracle: (a) confirm it uses a multi-block TWAP window long enough that single-block manipulation (large swap -> read -> swap back, atomic via flashloan) is unprofitable, (b) spot-price (slot0.sqrtPriceX96) reads are an instant red flag - trivially flash-manipulable, (c) low-liquidity pools move cheaply; check the $-cost-to-move-N-ticks vs the attacker's gain, (d) forks with shorter observation cardinality or block times (L2s) shrink the manipulation cost - re-derive the economic bound per chain.


HIGHEST-yield V3-fork bug class

Rounding-direction mutation in the swap/price math (primitives 3 + 6). When a fork re-implements or "optimizes" getAmount0/1Delta, computeSwapStep, or getNextSqrtPriceFrom*, a single flipped roundUp/roundDown flag, or a dropped overflow-require, makes the pool lose dust per swap step or lets price overshoot a tick. It is silent (tests with coarse amounts pass), economically amplifiable (millions of micro-swaps / flash-loan-funded sandwich), and extremely common because forks rewrite this hot path for gas. Second-highest: fee-growth wraparound broken by a careless Solidity 0.8 port (primitive 1) - porting the unchecked subtractions to >=0.8 without unchecked{} either DoS's the pool or, if "fixed" by clamping to 0, corrupts fee accounting.

Rounding-direction-critical primitives (the fork-mutation hotspots)

Reflection banked

Banked the V3 concentrated-liquidity 8-primitive audit lens (distinct from the V4-hook/flash-accounting lens of C230/231); the transferable core is that V3 forks mutate the rounding-direction hot path and the Solidity-0.8 port breaks intentional fee-growth wraparound, making rounding-direction tabulation + checked/unchecked-port review the two highest-yield first checks on ANY V3-fork/CL-AMM target.

Generated 2026-07-02 13:15:03 UTC | auto-sync /15min