P10: Automated Market Maker (AMM) / Decentralized Exchange

P10: Automated Market Maker (AMM) / Decentralized Exchange

Project Overview

Attribute Value
Main Language Solidity
Alternative Languages Vyper, Rust (for Solana)
Difficulty Expert
Coolness Level Level 5: Production-Ready Innovation
Business Potential Startup-Viable (DeFi Protocol)
Knowledge Area DeFi / Market Making
Main Book “Mastering Ethereum” by Andreas M. Antonopoulos & Gavin Wood

Learning Objectives

By completing this project, you will:

  1. Master the constant product formula (x*y=k) and understand why this simple equation enables trustless, permissionless trading without order books
  2. Implement liquidity provision mechanics including minting LP tokens proportional to contributions, calculating fair shares, and handling the “first liquidity provider” edge case
  3. Calculate and mitigate slippage understanding how trade size affects price impact and how to protect users from frontrunning
  4. Prevent flash swap attacks implementing reentrancy guards, checking invariants post-swap, and understanding the economic exploits that have drained millions
  5. Understand the mathematics of impermanent loss and why liquidity providers may end up with less value than simply holding tokens
  6. Build production-grade smart contracts with comprehensive testing, gas optimization, and security considerations

Deep Theoretical Foundation

The Problem: How Do You Trade Without a Middleman?

In traditional finance, exchanges use order books—lists of buy and sell orders waiting to be matched. The New York Stock Exchange, Coinbase, and Binance all work this way. Someone posts “I’ll buy 100 shares of AAPL at $150,” someone else posts “I’ll sell 100 shares at $150,” and the exchange matches them.

Order books have fundamental problems in decentralized systems:

  1. Gas costs: Every order placement, modification, and cancellation costs gas. On Ethereum, this could be $5-50 per action.
  2. Latency: Blockchain block times (12 seconds on Ethereum) are too slow for market makers who need millisecond execution.
  3. Liquidity fragmentation: Without centralized market makers, liquidity is scattered.
  4. Frontrunning: Miners can see pending orders and trade ahead of them.

Enter the Automated Market Maker (AMM): a smart contract that holds reserves of two tokens and uses a mathematical formula to determine prices. No order book. No matching engine. Just math.

The Constant Product Formula: x * y = k

The core insight of Uniswap (invented by Hayden Adams in 2018) is the constant product invariant:

x * y = k

Where:

  • x = reserve of Token A
  • y = reserve of Token B
  • k = constant (before fees)

Example:

Initial state:
  ETH reserve (x) = 100
  USDC reserve (y) = 300,000
  k = 100 * 300,000 = 30,000,000

