Uniswap V2’s x * y = k invariant fits in a single line. Every operation on a concentrated liquidity AMM (CLMM) touches fixed-point arithmetic, tick boundary traversal, per-position fee checkpoints, and non-fungible ownership—all in the same transaction. This paradigm shift introduced a new class of security considerations and novel MEV strategies, demonstrating the inherent trade-off between optimization and attack surface. This article maps every major vulnerability class introduced by the CLMM model, explains the underlying mechanics that enable each exploit, and ends with a practical audit checklist.


1. Tick Math and Precision Vulnerabilities

The sqrtPriceX96 Representation

Concentrated liquidity pools do not store price directly. Pools store prices as sqrtPriceX96—the square root of the actual token price in a 96-bit fixed-point format. This representation enables efficient mathematical operations while maintaining precision across the extreme price ranges that concentrated liquidity supports.

The relationship between a tick index i and its price is:

price(i) = 1.0001^i
sqrtPrice(i) = sqrt(1.0001^i) * 2^96

The core idea is to represent the pool’s price as the square root of the actual token price, scaled by a large fixed-point factor (2^96). This “sqrt-price” linearizes the relationship between price movement and liquidity, allowing all calculations to stay in pure integer arithmetic and avoid costly floating-point approximations.

The problem: the mathematical backbone relies on 512-bit multiplication-division routines that prevent overflow and preserve exactness when dealing with the large numbers produced by Q96 scaling. Without these high-precision helpers, the fixed-point arithmetic would quickly lose accuracy or fail outright.

Rounding Direction Errors

Every intermediate rounding in tick math must be directionally consistent. Rounding errors that favour the pool are safe; rounding errors that favour the caller can be exploited. Consider a vulnerable implementation of getSqrtRatioAtTick:

// VULNERABLE: naive integer sqrt loses precision near tick boundaries
function getSqrtRatioAtTick_Unsafe(int24 tick)
    internal
    pure
    returns (uint160 sqrtPriceX96)
{
    uint256 absTick = tick < 0 ? uint256(-int256(tick)) : uint256(int256(tick));
    // Direct exponentiation without the bit-shifting ladder used by Uniswap
    // Precision degrades for |tick| > ~100, enabling rounding attacks
    uint256 ratio = absTick & 0x1 != 0
        ? 0xfffcb933bd6fad37aa2d162d1a594001
        : 0x100000000000000000000000000000000;
    // ... (truncated for brevity) — missing the final Q32→Q96 precision step
    sqrtPriceX96 = uint160(ratio >> 32);  // BUG: off-by-one in shift
}

The correct Uniswap implementation uses a precomputed bit-ladder of 19 magic constants, each applied with mulshift96 to keep every intermediate value within 256 bits. Forks that simplify this ladder introduce systematic rounding errors that accumulate across tick crossings.

The nearestCurrentTick Boundary Bug

Exploiting a state where currentTick sits on a tick range boundary, nearestCurrentTick miscalculated as currentTick - 1 results in mining liquidity in the range (currentTick, currentTick + n). During a subsequent one-to-zero swap, this miscalculation causes a re-addition of the created liquidity.

The issue arises from pre-mining: crossing the tick boundary adds liquidity l0. Mining adds l1 liquidity but also contributes to the tick range, leading to l0 + l1 liquidity addition upon crossing the tick boundary. In the end, l1 + l0 + l1 liquidity is added due to mining and crossing, as two ticks become identical.

// VULNERABLE: incorrect nearestCurrentTick derivation
function _getNearestTick(
    IPool pool,
    int24 tickSpacing
) internal view returns (int24 nearestTick) {
    (, int24 currentTick, , , , , ) = pool.slot0();
    // BUG: subtracts 1 unconditionally, causing boundary collision
    nearestTick = (currentTick / tickSpacing) * tickSpacing - tickSpacing;
}

// CORRECT: use modulo to determine exact boundary alignment
function _getNearestTickSafe(
    IPool pool,
    int24 tickSpacing
) internal view returns (int24 nearestTick) {
    (, int24 currentTick, , , , , ) = pool.slot0();
    int24 compressed = currentTick / tickSpacing;
    // If currentTick is negative and not on a spacing boundary, round down
    if (currentTick < 0 && currentTick % tickSpacing != 0) compressed--;
    nearestTick = compressed * tickSpacing;
}

Fee Growth Overflow: The Intentional Wrapping

