Uniswap V1 to V4: The Evolution of Decentralized Exchanges

A deep dive into Uniswap’s evolution, exploring AMM, concentrated liquidity, and hooks.

Introduction

Uniswap has revolutionized decentralized trading since its inception in 2018. In this post, we’ll explore the evolution from V1 to V4, understanding what problems each version solved and how they improved upon their predecessors.

TradFi VS DeFi

In Traditional Finance (TradFi), trading relies on order books—a centralized ledger that matches buy orders (bids) with sell orders (asks). When you want to buy 1 ETH at $2,000, your order sits in the book until a seller willing to accept that price appears. This system requires:

  • Market makers to provide liquidity and tight spreads
  • Centralized infrastructure to maintain and match orders
  • Counterparties for every trade

This model faces significant challenges on-chain:

  • Gas costs: Every order placement, modification, and cancellation requires a transaction
  • Latency: Block times (12+ seconds on Ethereum) make real-time order matching impractical
  • Front-running: Miners/validators can exploit pending orders

Decentralized Finance (DeFi) solves this with Automated Market Makers (AMMs). Instead of matching orders, AMMs use:

  • Liquidity pools: Token reserves provided by anyone (Liquidity Providers)
  • Mathematical formulas: Algorithmic price determination based on pool ratios
  • Instant execution: Trade against the pool, not against other users

The AMM model eliminates the need for counterparties, order matching, and market makers—enabling truly permissionless, 24/7 trading with guaranteed liquidity.


Uniswap V1

GitHub Repository: https://github.com/Uniswap/v1-contracts

Uniswap V1 was the pioneer of AMMs on Ethereum, proving that automated market making could work on-chain.

CPMM

Uniswap V1 introduced the Constant Product Market Maker (CPMM) formula, revolutionizing on-chain trading by eliminating the need for order books.

\[x \cdot y = k\]

Where:

  • x = reserve of ETH
  • y = reserve of Token
  • k = constant product (invariant)

Why This Design?

Traditional order book exchanges require matching buyers with sellers, which is expensive on-chain due to gas costs and latency. The CPMM formula allows instant trades at any size—the price is determined algorithmically based on the ratio of reserves.

Example: If a pool has 10 ETH and 1000 DAI (k = 10,000), and you want to buy ETH:

  • Before: 10 ETH, 1000 DAI
  • You add 100 DAI
  • New state must maintain k: x * 1100 = 10000x ≈ 9.09 ETH
  • You receive: 10 - 9.09 = 0.91 ETH

Code

// Uniswap V1 Exchange Contract (Vyper)
@public
def getInputPrice(input_amount: uint256, input_reserve: uint256, output_reserve: uint256) -> uint256:
    assert input_reserve > 0 and output_reserve > 0
    input_amount_with_fee: uint256 = input_amount * 997  # 0.3% fee
    numerator: uint256 = input_amount_with_fee * output_reserve
    denominator: uint256 = (input_reserve * 1000) + input_amount_with_fee
    return numerator / denominator

Limitations

  1. ETH as mandatory base pair: Every token must pair with ETH, causing inefficient routing (e.g., DAI→ETH→USDC requires two swaps)
  2. No price oracles: Susceptible to flash loan manipulation
  3. Single fee tier: 0.3% for all pairs, regardless of volatility

Uniswap V2

GitHub Repository: https://github.com/Uniswap/v2-core

Improvements

1. Direct ERC20-ERC20 Pairs

V2 eliminated the ETH requirement, allowing direct token-to-token swaps.

Why? This reduces gas costs and slippage. Instead of DAI→ETH→USDC (two hops), you can do DAI→USDC directly if the pair exists.

// V2 Pair Creation - Any ERC20 to ERC20
function createPair(address tokenA, address tokenB) external returns (address pair) {
    require(tokenA != tokenB, 'IDENTICAL_ADDRESSES');
    (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
    require(token0 != address(0), 'ZERO_ADDRESS');
    require(getPair[token0][token1] == address(0), 'PAIR_EXISTS');
    
    bytes memory bytecode = type(UniswapV2Pair).creationCode;
    bytes32 salt = keccak256(abi.encodePacked(token0, token1));
    assembly {
        pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
    }
    // ...
}

2. Time-Weighted Average Price (TWAP) Oracle

V2 introduced manipulation-resistant price oracles by accumulating prices over time.

Why TWAP? Spot prices can be manipulated within a single block via flash loans. TWAP averages prices over time, making manipulation expensive.

// Price accumulator updated every block
function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {
    uint32 blockTimestamp = uint32(block.timestamp % 2**32);
    uint32 timeElapsed = blockTimestamp - blockTimestampLast;
    
    if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
        // Accumulate price * time
        price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
        price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
    }
    blockTimestampLast = blockTimestamp;
}

3. Flash Swaps