Trader wants to buy 1 ETH with USDC:
  New ETH reserve (x') = 99
  Required USDC reserve: y' = k / x' = 30,000,000 / 99 = 303,030.30
  USDC paid by trader: y' - y = 303,030.30 - 300,000 = 3,030.30

Effective price: 3,030.30 USDC per ETH (vs spot price of 3,000)

The larger the trade relative to the pool, the worse the price. This is slippage.

Why Does This Work?

The constant product formula creates a bonding curve that automatically adjusts prices based on supply and demand:

Price of Token A in terms of Token B = y / x

As traders buy A (reducing x), the price goes up.
As traders sell A (increasing x), the price goes down.

This mimics real market dynamics without any central coordinator. Arbitrageurs profit by bringing AMM prices in line with external markets, ensuring the AMM reflects true market prices.

The AMM Price Curve

The relationship between price and reserves follows a hyperbola:

     y (Token B reserve)
     |
     |
     | \
     |  \
     |   \___
     |       ----____
     |              ----____
     +----------------------------- x (Token A reserve)

Key properties:

  1. Infinite depth: You can never fully drain either reserve (asymptotically approaches axes)
  2. Continuous pricing: No gaps or jumps in price
  3. Self-adjusting: Price automatically responds to trades

The Swap Formula

Given input amount dx of Token A, how much Token B (dy) can you receive?

Starting with the invariant:

(x + dx) * (y - dy) = k = x * y

Solving for dy:

dy = y * dx / (x + dx)

With a 0.3% fee (like Uniswap V2):

dx_after_fee = dx * 997 / 1000
dy = y * dx_after_fee / (x + dx_after_fee)

The fee goes to liquidity providers as reward for supplying capital.

Liquidity Provision and LP Tokens

When you add liquidity to a pool, you receive LP (Liquidity Provider) tokens representing your share of the pool.

Initial liquidity (first depositor):

LP_tokens_minted = sqrt(x * y) - MINIMUM_LIQUIDITY

The MINIMUM_LIQUIDITY (typically 1000 wei) is permanently locked to prevent manipulation of empty pools.

Subsequent liquidity (pool already exists):

LP_tokens_minted = min(
    (amount_x * total_supply) / reserve_x,
    (amount_y * total_supply) / reserve_y
)

You must deposit in the current ratio of the pool. If you deposit out of ratio, you’ll receive fewer LP tokens than expected (the protocol effectively takes the excess as a donation).

Withdrawing liquidity:

amount_x_returned = (LP_tokens_burned * reserve_x) / total_supply
amount_y_returned = (LP_tokens_burned * reserve_y) / total_supply

Impermanent Loss: The Hidden Cost

Impermanent loss is the difference between holding tokens in an AMM vs. simply holding them in your wallet.

Example:

Initial deposit:
  1 ETH + 3000 USDC (ETH price = $3000)
  Total value: $6000

ETH price doubles to $6000:

If you just held:
  1 ETH = $6000
  3000 USDC = $3000
  Total: $9000

If you LP'd:
  Arbitrageurs rebalance the pool
  New reserves (maintaining k): ~0.707 ETH + ~4243 USDC
  Your share: 0.707 ETH ($4243) + 4243 USDC
  Total: $8486

Impermanent loss: ($9000 - $8486) / $9000 = 5.7%

It’s called “impermanent” because if prices return to the original ratio, the loss disappears. But if you withdraw at any other ratio, the loss becomes permanent.

Flash Swaps and Reentrancy Attacks

A flash swap allows borrowing tokens from the pool without upfront collateral, as long as you either:

  1. Return the borrowed tokens (plus fee) in the same transaction
  2. Pay for them with the other token in the same transaction

This enables atomic arbitrage but opens attack vectors:

Reentrancy Attack Pattern:

1. Attacker calls swap()
2. Contract sends tokens to attacker
3. Attacker's receive function calls swap() again
4. State isn't updated yet, so attacker gets favorable rate
5. Repeat until pool is drained

Protection: Checks-Effects-Interactions Pattern:

function swap(uint amount0Out, uint amount1Out, address to) {
    // 1. CHECKS: Validate inputs
    require(amount0Out > 0 || amount1Out > 0, "Invalid output");

    // 2. EFFECTS: Update state BEFORE external calls
    reserve0 -= amount0Out;
    reserve1 -= amount1Out;

    // 3. INTERACTIONS: External calls last
    if (amount0Out > 0) token0.transfer(to, amount0Out);
    if (amount1Out > 0) token1.transfer(to, amount1Out);

    // 4. VERIFY INVARIANT: Critical post-condition check
    uint balance0 = token0.balanceOf(address(this));
    uint balance1 = token1.balanceOf(address(this));
    require(balance0 * balance1 >= k, "K invariant violated");
}

Price Oracle Considerations

AMM prices can be manipulated within a single transaction (flash loan attack), making them unreliable for instant price queries. Uniswap V2 introduced Time-Weighted Average Prices (TWAP):

price0CumulativeLast += reserve1 / reserve0 * timeElapsed
price1CumulativeLast += reserve0 / reserve1 * timeElapsed

To get the average price over a period:

averagePrice = (priceCumulativeEnd - priceCumulativeStart) / timeElapsed

TWAP is manipulation-resistant because attackers would need to maintain manipulated prices over many blocks, making attacks economically infeasible.

Mathematical Deep Dive: Deriving the Formulas

1. Swap Output Formula

Starting with:

(x + dx)(y - dy) = xy
xy - x*dy + y*dx - dx*dy = xy
y*dx = x*dy + dx*dy
y*dx = dy(x + dx)
dy = y*dx / (x + dx)

2. Price Impact Formula

Spot price before trade: P0 = y/x Effective price paid: P_eff = dy/dx = y/(x + dx) Price impact: (P0 - P_eff)/P0 = dx/(x + dx)

For a trade of 1% of reserves: price impact = 1/101 = ~0.99%

3. Impermanent Loss Formula

Let r be the price ratio change (new_price / old_price):

IL = 2*sqrt(r) / (1 + r) - 1
Price Change Impermanent Loss
1.25x (25% up) 0.6%
1.50x (50% up) 2.0%
2x (100% up) 5.7%
3x (200% up) 13.4%
4x (300% up) 20.0%
5x (400% up) 25.5%

Complete Project Specification

Functional Requirements

  1. Core AMM Functions
    • Create trading pairs for any ERC-20 token pair
    • Swap tokens using the constant product formula
    • Add liquidity in the correct ratio, minting LP tokens
    • Remove liquidity by burning LP tokens
  2. Fee System
    • Charge 0.3% fee on swaps
    • Accumulate fees in the pool (increasing LP token value)
    • Optional protocol fee (e.g., 0.05% to protocol treasury)
  3. Price Oracle
    • Implement cumulative price tracking for TWAP
    • Provide time-weighted average price queries
    • Handle overflow safely (using fixed-point arithmetic)
  4. Factory Pattern
    • Deploy new pairs through a factory contract
    • Prevent duplicate pairs
    • Emit events for pair creation for indexing
  5. Flash Swap Support
    • Allow borrowing without collateral
    • Enforce repayment or swap in same transaction
    • Charge standard fee on flash operations
  6. Safety Features
    • Reentrancy guards on all state-changing functions
    • Minimum liquidity lock to prevent empty pool manipulation
    • Slippage protection via deadline and minimum output parameters

Non-Functional Requirements

  • Gas Efficiency: Swap should cost < 100,000 gas
  • Security: Pass Slither, Mythril, and manual audit
  • Accuracy: Maintain invariant within acceptable rounding error
  • Upgradeability: Consider proxy patterns for future upgrades

Contract Interfaces

interface IUniswapV2Pair {
    // ERC-20 functions for LP token
    function name() external pure returns (string memory);
    function symbol() external pure returns (string memory);
    function decimals() external pure returns (uint8);
    function totalSupply() external view returns (uint);
    function balanceOf(address owner) external view returns (uint);
    function transfer(address to, uint value) external returns (bool);
    function approve(address spender, uint value) external returns (bool);
    function transferFrom(address from, address to, uint value) external returns (bool);

    // AMM functions
    function factory() external view returns (address);
    function token0() external view returns (address);
    function token1() external view returns (address);
    function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
    function price0CumulativeLast() external view returns (uint);
    function price1CumulativeLast() external view returns (uint);
    function kLast() external view returns (uint);

    function mint(address to) external returns (uint liquidity);
    function burn(address to) external returns (uint amount0, uint amount1);
    function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
    function skim(address to) external;
    function sync() external;

    function initialize(address token0, address token1) external;
}

interface IUniswapV2Factory {
    function feeTo() external view returns (address);
    function feeToSetter() external view returns (address);
    function getPair(address tokenA, address tokenB) external view returns (address pair);
    function allPairs(uint) external view returns (address pair);
    function allPairsLength() external view returns (uint);
    function createPair(address tokenA, address tokenB) external returns (address pair);
    function setFeeTo(address) external;
    function setFeeToSetter(address) external;
}

interface IUniswapV2Router {
    function factory() external pure returns (address);
    function WETH() external pure returns (address);

    function addLiquidity(
        address tokenA,
        address tokenB,
        uint amountADesired,
        uint amountBDesired,
        uint amountAMin,
        uint amountBMin,
        address to,
        uint deadline
    ) external returns (uint amountA, uint amountB, uint liquidity);

    function removeLiquidity(
        address tokenA,
        address tokenB,
        uint liquidity,
        uint amountAMin,
        uint amountBMin,
        address to,
        uint deadline
    ) external returns (uint amountA, uint amountB);

    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external returns (uint[] memory amounts);

    function swapTokensForExactTokens(
        uint amountOut,
        uint amountInMax,
        address[] calldata path,
        address to,
        uint deadline
    ) external returns (uint[] memory amounts);

    function quote(uint amountA, uint reserveA, uint reserveB) external pure returns (uint amountB);
    function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) external pure returns (uint amountOut);
    function getAmountIn(uint amountOut, uint reserveIn, uint reserveOut) external pure returns (uint amountIn);
    function getAmountsOut(uint amountIn, address[] calldata path) external view returns (uint[] memory amounts);
    function getAmountsIn(uint amountOut, address[] calldata path) external view returns (uint[] memory amounts);
}