When operations need to calculate Uniswap V3 position’s fee growth, the implementation uses similar functions to those implemented by Uniswap V3. However, according to a known Uniswap issue, the contract implicitly relies on underflow/overflow when calculating the fee growth; if underflow is prevented, some operations that rely on fee growth will revert.

This is a critical footgun for forks compiled with Solidity ^0.8.x, where checked arithmetic is the default. The getFeeGrowthInside function must be wrapped in an unchecked block:

// VULNERABLE in Solidity >=0.8.0 without unchecked
function getFeeGrowthInsideVulnerable(
    mapping(int24 => Tick.Info) storage ticks,
    int24 tickLower,
    int24 tickUpper,
    int24 tickCurrent,
    uint256 feeGrowthGlobal0X128,
    uint256 feeGrowthGlobal1X128
) internal view returns (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) {
    Tick.Info storage lower = ticks[tickLower];
    Tick.Info storage upper = ticks[tickUpper];

    uint256 feeGrowthBelow0X128;
    uint256 feeGrowthBelow1X128;
    if (tickCurrent >= tickLower) {
        feeGrowthBelow0X128 = lower.feeGrowthOutside0X128;
        feeGrowthBelow1X128 = lower.feeGrowthOutside1X128;
    } else {
        // REVERTS in 0.8.x: underflow is expected here
        feeGrowthBelow0X128 = feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128;
        feeGrowthBelow1X128 = feeGrowthGlobal1X128 - lower.feeGrowthOutside1X128;
    }
    // ... similar pattern for feeGrowthAbove
}

// CORRECT: wrap the entire body
function getFeeGrowthInside(/* same params */)
    internal view
    returns (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128)
{
    unchecked {
        // All arithmetic here intentionally wraps on overflow/underflow
        // The difference (feeGrowthGlobal - outside) is always ≥ 0 in
        // mathematical terms, but may wrap in uint256 representation.
        // ...
    }
}

Any CLMM fork compiled on Solidity >=0.8.0 that does not audit every subtraction in getFeeGrowthInside will see permanent DoS on fee collection for any position that was active when a global counter wrapped.


2. Position NFT Ownership and Transfer Risks

Because each concentrated liquidity position is unique—defined by its specific price range, fee tier, and token pair—it is non-fungible. The standard, interchangeable ERC-20 LP tokens used in V1 and V2 were no longer suitable. Consequently, Uniswap V3 uses ERC-721 NFTs to represent ownership of these unique positions.

This architectural decision creates an entirely new ownership attack surface.

The Approval Attack

ERC-721’s approve and setApprovalForAll semantics operate on the NFT, not on an individual action. Any approved operator can call collect(), decreaseLiquidity(), and ultimately burn() on a position. A common mistake in wrapper contracts is storing approval without scoping it:

// VULNERABLE: grants open-ended approval to an untrusted manager
contract VaultWrapper {
    INonfungiblePositionManager public immutable nfpm;

    function depositPosition(uint256 tokenId) external {
        nfpm.transferFrom(msg.sender, address(this), tokenId);
        // BUG: setApprovalForAll persists beyond this deposit
        // Any future address that gets approved can drain all positions
        nfpm.setApprovalForAll(address(externalStrategy), true);
    }
}

// CORRECT: use per-token approval, revoke after use
contract VaultWrapperSafe {
    INonfungiblePositionManager public immutable nfpm;

    function depositAndMigrate(uint256 tokenId, address strategy) external {
        nfpm.transferFrom(msg.sender, address(this), tokenId);
        // Approve only for this specific tokenId
        nfpm.approve(strategy, tokenId);
        IStrategy(strategy).migrate(tokenId);
        // Revoke after migration
        nfpm.approve(address(0), tokenId);
    }
}

onERC721Received Reentrancy

Since LP positions are ERC-721 NFTs, developers must interact with the NonfungiblePositionManager contract. This contract handles the logic for minting new positions, adding or removing liquidity from existing positions, and collecting accrued fees.

The safeTransferFrom call on ERC-721 invokes onERC721Received on the recipient. A malicious recipient contract can reenter the sending protocol before any state is committed:

// Attacker contract exploiting onERC721Received callback
contract ReentrantReceiver is IERC721Receiver {
    IVault private victim;
    uint256 private tokenId;

    function attack(address vault, uint256 _tokenId) external {
        tokenId = _tokenId;
        victim = IVault(vault);
        // Trigger safeTransferFrom, which calls back into onERC721Received
        IERC721(address(victim.nfpm())).safeTransferFrom(
            address(this), vault, _tokenId
        );
    }

    function onERC721Received(
        address, address, uint256, bytes calldata
    ) external override returns (bytes4) {
        // At this point, victim.deposits[tokenId] may not yet be written
        // Reenter to claim fees before ownership is recorded
        victim.collectFees(tokenId);
        return this.onERC721Received.selector;
    }
}

Mitigation: Follow checks-effects-interactions rigorously. Update deposits[tokenId] before the safeTransferFrom call, or use a reentrancy guard on any function callable via onERC721Received.

Stale Operator After Transfer

The NonfungiblePositionManager stores an operator field per tokenId that persists across transfers. A position sold or transferred on a secondary market may arrive with a pre-authorized operator who can still call collect():

// VULNERABLE: protocol trusts operator field without freshness check
function collectForOperator(uint256 tokenId) external {
    (
        ,
        address operator,
        , , , , , , , , ,
    ) = nfpm.positions(tokenId);
    require(msg.sender == operator, "not operator");
    // BUG: operator was set by the previous owner and was never cleared
    nfpm.collect(/* ... */);
}

Any system that caches or relies on the operator field must invalidate it on every transfer. The NonfungiblePositionManager itself clears the approval slot in _transfer, but third-party wrapper contracts that cache operator state do not.


3. The collect() Function and Fee Accounting

Two-Step Fee Materialisation

The design separates accounting concerns: burning liquidity merely records the amounts owed to a provider, while a separate collect operation actually transfers the tokens.

This separation is intentional—it prevents reentrancy during liquidity removal—but it means that tokensOwed0 and tokensOwed1 in a position struct are a liability claim that can be inflated, stolen, or double-counted.

// Simplified position fee accounting (illustrative)
struct PositionInfo {
    uint128 liquidity;
    uint256 feeGrowthInside0LastX128;
    uint256 feeGrowthInside1LastX128;
    uint128 tokensOwed0;  // Accumulated but uncollected
    uint128 tokensOwed1;
}

function _updatePosition(
    PositionInfo storage position,
    int128 liquidityDelta,
    uint256 feeGrowthInside0X128,
    uint256 feeGrowthInside1X128
) internal {
    unchecked {
        // Fees accrued since last checkpoint
        uint128 feesOwed0 = uint128(
            FullMath.mulDiv(
                feeGrowthInside0X128 - position.feeGrowthInside0LastX128,
                position.liquidity,
                FixedPoint128.Q128
            )
        );
        uint128 feesOwed1 = uint128(
            FullMath.mulDiv(
                feeGrowthInside1X128 - position.feeGrowthInside1LastX128,
                position.liquidity,
                FixedPoint128.Q128
            )
        );

        position.tokensOwed0 += feesOwed0;
        position.tokensOwed1 += feesOwed1;
        position.feeGrowthInside0LastX128 = feeGrowthInside0X128;
        position.feeGrowthInside1LastX128 = feeGrowthInside1X128;
    }
}

The tokensOwed Truncation Vulnerability

The tokensOwed field is uint128. When fees are accumulated using FullMath.mulDiv(feeGrowthDelta, liquidity, Q128), the result is cast to uint128. For extremely large positions or pools with extended fee accumulation, this cast silently truncates. Auditors should verify:

  1. That tokensOwed += feesOwed cannot silently overflow uint128
  2. That collect() caps at uint128.max rather than overflowing to zero
// VULNERABLE: silent uint128 truncation on large fee accumulation
position.tokensOwed0 += uint128(fees0);  // silently wraps if fees0 > type(uint128).max

// CORRECT: use saturating addition or explicit cap
uint256 newOwed0 = uint256(position.tokensOwed0) + fees0;
position.tokensOwed0 = newOwed0 > type(uint128).max
    ? type(uint128).max
    : uint128(newOwed0);

collect() Without decreaseLiquidity() First

A subtle but common integration bug: calling collect() before decreaseLiquidity() collects only the fees already snapshotted in tokensOwed. The pending fees since the last checkpoint are not included until a liquidity event (mint, burn, or swap that crosses the position’s ticks) triggers _updatePosition. Integrators that expect collect() to flush all accumulated fees will silently leave value on the table.


4. Just-in-Time (JIT) Liquidity Attacks

Mechanics

JIT (Just-In-Time) Liquidity Attacks represent a sophisticated MEV extraction strategy unique to Uniswap V3’s concentrated liquidity architecture. These attacks exploit the ability to provide highly concentrated liquidity in narrow price ranges immediately before large swaps execute, capturing disproportionate trading fees before removing liquidity—all within a single transaction block with minimal capital risk exposure.