Borrow assets without upfront collateral, as long as you return them (or equivalent value) within the same transaction.

function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external {
    // Optimistically transfer tokens
    if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
    if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);
    
    // Callback for flash swap
    if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
    
    // Verify invariant is maintained
    uint balance0 = IERC20(token0).balanceOf(address(this));
    uint balance1 = IERC20(token1).balanceOf(address(this));
    require(balance0 * balance1 >= reserve0 * reserve1, 'K');
}

Limitations

  1. Capital inefficiency: Liquidity spread across entire price range (0 to ∞)
  2. One-size-fits-all fees: Still only 0.3%, unsuitable for all asset types

Uniswap V3

GitHub Repository: https://github.com/Uniswap/v3-core

Improvements

Concentrated Liquidity

V3’s revolutionary innovation allows LPs to provide liquidity within custom price ranges, dramatically improving capital efficiency.

Why Concentrated Liquidity?

In V2, if you provide $10,000 liquidity to ETH/USDC, your capital is spread from price $0 to $\infty$. In reality, ETH might only trade between $1,500-$2,500 for months. Most of your capital sits unused!

V3 lets you concentrate that $10,000 in the $1,500-$2,500 range, effectively providing ~4000x more liquidity at those prices.

Tick-Based Architecture

V3 divides the price space into discrete “ticks.” Each tick represents a 0.01% price change.

// Tick spacing determines granularity
// Fee tier 0.05% → tick spacing 10
// Fee tier 0.30% → tick spacing 60  
// Fee tier 1.00% → tick spacing 200

struct Position {
    uint128 liquidity;
    uint256 feeGrowthInside0LastX128;
    uint256 feeGrowthInside1LastX128;
    uint128 tokensOwed0;
    uint128 tokensOwed1;
}

function mint(
    address recipient,
    int24 tickLower,    // Lower bound of position
    int24 tickUpper,    // Upper bound of position
    uint128 amount,
    bytes calldata data
) external returns (uint256 amount0, uint256 amount1) {
    require(tickLower < tickUpper, 'TLU');
    require(tickLower >= TickMath.MIN_TICK, 'TLM');
    require(tickUpper <= TickMath.MAX_TICK, 'TUM');
    // ...
}

Multiple Fee Tiers

V3 introduced multiple fee tiers to match different pair characteristics:

Fee Tier Best For Example Pairs
0.01% Stable pairs USDC/USDT
0.05% Correlated assets ETH/stETH
0.30% Standard pairs ETH/USDC
1.00% Exotic/volatile SHIB/ETH

Why? Stable pairs have minimal impermanent loss, so LPs accept lower fees. Volatile pairs need higher fees to compensate for IL risk.

Swap Execution

Swaps in V3 may cross multiple ticks, with each tick potentially having different liquidity.

function swap(
    address recipient,
    bool zeroForOne,
    int256 amountSpecified,
    uint160 sqrtPriceLimitX96,
    bytes calldata data
) external returns (int256 amount0, int256 amount1) {
    // Iterate through ticks until swap is complete
    while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) {
        StepComputations memory step;
        step.sqrtPriceStartX96 = state.sqrtPriceX96;
        
        // Find next initialized tick
        (step.tickNext, step.initialized) = tickBitmap.nextInitializedTickWithinOneWord(...);
        
        // Compute swap step
        (state.sqrtPriceX96, step.amountIn, step.amountOut, step.feeAmount) = 
            SwapMath.computeSwapStep(...);
        
        // Cross tick if necessary (update liquidity)
        if (state.sqrtPriceX96 == step.sqrtPriceNextX96) {
            if (step.initialized) {
                int128 liquidityNet = ticks.cross(step.tickNext, ...);
                state.liquidity = LiquidityMath.addDelta(state.liquidity, liquidityNet);
            }
        }
    }
}

Limitations

  1. Gas costs: Each pool deployment is separate, cross-pool routing is expensive
  2. Limited customization: Protocol-defined fee tiers only
  3. No native hooks: Can’t extend functionality without forking

Uniswap V4

GitHub Repository: https://github.com/Uniswap/v4-core

Revolutionary Changes

1. Singleton Contract

All pools live in a single contract, eliminating the factory pattern.

Why Singleton?

In V3, creating a pool deploys a new contract (~4.5M gas). Multi-hop swaps require multiple external calls. V4’s singleton reduces:

  • Pool creation: ~98% gas savings
  • Multi-hop routing: Single contract, internal accounting
contract PoolManager is IPoolManager {
    mapping(PoolId => Pool.State) public pools;
    
    // All pools managed by one contract
    function initialize(PoolKey memory key, uint160 sqrtPriceX96) external returns (int24 tick) {
        PoolId id = key.toId();
        pools[id].initialize(sqrtPriceX96);
        return pools[id].slot0.tick;
    }
}

2. Flash Accounting