Solution Architecture

Module Structure

contracts/
├── core/
│   ├── UniswapV2Pair.sol        # Main AMM pair contract
│   ├── UniswapV2Factory.sol     # Factory for creating pairs
│   └── UniswapV2ERC20.sol       # LP token implementation
├── periphery/
│   ├── UniswapV2Router.sol      # User-facing router
│   ├── UniswapV2Library.sol     # Helper functions
│   └── UniswapV2OracleLibrary.sol # TWAP oracle helpers
├── interfaces/
│   ├── IUniswapV2Pair.sol
│   ├── IUniswapV2Factory.sol
│   ├── IUniswapV2Router.sol
│   ├── IUniswapV2Callee.sol     # Flash swap callback
│   └── IERC20.sol
├── libraries/
│   ├── Math.sol                  # sqrt, min functions
│   ├── UQ112x112.sol            # Fixed-point math
│   ├── SafeMath.sol             # Overflow protection
│   └── TransferHelper.sol       # Safe ERC20 transfers
└── test/
    ├── ERC20Mock.sol            # Test token
    └── FlashBorrower.sol        # Flash swap test

Core Data Structures

// Packed storage for gas efficiency
struct Reserves {
    uint112 reserve0;          // 112 bits
    uint112 reserve1;          // 112 bits
    uint32 blockTimestampLast; // 32 bits
    // Total: 256 bits = 1 slot
}

// Fixed-point math for price accumulator
// UQ112x112: 112 bits integer, 112 bits fraction
// Allows prices from 0 to 2^112 with 2^-112 precision
library UQ112x112 {
    uint224 constant Q112 = 2**112;

    function encode(uint112 y) internal pure returns (uint224 z) {
        z = uint224(y) * Q112;
    }

    function uqdiv(uint224 x, uint112 y) internal pure returns (uint224 z) {
        z = x / uint224(y);
    }
}

Contract Interaction Flow

User                    Router                    Pair                   Tokens
  |                        |                        |                       |
  |-- addLiquidity() ----->|                        |                       |
  |                        |-- transferFrom() ------|---------------------->|
  |                        |-- transferFrom() ------|---------------------->|
  |                        |-- mint(to) ----------->|                       |
  |                        |                        |-- _update() --------->|
  |                        |                        |-- _mint(LP tokens) -->|
  |<-- LP tokens ----------------------------------------|                  |
  |                        |                        |                       |
  |-- swapExactTokensForTokens() -->|               |                       |
  |                        |-- transferFrom() ------|---------------------->|
  |                        |-- swap() ------------->|                       |
  |                        |                        |-- transfer() -------->|
  |<-- output tokens ----------------------------------------------|        |

Key Algorithms

1. Optimal Liquidity Calculation

function _addLiquidity(
    address tokenA,
    address tokenB,
    uint amountADesired,
    uint amountBDesired,
    uint amountAMin,
    uint amountBMin
) internal returns (uint amountA, uint amountB) {
    (uint reserveA, uint reserveB) = getReserves(tokenA, tokenB);

    if (reserveA == 0 && reserveB == 0) {
        // First liquidity provider sets the ratio
        (amountA, amountB) = (amountADesired, amountBDesired);
    } else {
        // Calculate optimal amounts based on current ratio
        uint amountBOptimal = quote(amountADesired, reserveA, reserveB);

        if (amountBOptimal <= amountBDesired) {
            require(amountBOptimal >= amountBMin, "INSUFFICIENT_B_AMOUNT");
            (amountA, amountB) = (amountADesired, amountBOptimal);
        } else {
            uint amountAOptimal = quote(amountBDesired, reserveB, reserveA);
            assert(amountAOptimal <= amountADesired);
            require(amountAOptimal >= amountAMin, "INSUFFICIENT_A_AMOUNT");
            (amountA, amountB) = (amountAOptimal, amountBDesired);
        }
    }
}

2. Swap with K Invariant Check

function swap(
    uint amount0Out,
    uint amount1Out,
    address to,
    bytes calldata data
) external lock {
    require(amount0Out > 0 || amount1Out > 0, "INSUFFICIENT_OUTPUT_AMOUNT");

    (uint112 _reserve0, uint112 _reserve1,) = getReserves();
    require(amount0Out < _reserve0 && amount1Out < _reserve1, "INSUFFICIENT_LIQUIDITY");

    // Optimistically transfer tokens
    if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
    if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);

    // Flash swap callback
    if (data.length > 0) {
        IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
    }

    // Get current balances
    uint balance0 = IERC20(token0).balanceOf(address(this));
    uint balance1 = IERC20(token1).balanceOf(address(this));

    // Calculate amounts in
    uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
    uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
    require(amount0In > 0 || amount1In > 0, "INSUFFICIENT_INPUT_AMOUNT");

    // Verify K invariant with fee adjustment (0.3% fee)
    // balance0Adjusted = balance0 * 1000 - amount0In * 3
    // balance1Adjusted = balance1 * 1000 - amount1In * 3
    // require(balance0Adjusted * balance1Adjusted >= reserve0 * reserve1 * 1000^2)
    uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
    uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
    require(
        balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2),
        "K"
    );

    _update(balance0, balance1, _reserve0, _reserve1);
}

3. Price Accumulator Update

function _update(
    uint balance0,
    uint balance1,
    uint112 _reserve0,
    uint112 _reserve1
) private {
    require(balance0 <= type(uint112).max && balance1 <= type(uint112).max, "OVERFLOW");

    uint32 blockTimestamp = uint32(block.timestamp % 2**32);
    uint32 timeElapsed = blockTimestamp - blockTimestampLast;

    if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
        // Accumulate price * time
        // Using UQ112x112 for precision
        price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
        price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
    }

    reserve0 = uint112(balance0);
    reserve1 = uint112(balance1);
    blockTimestampLast = blockTimestamp;

    emit Sync(reserve0, reserve1);
}

Phased Implementation Guide

Phase 1: ERC-20 LP Token Base

Goal: Create the LP token that represents pool shares.

Tasks:

  1. Implement standard ERC-20 functions (transfer, approve, transferFrom)
  2. Add permit function for gasless approvals (EIP-2612)
  3. Implement internal _mint and _burn functions
  4. Add DOMAIN_SEPARATOR for EIP-712 signing

Validation:

// Test transfers
assertEq(lpToken.balanceOf(alice), 1000e18);
lpToken.transfer(bob, 500e18);
assertEq(lpToken.balanceOf(alice), 500e18);
assertEq(lpToken.balanceOf(bob), 500e18);

// Test permit
bytes32 digest = keccak256(abi.encodePacked(...));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(aliceKey, digest);
lpToken.permit(alice, bob, 100e18, deadline, v, r, s);
assertEq(lpToken.allowance(alice, bob), 100e18);

Hints if stuck:

  • Use OpenZeppelin’s ERC20 as reference (but implement yourself for learning)
  • DOMAIN_SEPARATOR includes chain ID to prevent cross-chain replay
  • Use unchecked arithmetic in Solidity 0.8+ for gas savings on safe operations

Phase 2: Core Pair Contract Structure

Goal: Set up the pair contract with reserves and initialization.

Tasks:

  1. Define token0, token1, and reserve storage
  2. Implement initialize() called by factory
  3. Add getReserves() view function
  4. Implement the reentrancy lock modifier

Validation:

pair.initialize(address(tokenA), address(tokenB));
assertEq(pair.token0(), address(tokenA));
assertEq(pair.token1(), address(tokenB));

(uint112 r0, uint112 r1, uint32 ts) = pair.getReserves();
assertEq(r0, 0);
assertEq(r1, 0);

Hints if stuck:

  • token0 should be the token with the smaller address (for deterministic ordering)
  • Use uint112 for reserves (saves gas, 112 bits is plenty for token amounts)
  • Lock pattern: uint private unlocked = 1; modifier lock() { require(unlocked == 1); unlocked = 0; _; unlocked = 1; }

Phase 3: Liquidity Minting

Goal: Implement adding liquidity and minting LP tokens.

Tasks:

  1. Implement mint() function for adding liquidity
  2. Handle first liquidity provider (sqrt formula, lock MINIMUM_LIQUIDITY)
  3. Handle subsequent providers (proportional minting)
  4. Update reserves after minting

Validation:

// First provider
tokenA.transfer(address(pair), 1000e18);
tokenB.transfer(address(pair), 1000e18);
uint liquidity = pair.mint(alice);
assertGt(liquidity, 0);
assertEq(pair.totalSupply(), liquidity + MINIMUM_LIQUIDITY);

// Second provider
tokenA.transfer(address(pair), 500e18);
tokenB.transfer(address(pair), 500e18);
uint liquidity2 = pair.mint(bob);
assertEq(liquidity2, liquidity / 2); // Half of first provider

Hints if stuck:

  • First liquidity: liquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY
  • Subsequent: liquidity = min(amount0 * totalSupply / reserve0, amount1 * totalSupply / reserve1)
  • MINIMUM_LIQUIDITY (1000) is minted to address(0) to prevent division by zero attacks

Phase 4: Liquidity Burning

Goal: Implement removing liquidity and receiving tokens back.

Tasks:

  1. Implement burn() function
  2. Calculate pro-rata token amounts
  3. Transfer tokens and burn LP tokens
  4. Update reserves

Validation:

// Transfer LP tokens to pair, then burn
pair.transfer(address(pair), liquidity);
(uint amount0, uint amount1) = pair.burn(alice);

assertGt(amount0, 0);
assertGt(amount1, 0);
assertEq(tokenA.balanceOf(alice), amount0);
assertEq(tokenB.balanceOf(alice), amount1);

Hints if stuck:

  • amount0 = liquidity * balance0 / totalSupply
  • amount1 = liquidity * balance1 / totalSupply
  • Use actual balances, not reserves, to include accumulated fees

Phase 5: Swap Implementation

Goal: Implement the core swap function with K invariant check.

Tasks:

  1. Implement swap() with output amounts as parameters
  2. Optimistically transfer tokens out
  3. Check that K invariant holds after swap (with fee)
  4. Update reserves and emit events

Validation:

// Add liquidity first
addLiquidity(1000e18, 1000e18);

// Swap 100 tokenA for tokenB
tokenA.transfer(address(pair), 100e18);
uint expectedOut = getAmountOut(100e18, 1000e18, 1000e18);
pair.swap(0, expectedOut, alice, "");

assertEq(tokenB.balanceOf(alice), expectedOut);
// Verify K increased (due to fees)
(uint112 r0, uint112 r1,) = pair.getReserves();
assertGt(uint(r0) * uint(r1), 1000e18 * 1000e18);

Hints if stuck:

  • The fee is implemented by requiring: balance0Adjusted * balance1Adjusted >= k * 1000000
  • Where balanceAdjusted = balance * 1000 - amountIn * 3 (0.3% fee)
  • Flash swaps: if data.length > 0, call the callback before checking invariant

Phase 6: Factory Contract

Goal: Create a factory that deploys pairs deterministically.

Tasks:

  1. Implement createPair() using CREATE2 for deterministic addresses
  2. Store pair addresses in mapping
  3. Prevent duplicate pairs
  4. Emit PairCreated event

Validation:

address pair = factory.createPair(address(tokenA), address(tokenB));
assertEq(factory.getPair(address(tokenA), address(tokenB)), pair);
assertEq(factory.getPair(address(tokenB), address(tokenA)), pair); // Order independent

// Duplicate should fail
vm.expectRevert("PAIR_EXISTS");
factory.createPair(address(tokenA), address(tokenB));

Hints if stuck:

  • CREATE2 salt: keccak256(abi.encodePacked(token0, token1))
  • Use bytecode hash of Pair contract for address calculation
  • Sort tokens by address before hashing to ensure (A,B) == (B,A)

Phase 7: Router Contract

Goal: Create user-friendly router with slippage protection.

Tasks:

  1. Implement addLiquidity() with minimum amount checks
  2. Implement removeLiquidity() with minimum amount checks
  3. Implement swapExactTokensForTokens() with minimum output
  4. Implement swapTokensForExactTokens() with maximum input
  5. Add deadline parameter to all functions
  6. Implement multi-hop swaps through path array

Validation:

// Add liquidity with slippage protection
router.addLiquidity(
    address(tokenA),
    address(tokenB),
    1000e18,  // desired A
    1000e18,  // desired B
    990e18,   // min A (1% slippage)
    990e18,   // min B (1% slippage)
    alice,
    block.timestamp + 1 hours
);

// Swap with slippage protection
address[] memory path = new address[](2);
path[0] = address(tokenA);
path[1] = address(tokenB);
router.swapExactTokensForTokens(
    100e18,   // amount in
    90e18,    // min out (10% slippage tolerance)
    path,
    alice,
    block.timestamp + 1 hours
);

Hints if stuck:

  • Router never holds tokens; all operations are atomic
  • Use TransferHelper.safeTransferFrom for handling non-standard ERC20s
  • Deadline check: require(deadline >= block.timestamp, "EXPIRED")

Phase 8: TWAP Oracle

Goal: Implement time-weighted average price oracle.

Tasks:

  1. Track cumulative prices in pair contract
  2. Create oracle library for calculating TWAP
  3. Handle overflow correctly (it’s intentional and safe)
  4. Implement observation window (e.g., 24-hour TWAP)

Validation:

// Record observation at start
uint price0Start = pair.price0CumulativeLast();
uint32 timestampStart = pair.blockTimestampLast();

// Advance time and make trades
vm.warp(block.timestamp + 1 hours);
doSomeSwaps();

// Record observation at end
uint price0End = pair.price0CumulativeLast();
uint32 timestampEnd = pair.blockTimestampLast();

// Calculate TWAP
uint timeElapsed = timestampEnd - timestampStart;
uint twap = (price0End - price0Start) / timeElapsed;
// Decode from UQ112x112 format
uint priceDecoded = twap * 1e18 / 2**112;

Hints if stuck:

  • Cumulative price overflows are intentional; subtraction still works
  • Use block.timestamp modulo 2^32 to fit in uint32
  • Store observations off-chain for historical TWAP calculation

Phase 9: Flash Swap Support

Goal: Enable borrowing tokens without upfront collateral.

Tasks:

  1. Support callback interface (IUniswapV2Callee)
  2. Allow arbitrary data to be passed to callback
  3. Verify repayment after callback
  4. Charge standard fee on flash borrowed amounts

Validation:

contract FlashBorrower is IUniswapV2Callee {
    function initiateFlashSwap(address pair, uint amount) external {
        IUniswapV2Pair(pair).swap(amount, 0, address(this), abi.encode("flash"));
    }

    function uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data) external {
        // Do arbitrage or whatever

        // Repay with fee
        uint amountToRepay = amount0 * 1000 / 997 + 1;
        IERC20(token1).transfer(msg.sender, amountToRepay);
    }
}

Hints if stuck:

  • Flash swaps work because invariant is checked AFTER callback
  • You can repay in either token (or swap at the same time)
  • Empty data means no callback; non-empty data triggers callback

Phase 10: Security Hardening

Goal: Add production-ready security measures.

Tasks:

  1. Add reentrancy guards to all state-changing functions
  2. Implement safe math (or use Solidity 0.8+ overflow checks)
  3. Add emergency functions (skim, sync)
  4. Comprehensive event emission for off-chain indexing
  5. Natspec documentation

Validation:

// Reentrancy attack should fail
vm.expectRevert("LOCKED");
pair.swap(...); // From within a callback

// Skim excess tokens
tokenA.transfer(address(pair), 1e18);
pair.skim(treasury);
assertEq(tokenA.balanceOf(treasury), 1e18);

// Sync reserves to actual balances
pair.sync();
(uint112 r0, uint112 r1,) = pair.getReserves();
assertEq(r0, tokenA.balanceOf(address(pair)));

Hints if stuck:

  • skim() sends excess tokens (beyond reserves) to specified address
  • sync() updates reserves to match actual balances (recovery function)
  • Emit Transfer, Approval, Swap, Mint, Burn, Sync events

Testing Strategy

Unit Tests

// test/UniswapV2Pair.t.sol
contract UniswapV2PairTest is Test {
    UniswapV2Pair pair;
    MockERC20 token0;
    MockERC20 token1;

    function setUp() public {
        token0 = new MockERC20("Token0", "TK0", 18);
        token1 = new MockERC20("Token1", "TK1", 18);

        UniswapV2Factory factory = new UniswapV2Factory(address(this));
        pair = UniswapV2Pair(factory.createPair(address(token0), address(token1)));
    }

    function testMintInitialLiquidity() public {
        token0.transfer(address(pair), 1e18);
        token1.transfer(address(pair), 1e18);

        uint liquidity = pair.mint(address(this));

        assertEq(liquidity, 1e18 - 1000); // Minus MINIMUM_LIQUIDITY
        assertEq(pair.totalSupply(), 1e18);
        assertEq(pair.balanceOf(address(0)), 1000);
    }

    function testSwapExactInput() public {
        // Add liquidity
        token0.transfer(address(pair), 1000e18);
        token1.transfer(address(pair), 1000e18);
        pair.mint(address(this));

        // Swap
        token0.transfer(address(pair), 100e18);
        uint expectedOut = 90661089388014913621; // Calculated

        pair.swap(0, expectedOut, address(this), "");

        assertEq(token1.balanceOf(address(this)), expectedOut);
    }
}

Fuzz Tests

function testFuzz_SwapMaintainsK(uint112 reserve0, uint112 reserve1, uint112 amountIn) public {
    vm.assume(reserve0 > 1e6 && reserve1 > 1e6);
    vm.assume(amountIn > 0 && amountIn < reserve0 / 2);

    // Setup
    addLiquidity(reserve0, reserve1);

    uint kBefore = uint(reserve0) * uint(reserve1);

    // Swap
    token0.transfer(address(pair), amountIn);
    uint amountOut = getAmountOut(amountIn, reserve0, reserve1);
    pair.swap(0, amountOut, address(this), "");

    (uint112 r0, uint112 r1,) = pair.getReserves();
    uint kAfter = uint(r0) * uint(r1);

    // K should increase (due to fees)
    assertGe(kAfter, kBefore);
}

Invariant Tests

contract InvariantTest is Test {
    Handler handler;
    UniswapV2Pair pair;

    function setUp() public {
        // Setup pair and handler
        handler = new Handler(pair);
        targetContract(address(handler));
    }

    function invariant_kNeverDecreases() public {
        (uint112 r0, uint112 r1,) = pair.getReserves();
        uint k = uint(r0) * uint(r1);
        assertGe(k, handler.lastK());
    }

    function invariant_lpTokensMatchReserves() public {
        uint totalSupply = pair.totalSupply();
        if (totalSupply > 0) {
            (uint112 r0, uint112 r1,) = pair.getReserves();
            assertGt(r0, 0);
            assertGt(r1, 0);
        }
    }
}

Integration Tests

function testMultiHopSwap() public {
    // Create pairs: A-B, B-C
    factory.createPair(address(tokenA), address(tokenB));
    factory.createPair(address(tokenB), address(tokenC));

    // Add liquidity to both
    addLiquidity(pairAB, 1000e18, 1000e18);
    addLiquidity(pairBC, 1000e18, 1000e18);

    // Swap A -> B -> C through router
    address[] memory path = new address[](3);
    path[0] = address(tokenA);
    path[1] = address(tokenB);
    path[2] = address(tokenC);

    uint[] memory amounts = router.swapExactTokensForTokens(
        100e18,
        80e18,
        path,
        address(this),
        block.timestamp
    );

    assertEq(amounts.length, 3);
    assertGt(amounts[2], 80e18);
}

Security Tests

function testReentrancyProtection() public {
    MaliciousReceiver attacker = new MaliciousReceiver(pair);

    // Setup liquidity
    addLiquidity(1000e18, 1000e18);

    // Attacker tries reentrancy
    vm.expectRevert("LOCKED");
    attacker.attack();
}

function testFlashSwapMustRepay() public {
    BadFlashBorrower borrower = new BadFlashBorrower();

    addLiquidity(1000e18, 1000e18);

    vm.expectRevert("K");
    pair.swap(100e18, 0, address(borrower), "flash");
}

function testSlippageProtection() public {
    addLiquidity(1000e18, 1000e18);

    // Frontrun: someone else makes a big trade
    token0.transfer(address(pair), 500e18);
    pair.swap(0, 332e18, address(attacker), "");

    // Victim's swap now gets worse rate
    vm.expectRevert("INSUFFICIENT_OUTPUT_AMOUNT");
    router.swapExactTokensForTokens(
        100e18,
        95e18,  // Minimum output won't be met
        path,
        victim,
        block.timestamp
    );
}

Common Pitfalls & Debugging

Pitfall 1: Incorrect Fee Calculation

Problem: Implementing fee as subtraction from output instead of multiplication.

Symptom: K decreases after swaps; pool can be drained.

Wrong:

// DON'T DO THIS
uint amountOut = reserveOut * amountIn / (reserveIn + amountIn);
uint fee = amountOut * 3 / 1000;
amountOut = amountOut - fee;

Correct:

// Fee is on INPUT
uint amountInWithFee = amountIn * 997;
uint numerator = amountInWithFee * reserveOut;
uint denominator = reserveIn * 1000 + amountInWithFee;
uint amountOut = numerator / denominator;

Pitfall 2: First Liquidity Provider Manipulation

Problem: Not locking MINIMUM_LIQUIDITY, allowing attackers to manipulate empty pools.

Symptom: First provider can drain subsequent providers.

Attack:

1. Attacker adds minimal liquidity (1 wei each)
2. Attacker donates large amount to inflate LP token value
3. Victim adds liquidity, gets 0 LP tokens due to rounding
4. Attacker withdraws, stealing victim's tokens

Solution:

function mint(address to) external lock returns (uint liquidity) {
    if (_totalSupply == 0) {
        liquidity = Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
        _mint(address(0), MINIMUM_LIQUIDITY); // Permanently locked
    }
    // ...
}

Pitfall 3: Reentrancy via Callback

Problem: Not using reentrancy guard, allowing flash swap callback to re-enter.

Symptom: Attacker drains pool through nested swaps.

Solution:

uint private unlocked = 1;

modifier lock() {
    require(unlocked == 1, "LOCKED");
    unlocked = 0;
    _;
    unlocked = 1;
}

function swap(...) external lock {
    // Now safe even if callback tries to re-enter
}

Pitfall 4: Integer Overflow/Underflow

Problem: Not handling large numbers correctly.

Symptom: Unexpected reverts or wrong calculations for large trades.

Solution:

// Use uint256 for intermediate calculations
// reserve0 and reserve1 are uint112, but product needs more bits
uint balance0Adjusted = uint(balance0) * 1000 - uint(amount0In) * 3;
uint balance1Adjusted = uint(balance1) * 1000 - uint(amount1In) * 3;

// K comparison in uint256
require(
    balance0Adjusted * balance1Adjusted >=
    uint(_reserve0) * uint(_reserve1) * 1000000,
    "K"
);

Pitfall 5: Token Order Assumption

Problem: Assuming token0 and token1 order in router.

Symptom: Swaps fail or return wrong amounts.

Solution:

function sortTokens(address tokenA, address tokenB)
    internal pure returns (address token0, address token1)
{
    require(tokenA != tokenB, "IDENTICAL_ADDRESSES");
    (token0, token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
    require(token0 != address(0), "ZERO_ADDRESS");
}

function getReserves(address tokenA, address tokenB)
    internal view returns (uint reserveA, uint reserveB)
{
    (address token0,) = sortTokens(tokenA, tokenB);
    (uint112 reserve0, uint112 reserve1,) = pair.getReserves();
    (reserveA, reserveB) = tokenA == token0 ? (reserve0, reserve1) : (reserve1, reserve0);
}

Pitfall 6: Deadline Not Enforced

Problem: Missing deadline check allows transactions to be held and executed later.

Symptom: User’s transaction gets executed at unfavorable price.

Attack:

1. User submits swap with 1% slippage tolerance
2. Miner holds transaction
3. Price moves 20% against user
4. Miner executes with new reserves, user gets bad rate

Solution:

modifier ensure(uint deadline) {
    require(deadline >= block.timestamp, "EXPIRED");
    _;
}

function swapExactTokensForTokens(
    uint amountIn,
    uint amountOutMin,
    address[] calldata path,
    address to,
    uint deadline
) external ensure(deadline) returns (uint[] memory amounts) {
    // ...
}

Extensions and Challenges

Challenge 1: Concentrated Liquidity (Uniswap V3 Style)

Implement a system where liquidity providers can concentrate their capital within specific price ranges, improving capital efficiency.

Key concepts:

  • Tick-based liquidity
  • Range orders
  • Non-fungible LP positions

Challenge 2: Multi-Asset Pools (Balancer Style)

Extend to pools with more than 2 tokens and configurable weights:

Value0^w0 * Value1^w1 * Value2^w2 = k

Challenge 3: Stableswap Curve (Curve Style)

Implement a hybrid invariant for stablecoin pairs that provides better prices near 1:1:

An^n * sum(xi) + D = ADn^n + D^(n+1) / (n^n * prod(xi))

Challenge 4: MEV Protection

Implement mechanisms to protect users from frontrunning:

  • Commit-reveal schemes
  • Flashbots integration
  • Frequent batch auctions

Challenge 5: Governance-Controlled Fees

Add a DAO governance system that can:

  • Adjust fee parameters
  • Enable/disable protocol fee
  • Upgrade router contracts
  • Pause in emergencies

Challenge 6: Cross-Chain AMM

Build a bridge that allows swapping tokens across chains:

  • Lock-and-mint mechanism
  • Optimistic verification
  • Liquidity unification

Real-World Connections

Uniswap

Uniswap is the canonical implementation of the constant product AMM:

  • V1 (2018): ETH-to-token swaps only
  • V2 (2020): Any ERC20-to-ERC20, flash swaps, TWAP oracle
  • V3 (2021): Concentrated liquidity, multiple fee tiers
  • V4 (2024): Hooks for customization, singleton architecture

As of 2024, Uniswap has processed over $1 trillion in trading volume.

SushiSwap

A fork of Uniswap V2 that added governance tokens and yield farming. Demonstrates both the power of open-source (anyone can fork) and the importance of community (SushiSwap thrived despite being a fork).

Curve Finance

Specialized for stablecoin swaps, using a modified invariant that provides better rates for assets that should trade near 1:1. Handles billions in TVL for USDC/USDT/DAI pairs.

Balancer

Generalized AMM with:

  • Multi-asset pools (up to 8 tokens)
  • Configurable weights
  • Smart pools with programmable logic

Impermanent Loss in Practice

In 2021, a study found that 49.5% of Uniswap V3 liquidity providers lost money compared to simply holding. Understanding IL is crucial for anyone providing liquidity.

Flash Loan Attacks

Notable exploits using flash swaps:

  • bZx (2020): $350k stolen via flash loan manipulation
  • Harvest Finance (2020): $34M drained
  • Cream Finance (2021): $130M flash loan attack

Understanding flash swaps helps you understand both the opportunity and the risk.


Resources

Primary References

  1. Uniswap V2 Whitepaper: Official PDF - The definitive resource
  2. “Mastering Ethereum” Chapter 11: Smart contract security patterns
  3. Uniswap V2 Core: GitHub - Production implementation

Code References

  1. Uniswap V2 Periphery: GitHub - Router and library contracts
  2. OpenZeppelin Contracts: GitHub - ERC20 and security utilities

Supplementary Reading

  1. “Constant Function Market Makers” by Guillermo Angeris et al. - Academic analysis
  2. “An Analysis of Uniswap Markets” by Bancor - Impermanent loss study
  3. “Flash Boys 2.0” by Daian et al. - MEV and frontrunning research

Tools

  1. Foundry: GitHub - Testing framework used in this project
  2. Slither: GitHub - Static analysis for security
  3. Echidna: GitHub - Fuzzing for smart contracts

Self-Assessment Checklist

Before moving to the next project, verify:

  • I can derive the swap output formula from the constant product invariant
  • I understand why the fee is applied to input, not output
  • I can explain why MINIMUM_LIQUIDITY is locked forever
  • I understand impermanent loss and can calculate it for a given price change
  • My implementation correctly handles flash swaps with repayment verification
  • I can explain why TWAP is more manipulation-resistant than spot price
  • My tests cover reentrancy, overflow, and slippage edge cases
  • I understand the trade-offs between different AMM curve formulas
  • I can identify MEV vulnerabilities in AMM transactions
  • My router implementation includes deadline and slippage protection

What’s Next?

With AMM mechanics mastered, you now understand the core building block of DeFi. In Project 11: NFT Marketplace, you’ll apply similar concepts to non-fungible tokens, implementing listings, auctions, royalties, and escrow mechanisms that power marketplaces like OpenSea and Blur.