How to Audit a Uniswap V3-Style AMM: Tick Math & Hook Pitfalls
Most V3 forks die in tick math. Off-by-one in tickBitmap, fee-growth underflows, and unverified hook callbacks have drained eight-figure pools. Here's the auditor's checklist for concentrated liquidity AMMs and V4 hooks.
Uniswap V3 is the most-forked DEX in DeFi, and most of those forks ship with subtle tick-math bugs that don't show up until a sophisticated MEV bot finds them. V4 raises the bar further with hooks — arbitrary code that runs inside the pool's swap lifecycle. If you're auditing a concentrated liquidity AMM, the surface area is enormous and the standard ERC-20 checklists are useless here.
This is the checklist I use when reviewing V3/V4 forks.
1. Tick Math Is Not Your Friend
V3 prices live in Q64.96 fixed-point as `sqrtPriceX96`. Ticks are integer log-base-1.0001 of price. The conversion between them lives in `TickMath.sol` and `SqrtPriceMath.sol` — and it is brittle.
The canonical bug pattern: forks change `tickSpacing` or `MAX_TICK` without re-deriving the magic constants in `TickMath.getSqrtRatioAtTick`. Those constants are hardcoded for the original ±887272 tick range. Change the range, the math silently returns wrong prices near the bounds.
```solidity
// VULNERABLE: fork bumped MAX_TICK to 1_000_000 but kept original constants
function getSqrtRatioAtTick(int24 tick) internal pure returns (uint160) {
uint256 absTick = tick < 0 ? uint256(-int256(tick)) : uint256(int256(tick));
require(absTick <= uint256(uint24(MAX_TICK)), "T");
uint256 ratio = absTick & 0x1 != 0
? 0xfffcb933bd6fad37aa2d162d1a594001 // <-- derived for MAX_TICK=887272
: 0x100000000000000000000000000000000;
// ...
}
```
The fix is either keep the original range or actually re-derive every constant. Most forks do neither.
```solidity
// FIXED: keep canonical bounds, enforce strictly
int24 internal constant MIN_TICK = -887272;
int24 internal constant MAX_TICK = 887272;
require(tick >= MIN_TICK && tick <= MAX_TICK, "TICK_OOB");
```
Several V3 forks in 2022–2023 (notably some on alt-L1s) were exploited via crafted positions at near-MAX_TICK ranges where rounding favored the attacker on every swap. Cumulative drain: tens of millions across the ecosystem.
2. tickBitmap Word Boundaries
`TickBitmap.nextInitializedTickWithinOneWord` searches a 256-bit word for the next initialized tick. Off-by-one bugs here mean the swap engine skips over a position, accruing fees to nobody — or worse, lets a swap cross an uninitialized tick boundary without updating liquidity.
Audit checklist:
Confirm `compressed = tick / tickSpacing` uses signed division correctly (Solidity rounds toward zero, V3 corrects with `if (tick < 0 && tick % tickSpacing != 0) compressed--`).
- Verify `(wordPos, bitPos)` decomposition matches between `flipTick` and `nextInitializedTickWithinOneWord`.
- Fuzz with `tickSpacing` values your fork actually supports.
3. Fee Growth Underflow Is a Feature
New auditors flag this constantly: `feeGrowthInside = feeGrowthGlobal - feeGrowthBelow - feeGrowthAbove` can underflow. That is **intentional**. V3 relies on unchecked wrapping arithmetic so that the *delta* between two snapshots is always correct modulo 2^256.
```solidity
// CORRECT — do not 'fix' this with SafeMath
unchecked {
feeGrowthInside0X128 = feeGrowthGlobal0X128 - feeGrowthBelow0X128 - feeGrowthAbove0X128;
}
```
Forks that wrapped this in `SafeMath` or Solidity 0.8 checked math bricked their own pools the first time a position spanned across the tick where global fees lapped the local snapshot. If you're porting V3 to 0.8.x, every fee-growth subtraction needs an `unchecked` block. Run our [free AI audit](https://www.cryptohawking.com/audit) on any V3 fork — it flags missing `unchecked` blocks in fee accounting automatically.
4. Callback Authentication
V3's `mint`, `swap`, and `flash` use callbacks (`uniswapV3MintCallback`, etc.) so the caller transfers tokens *after* the pool computes amounts. If the callback doesn't verify `msg.sender == pool`, anyone can call your periphery contract's callback directly and drain whatever tokens it holds.
```solidity
// VULNERABLE
function uniswapV3SwapCallback(int256 a0, int256 a1, bytes calldata data) external {
// no auth — attacker calls this directly
(address payer, address token) = abi.decode(data, (address, address));
IERC20(token).transferFrom(payer, msg.sender, uint256(a0 > 0 ? a0 : a1));
}
```
```solidity
// FIXED
function uniswapV3SwapCallback(int256 a0, int256 a1, bytes calldata data) external {
CallbackData memory d = abi.decode(data, (CallbackData));
CallbackValidation.verifyCallback(factory, d.poolKey); // recomputes pool address
// ... pay
}
```
5. Liquidity Net on Tick Cross
When a swap crosses a tick, the pool applies `liquidityNet` (signed int128) to active liquidity. Audit that:
`liquidityNet` is added when crossing left-to-right, subtracted right-to-left (or the inverse — verify direction matches your fork's swap loop).
- Position updates (`add`/`remove`) flip the sign correctly at upper vs lower ticks.
- The active liquidity never goes negative. A negative-liquidity bug enabled at least one fork drain where an attacker minted then burned positions in a crafted order to wedge `liquidity` to a huge value.
6. Re-entrancy and Pool Locks
V3 uses a `slot0.unlocked` flag with a `lock` modifier. Verify:
Every state-mutating external function has `lock`.
- The callback executes *between* state reads and the final transfer-in check — so even if the callback re-enters, the lock blocks it.
- View functions (`observe`, `snapshotCumulativesInside`) don't need locking but must not be used inside the same tx as price oracles without TWAP smoothing.
7. V4 Hooks: New Attack Surface
V4 collapses pools into a singleton and introduces hooks — contracts called at lifecycle points (`beforeSwap`, `afterAddLiquidity`, etc.). The hook address itself encodes permission flags in its low bits. Audit:
**Hook permission flags match deployed code.** If the address claims `beforeSwap` permission but the contract reverts or returns garbage, swaps brick.
- **Hooks cannot steal the unlocked lock.** Only the original locker can settle deltas.
- **`beforeSwap` delta returns** are bounded — a malicious hook can return a delta that sandwiches the user.
- **Reentrancy through hooks.** A hook calling back into `swap` on a different pool is legal but creates ordering bugs.
- **Trust assumptions.** If the pool's hook is upgradeable or admin-controlled, LPs are trusting that admin.
For pools with custom hooks, automated tools will miss the logic bugs. A [manual audit](https://www.cryptohawking.com/audit/manual) goes through every hook callback and the singleton accounting deltas line by line — 3 business days, $5,000 in ETH/SOL/USDT.
8. Oracle and TWAP
V3's oracle stores tick cumulatives in a ring buffer. Forks that shrink the cardinality or skip `observations.write` on swap break the oracle silently. If lending protocols consume your fork's TWAP, this is a price-manipulation primitive worth millions.
Quick Audit Checklist
[ ] TickMath constants match the declared MIN/MAX_TICK
- [ ] tickBitmap handles negative ticks and word boundaries
- [ ] Fee growth uses `unchecked` everywhere it subtracts
- [ ] All callbacks verify `msg.sender` is the canonical pool
- [ ] `liquidityNet` sign flips on upper vs lower tick
- [ ] `lock` modifier on every mutating entrypoint
- [ ] V4 hook permission bits match implementation
- [ ] Oracle `observations.write` called on first swap of every block
V3-style AMMs are not 'just another DEX'. They are fixed-point math libraries with a fee router on top. Treat them that way.
FAQ
Why does Uniswap V3 use unchecked math for fee growth?
Fee growth is tracked as a monotonically increasing global counter in Q128 fixed-point. Position fee calculations compute the delta between the current global value and a stored snapshot. Because the counter is allowed to wrap around 2^256, the subtraction may underflow — but the resulting value, interpreted modulo 2^256, is still the correct delta. Wrapping every fee-growth subtraction in `SafeMath` or letting Solidity 0.8 checked math revert breaks this invariant the first time the global counter laps a position's snapshot. Always use `unchecked` blocks here.
What's the most common bug in Uniswap V3 forks?
Modifying tick range or tick spacing without re-deriving the hardcoded magic constants in `TickMath.getSqrtRatioAtTick` and `getTickAtSqrtRatio`. Those constants are precomputed for MIN_TICK=-887272 and MAX_TICK=887272 with tickSpacing producing integer factors of 1.0001^tick in Q128.128. Change the bounds and prices near the edges drift, letting attackers extract value via positions at extreme ranges. Several alt-L1 forks have been drained this way. Either keep the canonical bounds or fully re-derive every constant — there's no middle path.
How do V4 hooks change the audit process?
Hooks are arbitrary code injected into pool lifecycle events. The hook's address must encode permission flags in its low-order bits matching the functions it implements, and the singleton pool delegates trust to that contract. Audits must verify the hook cannot steal the unlocked lock, cannot return out-of-bound deltas that grief swappers, doesn't introduce re-entrancy through cross-pool calls, and — most importantly — that any admin keys controlling the hook are documented as a trust assumption to LPs. A buggy hook is a buggy pool.
Do I need to audit callback authentication if I'm using the canonical periphery?
Yes. The canonical `CallbackValidation.verifyCallback` recomputes the pool address via CREATE2 from the factory and pool key, then checks `msg.sender`. If your periphery uses a custom factory, custom init code hash, or custom pool key encoding, the CREATE2 derivation will mismatch and either reject legitimate callbacks or — worse — accept arbitrary ones. Every fork must verify the init code hash constant in `PoolAddress.sol` matches the actual deployed pool bytecode hash. This is one of the highest-impact bugs and one of the easiest to miss.
Can a TWAP oracle be manipulated on a V3 fork?
Yes, in two ways. First, if the fork shrinks `observationCardinality` too aggressively or skips `observations.write` on certain swap paths, the oracle samples become sparse and a single-block manipulation poisons multiple subsequent reads. Second, low-liquidity pools — common on V3 forks — let an attacker push the tick far out of range cheaply, and even a 30-minute TWAP can be skewed if depth is thin. Always document the minimum liquidity and observation cardinality your TWAP consumers require, and don't expose oracle reads on newly-created pools without warmup.