Instead of transferring tokens for each operation, V4 tracks deltas internally and settles at the end.

Why Flash Accounting?

Consider a ETH→USDC→DAI swap:

  • V3: Transfer ETH in → Receive USDC → Transfer USDC in → Receive DAI (4 transfers)
  • V4: Track deltas internally → Single settlement (2 transfers)
// V4 uses transient storage (EIP-1153) for flash accounting
function swap(PoolKey memory key, SwapParams memory params) external returns (BalanceDelta) {
    BalanceDelta delta = pools[key.toId()].swap(...);
    
    // Update caller's balance delta (no actual transfer yet)
    _accountDelta(key.currency0, delta.amount0());
    _accountDelta(key.currency1, delta.amount1());
    
    return delta;
}

// Settlement happens at the end
function settle(Currency currency) external payable returns (uint256) {
    uint256 paid = currency.balanceOfSelf() - reservesOf[currency];
    _accountDelta(currency, -paid.toInt128());
    return paid;
}

3. Hooks: The Plugin System

Hooks are the most powerful feature—custom code that executes at specific points in pool lifecycle.

Why Hooks?

Previously, adding features like dynamic fees, TWAMM, or limit orders required forking Uniswap. Hooks enable:

  • Composability: Build on Uniswap without forking
  • Innovation: Anyone can create new pool behaviors
  • Flexibility: Different pools can have different logic
abstract contract BaseHook is IHooks {
    // Hook permissions encoded in contract address
    function getHookPermissions() public pure virtual returns (Hooks.Permissions memory);
    
    // Lifecycle hooks
    function beforeInitialize(address, PoolKey calldata, uint160) external virtual returns (bytes4);
    function afterInitialize(address, PoolKey calldata, uint160, int24) external virtual returns (bytes4);
    function beforeAddLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, bytes calldata) external virtual returns (bytes4);
    function afterAddLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, BalanceDelta, bytes calldata) external virtual returns (bytes4, BalanceDelta);
    function beforeSwap(address, PoolKey calldata, SwapParams calldata, bytes calldata) external virtual returns (bytes4, BeforeSwapDelta, uint24);
    function afterSwap(address, PoolKey calldata, SwapParams calldata, BalanceDelta, bytes calldata) external virtual returns (bytes4, int128);
}

Hook Examples

Dynamic Fee Hook — Adjust fees based on volatility:

contract VolatilityFeeHook is BaseHook {
    function beforeSwap(
        address,
        PoolKey calldata key,
        IPoolManager.SwapParams calldata,
        bytes calldata
    ) external override returns (bytes4, BeforeSwapDelta, uint24) {
        uint24 dynamicFee = calculateFeeFromVolatility(key);
        // Return dynamic fee (overrides pool's base fee)
        return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, dynamicFee | LPFeeLibrary.OVERRIDE_FEE_FLAG);
    }
}

Limit Order Hook — Enable limit orders on AMM:

contract LimitOrderHook is BaseHook {
    mapping(PoolId => mapping(int24 => Order[])) public orders;
    
    function afterSwap(
        address,
        PoolKey calldata key,
        IPoolManager.SwapParams calldata,
        BalanceDelta,
        bytes calldata
    ) external override returns (bytes4, int128) {
        int24 currentTick = poolManager.getSlot0(key.toId()).tick;
        // Execute any limit orders that crossed
        _executeCrossedOrders(key, currentTick);
        return (this.afterSwap.selector, 0);
    }
}

4. Native ETH Support

V4 supports native ETH directly, avoiding WETH wrapping/unwrapping costs.

// Native ETH identified by address(0)
PoolKey memory key = PoolKey({
    currency0: Currency.wrap(address(0)),  // Native ETH
    currency1: Currency.wrap(address(USDC)),
    fee: 3000,
    tickSpacing: 60,
    hooks: IHooks(address(0))
});

Conclusion

Uniswap’s evolution showcases the rapid innovation in DeFi:

  • V1 proved AMMs work on-chain
  • V2 generalized the model with ERC20 pairs and oracles
  • V3 revolutionized capital efficiency with concentrated liquidity
  • V4 opens infinite possibilities with hooks and singleton architecture

Each version solved real problems faced by traders and liquidity providers, making decentralized trading more efficient, flexible, and powerful.

Evolution Summary

Feature V1 V2 V3 V4
AMM Model x·y=k x·y=k Concentrated Concentrated
Pairs ETH-ERC20 Any ERC20 Any ERC20 Any ERC20 + Native ETH
Fee Tiers 0.3% 0.3% 0.01/0.05/0.3/1% Dynamic via hooks
Price Oracle None TWAP Improved TWAP Via hooks
Architecture Factory Factory Factory Singleton
Extensibility None None Limited Hooks
Flash Features None Flash Swaps Flash Swaps Flash Accounting

Further Reading: