AMMs & Liquidity Pools

Difficulty: Intermediate

Automated Market Makers (AMMs) are the backbone of decentralized exchanges (DEXs) like Uniswap, SushiSwap, and PancakeSwap. Unlike traditional order book exchanges where buyers and sellers place limit orders that match against each other, AMMs use mathematical formulas and liquidity pools to enable trustless, permissionless token swaps. Anyone can trade at any time without needing a counterparty, and anyone can earn fees by providing liquidity.

The most common AMM model is the Constant Product Market Maker, pioneered by Uniswap. It uses the formula x * y = k, where x and y are the reserves of two tokens in a pool, and k is a constant that must be maintained (or increased) after every trade. When a user swaps token A for token B, they add token A to the pool (increasing x) and receive token B from the pool (decreasing y), such that the product x * y remains equal to k. This simple formula automatically adjusts prices based on supply and demand: as one token is bought, its price increases because fewer of it remain in the pool.

Liquidity providers (LPs) deposit equal values of both tokens into a pool and receive LP tokens representing their proportional share of the pool. When traders swap, they pay a fee (typically 0.3% on Uniswap V2), which is added to the pool reserves. LPs earn these fees proportionally to their share of the pool. When LPs withdraw, they burn their LP tokens and receive their share of both tokens plus accumulated fees.

Impermanent loss is the key risk for liquidity providers. It occurs when the price ratio of the pooled tokens changes from the ratio at the time of deposit. The name is misleading because the loss becomes permanent if the LP withdraws while prices have diverged. For example, if you provide ETH/USDC liquidity and ETH doubles in price, the AMM formula automatically sells some of your ETH for USDC (to maintain the constant product). You end up with less ETH and more USDC than if you had simply held. The 'loss' is the difference between what you have in the pool versus what you would have had by just holding the tokens. Fees earned can offset impermanent loss, but in volatile markets, the loss often exceeds fee income.

Advanced AMM designs attempt to improve capital efficiency. Uniswap V3 introduced concentrated liquidity, where LPs specify a price range for their liquidity, earning higher fees within that range but nothing outside it. Curve Finance uses a modified formula (StableSwap invariant) optimized for assets that should trade near parity (like USDC/USDT), providing much lower slippage for stablecoin swaps. Balancer extends the constant product formula to support weighted pools with more than two tokens.

Code examples

Simple Constant Product AMM

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract SimpleAMM is ERC20 {
    IERC20 public immutable tokenA;
    IERC20 public immutable tokenB;

    uint256 public reserveA;
    uint256 public reserveB;

    uint256 public constant FEE_NUMERATOR = 3;    // 0.3% fee
    uint256 public constant FEE_DENOMINATOR = 1000;

    event LiquidityAdded(address indexed provider, uint256 amountA, uint256 amountB, uint256 lpTokens);
    event LiquidityRemoved(address indexed provider, uint256 amountA, uint256 amountB, uint256 lpTokens);
    event Swap(address indexed trader, address tokenIn, uint256 amountIn, uint256 amountOut);

    constructor(address _tokenA, address _tokenB)
        ERC20("AMM LP Token", "LP")
    {
        tokenA = IERC20(_tokenA);
        tokenB = IERC20(_tokenB);
    }

    // Add liquidity - must provide both tokens in current ratio
    function addLiquidity(uint256 amountA, uint256 amountB)
        external
        returns (uint256 lpTokens)
    {
        tokenA.transferFrom(msg.sender, address(this), amountA);
        tokenB.transferFrom(msg.sender, address(this), amountB);

        if (totalSupply() == 0) {
            // First LP: mint sqrt(amountA * amountB) shares
            lpTokens = sqrt(amountA * amountB);
        } else {
            // Subsequent LPs: mint proportional to smaller ratio
            uint256 lpA = (amountA * totalSupply()) / reserveA;
            uint256 lpB = (amountB * totalSupply()) / reserveB;
            lpTokens = lpA < lpB ? lpA : lpB;
        }

        require(lpTokens > 0, "Insufficient liquidity minted");
        _mint(msg.sender, lpTokens);

        reserveA += amountA;
        reserveB += amountB;

        emit LiquidityAdded(msg.sender, amountA, amountB, lpTokens);
    }

    // Remove liquidity - burn LP tokens, receive both tokens
    function removeLiquidity(uint256 lpTokens)
        external
        returns (uint256 amountA, uint256 amountB)
    {
        require(lpTokens > 0, "Must burn LP tokens");

        amountA = (lpTokens * reserveA) / totalSupply();
        amountB = (lpTokens * reserveB) / totalSupply();

        _burn(msg.sender, lpTokens);

        reserveA -= amountA;
        reserveB -= amountB;

        tokenA.transfer(msg.sender, amountA);
        tokenB.transfer(msg.sender, amountB);

        emit LiquidityRemoved(msg.sender, amountA, amountB, lpTokens);
    }

    // Swap tokenA for tokenB
    function swapAForB(uint256 amountAIn) external returns (uint256 amountBOut) {
        require(amountAIn > 0, "Amount must be > 0");

        // Apply fee: effective input = amountIn * (1 - fee)
        uint256 amountAInWithFee = amountAIn * (FEE_DENOMINATOR - FEE_NUMERATOR);

        // Constant product: (reserveA + amountIn) * (reserveB - amountOut) = reserveA * reserveB
        // Solving for amountOut:
        // amountOut = (amountInWithFee * reserveB) / (reserveA * 1000 + amountInWithFee)
        amountBOut = (amountAInWithFee * reserveB) /
            (reserveA * FEE_DENOMINATOR + amountAInWithFee);

        require(amountBOut > 0, "Insufficient output");

        tokenA.transferFrom(msg.sender, address(this), amountAIn);
        tokenB.transfer(msg.sender, amountBOut);

        reserveA += amountAIn;
        reserveB -= amountBOut;

        emit Swap(msg.sender, address(tokenA), amountAIn, amountBOut);
    }

    // Swap tokenB for tokenA
    function swapBForA(uint256 amountBIn) external returns (uint256 amountAOut) {
        require(amountBIn > 0, "Amount must be > 0");

        uint256 amountBInWithFee = amountBIn * (FEE_DENOMINATOR - FEE_NUMERATOR);
        amountAOut = (amountBInWithFee * reserveA) /
            (reserveB * FEE_DENOMINATOR + amountBInWithFee);

        require(amountAOut > 0, "Insufficient output");

        tokenB.transferFrom(msg.sender, address(this), amountBIn);
        tokenA.transfer(msg.sender, amountAOut);

        reserveB += amountBIn;
        reserveA -= amountAOut;

        emit Swap(msg.sender, address(tokenB), amountBIn, amountAOut);
    }

    // Get quote: how many tokenB for a given amountA?
    function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut)
        public
        pure
        returns (uint256)
    {
        uint256 amountInWithFee = amountIn * (FEE_DENOMINATOR - FEE_NUMERATOR);
        return (amountInWithFee * reserveOut) /
            (reserveIn * FEE_DENOMINATOR + amountInWithFee);
    }

    // Get current price of tokenA in terms of tokenB
    function getPriceAinB() external view returns (uint256) {
        return (reserveB * 1e18) / reserveA;
    }

    function sqrt(uint256 x) internal pure returns (uint256 y) {
        if (x == 0) return 0;
        uint256 z = (x + 1) / 2;
        y = x;
        while (z < y) {
            y = z;
            z = (x / z + z) / 2;
        }
    }
}

This AMM implements the x*y=k constant product formula. LP tokens represent proportional pool ownership. Swaps deduct a 0.3% fee that remains in the pool, benefiting LPs. The getAmountOut function calculates swap output accounting for fees.

Key points

Concepts covered

AMM, Constant Product, Liquidity Pool, Impermanent Loss