DEV Community

metadevdigital
metadevdigital

Posted on

How Uniswap V3 Concentrated Liquidity Works Under the Hood

How Uniswap V3 Concentrated Liquidity Works Under the Hood

cover

The Database Index Analogy

Traditional databases don't scan every row—they use indexes on specific columns to narrow the search space. Uniswap V2 was a full table scan: liquidity providers dumped capital across the entire price range (0 to infinity) and almost none of it got used. V3 introduced concentrated liquidity, which works exactly like a database index. You specify a price range where you want liquidity active, and all your capital actually does work in that range instead of rotting across price points that never trade.

This simple idea blew up the entire AMM architecture at the contract level.

How V2 Worked (The Inefficient Baseline)

In V2, you deposit 100 ETH and 100,000 USDC. The protocol enforces:

x * y = k
Enter fullscreen mode Exit fullscreen mode

Your capital was available at every possible price. Most of it sat idle. If ETH/USDC trades at $1,000, the pool has liquidity sitting at $10 and $10,000 where it'll never move. Keeping a database index for queries that never run is exactly this stupid.

V3's Concentrated Liquidity: The Price Range Revolution

V3 lets you specify a lower tick (tickLower) and upper tick (tickUpper). That's it—only that range is active. The key part: you need 100x less capital to provide the same depth in a concentrated range. But the protocol tracks this completely differently.

Instead of one global k, V3 uses per-range virtual reserves.

How It Works: Ticks and Liquidity

Uniswap V3 chops prices into "ticks"—each tick is a 0.01% price movement at the standard 0.01% fee tier.

// Simplified tick structure
struct TickInfo {
    int128 liquidityNet;  // net liquidity added/removed at this tick
    uint256 feeGrowthOutside0X128;  // fee tracking
    uint256 feeGrowthOutside1X128;
}

// User provides liquidity between tickLower and tickUpper
mapping(int24 => TickInfo) public ticks;
Enter fullscreen mode Exit fullscreen mode

When you deposit, the protocol records liquidity entering at tickLower and exiting at tickUpper. As price moves, the protocol tracks which ticks got crossed. Only the ticks between current price and your position boundaries count toward your active liquidity.

Calculating Virtual Reserves

This is where the database analogy clicks. V3 doesn't maintain one pool reserve—it maintains the current sqrtPriceX96 and an aggregated liquidity that changes as price moves through ticks.

When a swap executes, the protocol asks: what liquidity exists in the current price range?

// Pseudocode for how Uniswap V3 calculates output
const currentTick = Math.floor(Math.log(price) / Math.log(1.0001));
const activeTickLiquidity = sumLiquidityInRange(currentTick);

// Now calculate swap using constant product, but with this liquidity only
const outputAmount = calculateSwapOutput(activeTickLiquidity, inputAmount);
Enter fullscreen mode Exit fullscreen mode

When price crosses a tick, liquidity from positions with boundaries at that tick gets added or removed. You're not tracking one global reserve—you're dynamically summing liquidity based on active ticks.

The Real-World Case: The 2021 Arbitrage Wars

May 2021. Arbitrageurs exploited concentrated liquidity mechanics like vultures. Someone provided liquidity between ticks 190,000 and 192,000 (roughly ETH/USDC range), and a flash loan attack extracted their fees by swinging price through those ticks during the same transaction.

The attack worked because the entire capital was concentrated in one narrow band—when price ripped through those ticks, their liquidity got eaten by larger swaps and the attacker captured the fee spread. Concentration gives you capital efficiency but doubles your impermanent loss risk.

Tracking Fees Per Range

V3 doesn't hand out fees globally. It tracks fee growth (feeGrowthOutside and feeGrowthInside) per tick.

function collectFees(
    address recipient,
    int24 tickLower,
    int24 tickUpper,
    uint128 amount0Requested,
    uint128 amount1Requested
) external returns (uint128 amount0, uint128 amount1) {
    // Calculate fee growth that occurred *inside* this position's range
    uint256 feeGrowthInside0 = calculateFeeGrowthInside(
        tickLower, 
        tickUpper
    );

    // Your share = feeGrowthInside * your liquidity
    uint256 owed0 = (feeGrowthInside0 * positionLiquidity) / (2**128);

    // Transfer fees to recipient
    // ...
}
Enter fullscreen mode Exit fullscreen mode

When you claim fees, the protocol calculates how much fee growth happened inside your range and multiplies by your liquidity. Straightforward once you understand the tick structure.

The Overhead You Don't See

Maintaining per-tick state is expensive. Every swap iterates through ticks, checks boundaries, updates liquidity. V2 was stupid simple: one reserve, distribute fees once. V3 trades simplicity for efficiency—the tradeoff we all learned to live with.

Actually Look at This Yourself

Go to Uniswap's analytics, pick an active position, note the lower and upper bounds. Find the V3 Pool contract on Etherscan. Call liquidity() to see current active liquidity, then tickBitmap() to see which ticks crossed. You're watching the index work.


Top comments (0)