This design introduces a new type of Miner Extractable Value (MEV) source called Just-in-Time (JIT) liquidity attack, where the adversary mints and burns a liquidity position right before and after a sizable swap.

The attack sequence:

  1. Observe: Monitor public mempool for large swap transactions.
  2. Frontrun: In the same block, immediately before the victim swap, mint a massive concentrated position around the current price.
  3. Capture: The victim swap executes through the JIT position, paying fees proportional to the JIT liquidity relative to total liquidity in range.
  4. Exit: Immediately after the victim swap, burn the position and collect all accumulated fees.

This type of attack is detrimental to existing Liquidity Providers (LPs) within the pool, as their shares of liquidity undergo an average dilution of 85%.

In Q4 2023, JIT activity captured over 15% of all fees on select Uniswap V3 pools, demonstrating the attack’s profitability and scale.

Simulating a JIT Attack On-Chain

// Illustrative JIT attack bundle (simplified, requires block builder access)
contract JITAttacker {
    INonfungiblePositionManager immutable nfpm;
    ISwapRouter immutable router;

    struct JITParams {
        address pool;
        address token0;
        address token1;
        uint24 fee;
        int24 tickLower;
        int24 tickUpper;
        uint256 amount0Desired;
        uint256 amount1Desired;
        bytes victimSwapData; // calldata of the victim's swap
    }

    // Step 1 + 2 + 4 all in one atomically-bundled transaction
    function executeJIT(JITParams calldata p) external {
        // 1. Provide concentrated liquidity
        IERC20(p.token0).approve(address(nfpm), p.amount0Desired);
        IERC20(p.token1).approve(address(nfpm), p.amount1Desired);

        (uint256 tokenId, uint128 liquidity, , ) = nfpm.mint(
            INonfungiblePositionManager.MintParams({
                token0: p.token0,
                token1: p.token1,
                fee: p.fee,
                tickLower: p.tickLower,
                tickUpper: p.tickUpper,
                amount0Desired: p.amount0Desired,
                amount1Desired: p.amount1Desired,
                amount0Min: 0,
                amount1Min: 0,
                recipient: address(this),
                deadline: block.timestamp
            })
        );

        // 2. Victim swap executes here (in the same block, after this tx)
        //    — orchestrated via block builder bundle ordering

        // 3. Burn position and collect all fees
        nfpm.decreaseLiquidity(
            INonfungiblePositionManager.DecreaseLiquidityParams({
                tokenId: tokenId,
                liquidity: liquidity,
                amount0Min: 0,
                amount1Min: 0,
                deadline: block.timestamp
            })
        );

        nfpm.collect(
            INonfungiblePositionManager.CollectParams({
                tokenId: tokenId,
                recipient: msg.sender,
                amount0Max: type(uint128).max,
                amount1Max: type(uint128).max
            })
        );

        nfpm.burn(tokenId);
    }
}

Capital Requirements and Profitability

This attack strategy poses significant entry barriers, as it necessitates adversaries to provide liquidity that is, on average, 269 times greater than the swap volume. Additionally, the JIT liquidity attack exhibits relatively poor profitability, with an average Return On Investment (ROI) of merely 0.007%.

Despite low ROI per attack, over a span of 20 months researchers identified 36,671 such attacks, which collectively generated profits of 7,498 ETH.

Mitigations

Solutions like Uniswap V4 hooks and dynamic fee tiers must be deployed to create economic disincentives and operational friction for attackers. Specific hook-based defenses include:

  • Minimum liquidity duration: Track block.number of mint and revert burn within the same block.
  • Dynamic fee scaling: Increase swap fees when the ratio of new-liquidity to existing-liquidity exceeds a threshold.
  • Commit-reveal schemes: Require a minimum delay between position mint and any fee collection.
// Hook pattern: enforce minimum liquidity duration (Uniswap V4)
contract JITGuardHook is BaseHook {
    mapping(uint256 positionId => uint256 mintBlock) public mintBlock;

    function afterAddLiquidity(
        address,
        PoolKey calldata key,
        IPoolManager.ModifyLiquidityParams calldata params,
        BalanceDelta,
        BalanceDelta,
        bytes calldata
    ) external override onlyPoolManager returns (bytes4, BalanceDelta) {
        uint256 posId = uint256(keccak256(abi.encode(key, params.tickLower, params.tickUpper)));
        mintBlock[posId] = block.number;
        return (BaseHook.afterAddLiquidity.selector, BalanceDeltaLibrary.ZERO_DELTA);
    }

    function beforeRemoveLiquidity(
        address,
        PoolKey calldata key,
        IPoolManager.ModifyLiquidityParams calldata params,
        bytes calldata
    ) external override onlyPoolManager returns (bytes4) {
        uint256 posId = uint256(keccak256(abi.encode(key, params.tickLower, params.tickUpper)));
        require(
            block.number > mintBlock[posId],
            "JITGuard: cannot remove in same block as mint"
        );
        return BaseHook.beforeRemoveLiquidity.selector;
    }
}

5. Liquidity Manipulation Around Price Boundaries

Thin-Tick Sandwich Attacks

While concentrated liquidity makes trading more efficient, it adds complexity. The narrower the price range, the more volatile the price can become if liquidity is thin, potentially opening new doors for manipulation.

When liquidity is concentrated in a single tick spacing, a relatively small swap can push the price across the tick boundary entirely. An attacker can sandwich this transition:

// Conceptual: price-boundary sandwich
// 1. Attacker observes that nearly all liquidity is at tick [T, T+1]
// 2. Attacker swaps a small amount to push price just below T
//    (low cost because liquidity above T is now out of range)
// 3. Victim's swap executes against thin liquidity, suffering high slippage
// 4. Attacker reverses the initial swap at a profit

interface IManipulator {
    function pushPriceAcrossTick(
        address pool,
        bool zeroForOne,
        int256 amountSpecified,
        uint160 sqrtPriceLimitX96
    ) external;
}

The key invariant to check: what is the minimum swap cost to push price out of any single tick? For a pool with liquidity L and tick spacing s, the token quantity required to cross a tick is:

Δtoken0 = L * (1/sqrt(price_lower) - 1/sqrt(price_upper))
Δtoken1 = L * (sqrt(price_upper) - sqrt(price_lower))

Pools with very narrow default tick spacing (e.g., tickSpacing = 1) on illiquid tokens are especially vulnerable because each individual tick holds proportionally less liquidity.

Liquidity Clustering for Pool Takeover

An attacker with sufficient capital can add a dominant liquidity position in a narrow band around the current price, then manipulate that price at will since their position represents the majority of active liquidity:

// Security check: detect dangerous liquidity concentration
function checkLiquidityConcentration(
    IUniswapV3Pool pool,
    int24 tickSpacing,
    uint8 numTicksToCheck
) external view returns (bool isDangerous) {
    (, int24 currentTick, , , , , ) = pool.slot0();
    uint128 totalActiveLiquidity = pool.liquidity();

    uint128 concentratedLiquidity;
    for (int24 i = -int24(numTicksToCheck); i <= int24(numTicksToCheck); i++) {
        int24 tick = currentTick + i * tickSpacing;
        (int128 liquidityNet, , , , , , , ) = pool.ticks(tick);
        if (liquidityNet > 0) {
            concentratedLiquidity += uint128(liquidityNet);
        }
    }

    // Flag if >80% of liquidity sits within numTicksToCheck ticks
    isDangerous = concentratedLiquidity * 100 > totalActiveLiquidity * 80;
}

6. Oracle Manipulation via Concentrated Positions

Spot Price vs TWAP

While the TWAP oracle itself is more robust, the spot price of a V3 pool is easier to manipulate than in V2. Because liquidity is concentrated, the amount of capital required to move the price by a certain percentage is significantly lower.

This increases the risk for other protocols that might incorrectly use the V3 spot price as an oracle, making them more vulnerable to manipulation attacks.

Any protocol that calls slot0() and uses sqrtPriceX96 directly as a price feed is trivially exploitable with a single flash-loan swap on a low-TVL pool.

TWAP Manipulation in Proof-of-Stake

The TWAP oracle accumulates tick * secondsElapsed across observations. Manipulating a TWAP requires holding the price at a false level for multiple blocks. The robustness, resilience, and price accuracy of TWAP oracles highly depend on market conditions, pool liquidity, and liquidity distribution per tick, thus exposing them to market risk and liquidity fluctuations.

Research on Uniswap V3 manipulation risk emphasizes that liquidity distribution matters, not just the headline dollar value in the pool. In concentrated-liquidity AMMs, liquidity can be dense near the current price and sparse farther away, or asymmetrical across directions. An attacker cares about the cheapest path to push price where they need it to go.

Under Ethereum’s proof-of-stake, validator lookahead provides up to a one-block advantage per slot. Neither full-range liquidity nor double-sided limit orders fix the problem if a validator has enough market share. With enough market share, validators could execute twenty to thirty block manipulations, which are cheaper to execute.

Defensive Oracle Integration

// VULNERABLE: reading spot price from slot0
function getPrice_Unsafe(address pool) internal view returns (uint256) {
    (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(pool).slot0();
    // Easily manipulated with a single atomic transaction
    return FullMath.mulDiv(uint256(sqrtPriceX96), uint256(sqrtPriceX96), 2**192);
}

// SAFE: use TWAP with sufficient cardinality and window
function getPrice_Safe(
    address pool,
    uint32 twapWindow
) internal view returns (uint256 arithmeticMeanPrice) {
    require(twapWindow >= 1800, "TWAP window too short"); // min 30 minutes

    uint32[] memory secondsAgos = new uint32[](2);
    secondsAgos[0] = twapWindow;
    secondsAgos[1] = 0;

    (int56[] memory tickCumulatives, ) =
        IUniswapV3Pool(pool).observe(secondsAgos);

    int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0];
    int24 arithmeticMeanTick = int24(tickCumulativesDelta / int56(int32(twapWindow)));
    // Round towards negative infinity for int24
    if (tickCumulativesDelta < 0 && (tickCumulativesDelta % int56(int32(twapWindow)) != 0)) {
        arithmeticMeanTick--;
    }

    uint160 sqrtRatioX96 = TickMath.getSqrtRatioAtTick(arithmeticMeanTick);
    arithmeticMeanPrice = FullMath.mulDiv(
        uint256(sqrtRatioX96),
        uint256(sqrtRatioX96),
        2**192
    );
}

Also verify that the oracle cardinality has been initialized to support the desired lookback window:

function ensureOracleCardinality(address pool, uint16 requiredCardinality) external {
    IUniswapV3Pool(pool).increaseObservationCardinalityNext(requiredCardinality);
}

Calling observe() with a secondsAgo value longer than the oldest initialized observation will revert. Protocols must ensure cardinality is bootstrapped before going live.


7. Uniswap V4 Hooks: New Security Implications

The security model shifts dramatically from trusting a single, monolithic protocol to a more complex, shared responsibility model. Hooks are external calls from the core PoolManager, which inherently introduces new vectors for attack if not developed with extreme care.

Reentrancy Through Hook Callbacks

The very nature of hooks reintroduces the risk of reentrancy attacks, a class of vulnerability that was largely solved in the core contracts of V2 and V3. A hook that makes an external call to an untrusted contract before its own state is fully updated could be exploited to drain funds or manipulate pool state.

A malicious hook can reenter the PoolManager before its state is finalized, creating the same class of vulnerability that the DAO hack exploited in 2016 — but now embedded in the AMM infrastructure itself. Hooks must follow CEI rigorously, and the PoolManager enforces a reentrancy lock that hook developers must understand and respect.


Concentrated Liquidity Audit Checklist

Position and range arithmetic

  • Tick math uses the correct signed integer handling — underflow and overflow in tick arithmetic are checked
  • Liquidity amounts are validated: zero-liquidity positions cannot be opened or used in fee calculations
  • Fee growth tracking (feeGrowthInside) is computed correctly at position creation, modification, and closure
  • Rounding direction in fee and liquidity calculations consistently favors the protocol

Oracle integration

  • No protocol reads slot0 directly for pricing — TWAP via observe() is used
  • TWAP window is long enough given the pool’s liquidity depth and the value at stake
  • The observation cardinality of the pool has been initialized to a sufficient value before any TWAP is consumed

Tick and range boundary conditions

  • Positions at the minimum and maximum tick are handled correctly
  • The protocol correctly handles the case where the current tick is outside all active ranges (zero liquidity)
  • Position transitions across tick boundaries update all relevant state atomically

Uniswap V4 hooks (if applicable)

  • Hook callbacks follow CEI — no external call before state is finalized
  • The hook does not assume a specific ordering of other hooks in the same pool
  • Hook permissions are minimally scoped — only the callbacks required for the hook’s function are enabled
  • Hook contract has been audited independently from the pool logic
  • The hook cannot manipulate PoolManager state in ways the LP did not consent to

Flash accounting (V4)

  • All flash deltas are settled within the same transaction — no open delta at transaction end
  • The unlock callback cannot be reentered in a way that creates inconsistent delta state
  • Currency balances are checked after all operations complete, not at intermediate steps