Build a Uniswap V2 AMM from Scratch

Build a constant-product AMM with liquidity pools and swap fees in Solidity.

Introduction

30 Second Summary

Every time you swap tokens on a decentralized exchange, a mathematical formula determines the price instead of order books or middlemen. Understanding how that formula works at the smart contract level is what separates DeFi builders from DeFi users.

In this project, you will build a complete Uniswap V2-style automated market maker from scratch using Solidity and Foundry. You will implement the constant-product formula, mint and burn LP tokens, enforce swap fees, and prove it all works with a test suite.

What You'll Build

A minimal but fully functional decentralized exchange protocol consisting of a Factory contract, a Pair contract, and a comprehensive Foundry test suite.

By the end of this project, you'll have:

  • A Pair contract that holds two tokens, mints and burns LP tokens, and executes swaps with a 0.3% fee enforced by the constant-product formula (x * y = k).
  • A Factory contract that deploys new Pair contracts via CREATE2 for deterministic addresses.
  • A Foundry test suite proving the K-invariant holds after every swap, liquidity math is correct, and reentrancy is blocked.
  • Secret Mission: Build a simplified Router contract with slippage protection and deadline enforcement.

Are there any prerequisites?

This project assumes you have experience writing Solidity (ERC-20 contracts, mappings, inheritance) and that you already have Foundry installed on your machine.

Initialize the Foundry Project

Before writing any AMM logic, you need a properly structured Foundry project with the right compiler settings.

In this step, you will scaffold a new Foundry project, configure it for Solidity 0.8.24, and create the base ERC-20 token contract that the rest of the protocol builds on.

In this step, get ready to:
  • Initialize a Foundry project and remove the template files.
  • Configure the Solidity compiler version and optimizer settings.
  • Create a SimpleERC20 contract for LP token functionality and testing.
Create the project and verify tooling
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Initialize a new Foundry project and move into it by running:
forge init uniswap-v2-clone && cd uniswap-v2-clone

What does this command do?

forge init scaffolds a new Foundry project with a default folder structure: src/ for contracts, test/ for tests, script/ for deployment scripts, and lib/ for dependencies.

It also initializes a Git repository and installs forge-std (Foundry's standard test library) as a submodule.

  • Verify the scaffold compiled correctly by running:
forge build

You should see Compiler run successful! in your terminal output.

Not seeing a successful build?

  • Make sure you ran cd uniswap-v2-clone and are inside the project folder. Run pwd to confirm your current directory.
  • If forge is not found, make sure Foundry is installed. Run foundryup to install or update it.

Help me troubleshoot my Foundry build

The default template includes example files you do not need. Time to clean house.

  • Delete the template files by running:
rm src/Counter.sol test/Counter.t.sol script/Counter.s.sol
  • Confirm the files are gone by running:
ls src/

You should see no output, meaning the src/ folder is empty and ready for your contracts.

Configure the project for Solidity 0.8.24

Foundry defaults to the latest Solidity compiler. Pinning to 0.8.24 ensures reproducible builds and gives you built-in arithmetic overflow protection without needing SafeMath.

  • Open foundry.toml in the project root and replace its contents with:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.24"
optimizer = true
optimizer_runs = 200

What do these settings do?

  • solc = "0.8.24" pins the exact Solidity compiler version for your project.
  • optimizer = true tells the compiler to optimize bytecode, reducing gas costs when deployed.
  • optimizer_runs = 200 tunes optimization for contracts expected to be called around 200 times. This balances deployment cost versus execution cost.
  • Save foundry.toml.
  • Confirm the configuration works by running:
forge build

With no source files yet, this should pass cleanly with no errors.

Seeing a compiler error?

  • Check that you deleted all three template files in the previous step. A leftover Counter.sol might reference a Solidity version that conflicts with your 0.8.24 pin.
  • Make sure foundry.toml uses straight double quotes, not curly quotes. Some text editors auto-replace them.

Help me fix my foundry.toml configuration

✔️ Awesome, I've got everything!

Great. Make sure you have saved foundry.toml before moving on.

ⓧ I'd like to double check the full code

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.24"
optimizer = true
optimizer_runs = 200
Create the mock ERC-20 token for testing

Every token in your AMM (including the LP tokens that represent liquidity positions) follows the ERC-20 standard. This SimpleERC20 contract serves double duty in the project.

The Pair contract will inherit from it to become an LP token. You will also deploy standalone instances as mock tokens for testing swaps and liquidity.

  • Create a new file at src/SimpleERC20.sol and paste the following code:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

contract SimpleERC20 {
    string public name;
    string public symbol;
    uint8 public constant decimals = 18;
    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(string memory _name, string memory _symbol) {
        name = _name;
        symbol = _symbol;
    }

    // Unrestricted mint for testing - anyone can call this
    function mint(address to, uint256 amount) external {
        totalSupply += amount;
        balanceOf[to] += amount;
        emit Transfer(address(0), to, amount);
    }

    // Standard ERC-20 approve
    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    // Transfer tokens from caller to recipient
    function transfer(address to, uint256 amount) external returns (bool) {
        return _transfer(msg.sender, to, amount);
    }

    // Transfer tokens on behalf of another address (requires approval)
    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        uint256 allowed = allowance[from][msg.sender];
        if (allowed != type(uint256).max) {
            allowance[from][msg.sender] = allowed - amount;
        }
        return _transfer(from, to, amount);
    }

    // Internal transfer logic shared by transfer and transferFrom
    function _transfer(address from, address to, uint256 amount) internal returns (bool) {
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        emit Transfer(from, to, amount);
        return true;
    }
}

What does this contract do?

  • It implements the core ERC-20 interface: transfer, transferFrom, approve, and balance/allowance tracking.
  • The mint function has no access control. In production this would be dangerous, but for testing it lets you create tokens freely.
  • The transferFrom function skips the allowance deduction if the spender has type(uint256).max approval. This is a gas optimization for trusted contracts like the Router.
  • Save src/SimpleERC20.sol.
  • Verify it compiles by running:
forge build

You should see Compiler run successful! confirming your ERC-20 contract is valid Solidity.

Build failing on SimpleERC20?

  • Check that the first line is pragma solidity 0.8.24; matching the version in foundry.toml. A mismatch causes a compiler version error.
  • Make sure the file is saved inside the src/ folder of your uniswap-v2-clone project. Files outside src/ are not picked up by the compiler.
  • Look for missing curly braces or semicolons in the Solidity code. The compiler error message names the exact line number.

Help me fix my SimpleERC20 compilation error

✔️ Awesome, I've got everything!

Great. Double check you have saved src/SimpleERC20.sol and that forge build passes.

ⓧ I'd like to double check the full code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

contract SimpleERC20 {
    string public name;
    string public symbol;
    uint8 public constant decimals = 18;
    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(string memory _name, string memory _symbol) {
        name = _name;
        symbol = _symbol;
    }

    function mint(address to, uint256 amount) external {
        totalSupply += amount;
        balanceOf[to] += amount;
        emit Transfer(address(0), to, amount);
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        return _transfer(msg.sender, to, amount);
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        uint256 allowed = allowance[from][msg.sender];
        if (allowed != type(uint256).max) {
            allowance[from][msg.sender] = allowed - amount;
        }
        return _transfer(from, to, amount);
    }

    function _transfer(address from, address to, uint256 amount) internal returns (bool) {
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        emit Transfer(from, to, amount);
        return true;
    }
}

Your project skeleton is ready with a configured compiler and a working base token. Next up, you will build the Pair contract that turns this ERC-20 into a liquidity pool.

Build the Pair Contract — Reserves and LP Minting

Your project compiles and you have a working SimpleERC20 contract. Now it is time to build the heart of the AMM: the Pair contract.

The Pair contract is where two tokens live together in a liquidity pool. It tracks how many of each token it holds (reserves), lets liquidity providers deposit tokens in exchange for LP tokens, and lets them redeem those LP tokens to withdraw their share later.

In this step, get ready to:
  • Create the Pair contract with state variables, a reentrancy lock, and initialization logic.
  • Implement the mint function for adding liquidity and receiving LP tokens.
  • Implement the burn function for removing liquidity and redeeming LP tokens.
Create src/Pair.sol with state variables and initialization

The Pair contract inherits from SimpleERC20 because the Pair IS the LP token. Every pool mints its own token to represent ownership shares.

  • Create a new file src/Pair.sol in your uniswap-v2-clone project and paste the following contract scaffold:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {SimpleERC20} from "./SimpleERC20.sol";

contract Pair is SimpleERC20 {
    // Minimum LP tokens permanently locked on first deposit
    uint256 public constant MINIMUM_LIQUIDITY = 1000;

    // Addresses set once by the factory during initialization
    address public factory;
    address public token0;
    address public token1;

    // Reserve balances packed into uint112 for gas efficiency
    uint112 private reserve0;
    uint112 private reserve1;
    uint32 private blockTimestampLast;

    // Reentrancy guard state
    uint256 private unlocked = 1;

    event Mint(address indexed sender, uint256 amount0, uint256 amount1);
    event Burn(address indexed sender, uint256 amount0, uint256 amount1, address indexed to);
    event Swap(
        address indexed sender,
        uint256 amount0In,
        uint256 amount1In,
        uint256 amount0Out,
        uint256 amount1Out,
        address indexed to
    );
    event Sync(uint112 reserve0, uint112 reserve1);

    // Reentrancy lock: prevents re-entering during token transfers
    modifier lock() {
        require(unlocked == 1, "Pair: LOCKED");
        unlocked = 0;
        _;
        unlocked = 1;
    }

    // The deployer (factory) becomes the factory address
    constructor() SimpleERC20("Uniswap V2 LP", "UNI-V2") {
        factory = msg.sender;
    }

    // Called once by the factory to set the token pair
    function initialize(address _token0, address _token1) external {
        require(msg.sender == factory, "Pair: FORBIDDEN");
        token0 = _token0;
        token1 = _token1;
    }

    // External view to read current reserves
    function getReserves() public view returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast) {
        _reserve0 = reserve0;
        _reserve1 = reserve1;
        _blockTimestampLast = blockTimestampLast;
    }
}

What does this code do?

This scaffold sets up the Pair contract's foundation.

  • MINIMUM_LIQUIDITY (1000 wei) is permanently locked on the first deposit to prevent the pool from ever being fully drained.
  • The reserves use uint112 so both reserves plus a timestamp fit in a single 256-bit storage slot. This saves gas on every read.
  • The lock() modifier prevents reentrancy attacks. The Pair optimistically transfers tokens out during a swap before checking the invariant, so reentry would break the math.
  • initialize() is separate from the constructor because the factory deploys the Pair via CREATE2 and then configures it after deployment.

You will not see a change yet. The next two sections add the functions this contract needs before it can compile.

Implement the mint() function (adding liquidity)

The mint() function is how liquidity providers deposit tokens into the pool. It calculates how many LP tokens to give them based on the ratio of tokens they deposited.

  • In src/Pair.sol, add the following mint() function inside the Pair contract, after the getReserves() function:
    function mint(address to) external lock returns (uint256 liquidity) {
        (uint112 _reserve0, uint112 _reserve1,) = getReserves();
        uint256 balance0 = SimpleERC20(token0).balanceOf(address(this));
        uint256 balance1 = SimpleERC20(token1).balanceOf(address(this));
        uint256 amount0 = balance0 - _reserve0;
        uint256 amount1 = balance1 - _reserve1;

        if (totalSupply == 0) {
            // First deposit: LP tokens = sqrt(amount0 * amount1) minus locked minimum
            liquidity = _sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
            _mintTokens(address(0), MINIMUM_LIQUIDITY);
        } else {
            // Subsequent deposits: LP tokens based on the smaller ratio
            liquidity = _min(
                (amount0 * totalSupply) / _reserve0,
                (amount1 * totalSupply) / _reserve1
            );
        }
        require(liquidity > 0, "Pair: INSUFFICIENT_LIQUIDITY_MINTED");
        _mintTokens(to, liquidity);

        _update(balance0, balance1);
        emit Mint(msg.sender, amount0, amount1);
    }

What does this code do?

The mint function uses two different formulas depending on whether the pool is empty or already has liquidity.

  • First mint: LP tokens equal the square root of the product of both deposit amounts, minus MINIMUM_LIQUIDITY that gets permanently locked to address(0). This prevents a pool from having zero total supply after its creation.
  • Subsequent mints: LP tokens are proportional to whichever deposit ratio is smaller. This incentivizes depositing both tokens in the correct ratio.
  • The function detects how many tokens arrived by comparing current balances to stored reserves. Tokens must be transferred to the Pair before calling mint().

The contract still will not compile because mint() calls _sqrt(), _min(), _mintTokens(), and _update() which do not exist yet. You will add them after the burn function.

Implement the burn() function and private helpers

The burn() function is the inverse of mint. LP holders send their LP tokens to the Pair, then call burn() to receive their pro-rata share of both tokens back.

  • Add the burn() function after mint():
    function burn(address to) external lock returns (uint256 amount0, uint256 amount1) {
        uint256 balance0 = SimpleERC20(token0).balanceOf(address(this));
        uint256 balance1 = SimpleERC20(token1).balanceOf(address(this));
        uint256 liquidity = balanceOf[address(this)];

        // Calculate pro-rata share of each token
        amount0 = (liquidity * balance0) / totalSupply;
        amount1 = (liquidity * balance1) / totalSupply;
        require(amount0 > 0 && amount1 > 0, "Pair: INSUFFICIENT_LIQUIDITY_BURNED");

        // Burn the LP tokens and send underlying tokens back
        _burnTokens(address(this), liquidity);
        SimpleERC20(token0).transfer(to, amount0);
        SimpleERC20(token1).transfer(to, amount1);

        // Re-read balances after transfers
        balance0 = SimpleERC20(token0).balanceOf(address(this));
        balance1 = SimpleERC20(token1).balanceOf(address(this));

        _update(balance0, balance1);
        emit Burn(msg.sender, amount0, amount1, to);
    }

What does this code do?

The burn function reads how many LP tokens have been sent to the contract (balanceOf[address(this)]), then calculates a proportional share of both tokens.

If you hold 10% of the total LP supply, you get 10% of both token balances back. After transferring tokens out, it re-reads the balances and updates the stored reserves.

  • Now add the private helper functions after burn(). These are the internal utilities that mint() and burn() both depend on:
    // Sync stored reserves with actual token balances
    function _update(uint256 balance0, uint256 balance1) private {
        require(balance0 <= type(uint112).max && balance1 <= type(uint112).max, "Pair: OVERFLOW");
        reserve0 = uint112(balance0);
        reserve1 = uint112(balance1);
        blockTimestampLast = uint32(block.timestamp % 2 ** 32);
        emit Sync(reserve0, reserve1);
    }

    // Internal mint that updates supply and balance
    function _mintTokens(address to, uint256 amount) private {
        totalSupply += amount;
        balanceOf[to] += amount;
        emit Transfer(address(0), to, amount);
    }

    // Internal burn that updates supply and balance
    function _burnTokens(address from, uint256 amount) private {
        balanceOf[from] -= amount;
        totalSupply -= amount;
        emit Transfer(from, address(0), amount);
    }

    // Babylonian square root
    function _sqrt(uint256 y) private pure returns (uint256 z) {
        if (y > 3) {
            z = y;
            uint256 x = y / 2 + 1;
            while (x < z) {
                z = x;
                x = (y / x + x) / 2;
            }
        } else if (y != 0) {
            z = 1;
        }
    }

    // Return the smaller of two values
    function _min(uint256 a, uint256 b) private pure returns (uint256) {
        return a < b ? a : b;
    }

What do these helpers do?

  • _update() syncs the stored reserves with the actual token balances. It casts to uint112 and emits a Sync event so off-chain indexers can track pool state.
  • _mintTokens() and _burnTokens() manage the LP token supply without going through the external mint() on SimpleERC20 (which has no access control and is for test minting).
  • _sqrt() uses the Babylonian method to compute integer square roots. This is needed for the geometric mean calculation on the first liquidity deposit.
  • Save src/Pair.sol.
  • Verify the contract compiles by running:
forge build

You should see output indicating successful compilation with no errors.

Seeing a compilation error?

  • Make sure the closing } brace of the contract appears after all the helper functions. Every function must be inside the contract Pair block.
  • Check that your import path matches exactly: import {SimpleERC20} from "./SimpleERC20.sol";. The file must exist at src/SimpleERC20.sol.
  • Confirm the pragma is pragma solidity 0.8.24; and matches the solc version in your foundry.toml.

Help me debug my Pair.sol compilation error

✔️ Awesome, I've got everything!

Great. Double check you have saved src/Pair.sol and that forge build shows no errors.

ⓧ I'd like to double check the full code

Here is the complete src/Pair.sol file at this point. Compare yours against it:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {SimpleERC20} from "./SimpleERC20.sol";

contract Pair is SimpleERC20 {
    uint256 public constant MINIMUM_LIQUIDITY = 1000;

    address public factory;
    address public token0;
    address public token1;

    uint112 private reserve0;
    uint112 private reserve1;
    uint32 private blockTimestampLast;

    uint256 private unlocked = 1;

    event Mint(address indexed sender, uint256 amount0, uint256 amount1);
    event Burn(address indexed sender, uint256 amount0, uint256 amount1, address indexed to);
    event Swap(
        address indexed sender,
        uint256 amount0In,
        uint256 amount1In,
        uint256 amount0Out,
        uint256 amount1Out,
        address indexed to
    );
    event Sync(uint112 reserve0, uint112 reserve1);

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

    constructor() SimpleERC20("Uniswap V2 LP", "UNI-V2") {
        factory = msg.sender;
    }

    function initialize(address _token0, address _token1) external {
        require(msg.sender == factory, "Pair: FORBIDDEN");
        token0 = _token0;
        token1 = _token1;
    }

    function getReserves() public view returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast) {
        _reserve0 = reserve0;
        _reserve1 = reserve1;
        _blockTimestampLast = blockTimestampLast;
    }

    function mint(address to) external lock returns (uint256 liquidity) {
        (uint112 _reserve0, uint112 _reserve1,) = getReserves();
        uint256 balance0 = SimpleERC20(token0).balanceOf(address(this));
        uint256 balance1 = SimpleERC20(token1).balanceOf(address(this));
        uint256 amount0 = balance0 - _reserve0;
        uint256 amount1 = balance1 - _reserve1;

        if (totalSupply == 0) {
            liquidity = _sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
            _mintTokens(address(0), MINIMUM_LIQUIDITY);
        } else {
            liquidity = _min(
                (amount0 * totalSupply) / _reserve0,
                (amount1 * totalSupply) / _reserve1
            );
        }
        require(liquidity > 0, "Pair: INSUFFICIENT_LIQUIDITY_MINTED");
        _mintTokens(to, liquidity);

        _update(balance0, balance1);
        emit Mint(msg.sender, amount0, amount1);
    }

    function burn(address to) external lock returns (uint256 amount0, uint256 amount1) {
        uint256 balance0 = SimpleERC20(token0).balanceOf(address(this));
        uint256 balance1 = SimpleERC20(token1).balanceOf(address(this));
        uint256 liquidity = balanceOf[address(this)];

        amount0 = (liquidity * balance0) / totalSupply;
        amount1 = (liquidity * balance1) / totalSupply;
        require(amount0 > 0 && amount1 > 0, "Pair: INSUFFICIENT_LIQUIDITY_BURNED");

        _burnTokens(address(this), liquidity);
        SimpleERC20(token0).transfer(to, amount0);
        SimpleERC20(token1).transfer(to, amount1);

        balance0 = SimpleERC20(token0).balanceOf(address(this));
        balance1 = SimpleERC20(token1).balanceOf(address(this));

        _update(balance0, balance1);
        emit Burn(msg.sender, amount0, amount1, to);
    }

    function _update(uint256 balance0, uint256 balance1) private {
        require(balance0 <= type(uint112).max && balance1 <= type(uint112).max, "Pair: OVERFLOW");
        reserve0 = uint112(balance0);
        reserve1 = uint112(balance1);
        blockTimestampLast = uint32(block.timestamp % 2 ** 32);
        emit Sync(reserve0, reserve1);
    }

    function _mintTokens(address to, uint256 amount) private {
        totalSupply += amount;
        balanceOf[to] += amount;
        emit Transfer(address(0), to, amount);
    }

    function _burnTokens(address from, uint256 amount) private {
        balanceOf[from] -= amount;
        totalSupply -= amount;
        emit Transfer(from, address(0), amount);
    }

    function _sqrt(uint256 y) private pure returns (uint256 z) {
        if (y > 3) {
            z = y;
            uint256 x = y / 2 + 1;
            while (x < z) {
                z = x;
                x = (y / x + x) / 2;
            }
        } else if (y != 0) {
            z = 1;
        }
    }

    function _min(uint256 a, uint256 b) private pure returns (uint256) {
        return a < b ? a : b;
    }
}

Your Pair contract compiles and has full liquidity management. Next up, you will add the swap function that lets traders exchange one token for the other while enforcing the constant-product invariant.

Implement the Swap Function

Your Pair contract can accept and return liquidity. Now it needs to do what an AMM is actually built for: executing trades.

The swap function is where the constant-product formula comes alive. A trader sends tokens in, the contract sends different tokens out, and the K-invariant ensures the pool can never be drained below its mathematical threshold.

In this step, get ready to:
  • Understand how your existing _update() function syncs reserves after operations.
  • Implement the swap() function with optimistic transfers and K-invariant enforcement.
  • Verify the complete Pair contract compiles with all swap logic in place.
Understand how _update() syncs reserves

Your Pair contract already has a private _update() function that both mint() and burn() call at the end of their logic. The swap function will also call it, so understanding its role is critical.

What does _update() do?

Every time tokens move in or out of the pool, the contract's actual token balances change. But the swap function reads from stored reserves (not live balances) to calculate inputs and enforce the invariant.

The _update() function bridges this gap. It takes the current balances, casts them to uint112, stores them as the new reserves, records the block timestamp, and emits a Sync event. This keeps reserves in lockstep with reality after every mint, burn, or swap.

Implement the swap() function

The swap function uses a pattern called optimistic transfers. It sends tokens out first, then checks whether enough tokens were sent in to maintain the constant-product invariant. This is the same pattern used in flash loans.

  • In src/Pair.sol, find the burn() function's closing brace.
  • Add the following swap() function below it:
    function swap(uint256 amount0Out, uint256 amount1Out, address to) external lock {
        require(amount0Out > 0 || amount1Out > 0, "Pair: INSUFFICIENT_OUTPUT_AMOUNT");
        (uint112 _reserve0, uint112 _reserve1,) = getReserves();
        require(amount0Out < _reserve0 && amount1Out < _reserve1, "Pair: INSUFFICIENT_LIQUIDITY");

        // Optimistically transfer tokens out before checking invariant
        if (amount0Out > 0) SimpleERC20(token0).transfer(to, amount0Out);
        if (amount1Out > 0) SimpleERC20(token1).transfer(to, amount1Out);

        // Read actual balances after the outbound transfers
        uint256 balance0 = SimpleERC20(token0).balanceOf(address(this));
        uint256 balance1 = SimpleERC20(token1).balanceOf(address(this));

        // Calculate how much was sent in by comparing balances to expected post-transfer reserves
        uint256 amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
        uint256 amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
        require(amount0In > 0 || amount1In > 0, "Pair: INSUFFICIENT_INPUT_AMOUNT");

        // Enforce the K-invariant with 0.3% fee applied to input amounts
        uint256 balance0Adjusted = balance0 * 1000 - amount0In * 3;
        uint256 balance1Adjusted = balance1 * 1000 - amount1In * 3;
        require(
            balance0Adjusted * balance1Adjusted >= uint256(_reserve0) * uint256(_reserve1) * (1000 ** 2),
            "Pair: K"
        );

        // Sync reserves with new balances and emit the swap event
        _update(balance0, balance1);
        emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
    }

What does this code do?

  • The function takes amount0Out and amount1Out as parameters. The caller specifies how many tokens they want out. The contract figures out how many were sent in.
  • Tokens are transferred out first (optimistic transfer). Then the contract checks balances to see what was deposited.
  • The K-invariant check uses fee-adjusted balances: balance * 1000 - amountIn * 3. Multiplying by 1000 and subtracting 3 per unit of input effectively applies a 0.3% fee. The fee stays in the pool, making K grow over time.
  • The lock modifier prevents reentrancy. Since tokens are sent out before the invariant is verified, a malicious token contract could try to re-enter swap() during the transfer. The lock blocks this.

💡 Why optimistic transfers instead of transfer-then-check?

Uniswap V2 sends tokens out before confirming the invariant holds. This seems risky, but it enables powerful patterns like flash swaps (borrow tokens, use them, repay in the same transaction).

The safety guarantee comes from the require statement at the end. If the K-invariant is violated after everything settles, the entire transaction reverts. No matter what happens in between, the pool's math must check out or nothing happened.

Verify the complete Pair contract compiles

Your getReserves() function (already in place from the previous step) is how external contracts read the pool's current state. The swap function reads reserves internally, but any Router or UI will call getReserves() to calculate expected swap outputs off-chain before submitting a trade.

  • Save src/Pair.sol.
  • Confirm the complete Pair contract compiles by running:
forge build
  • You should see a successful compilation with no errors. The Pair contract now has complete swap logic with fee enforcement.

Seeing a compilation error?

  • Check that the swap() function is placed inside the Pair contract (between burn() and _update()), not accidentally outside the contract braces.
  • Verify the require statement for the K-invariant uses 1000 ** 2 (not 1000 * 2). The exponent operator is ** in Solidity.
  • Make sure uint256(_reserve0) casts are present in the K-check. Without them, uint112 * uint112 can overflow.

Help me debug a Solidity compilation error in my Pair swap function.

✔️ Awesome, I've got everything!

Great. Double check you've saved src/Pair.sol and the build passes cleanly.

ⓧ I'd like to double check the full code

Here is the complete src/Pair.sol at this point:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {SimpleERC20} from "./SimpleERC20.sol";

contract Pair is SimpleERC20 {
    uint256 public constant MINIMUM_LIQUIDITY = 1000;

    address public factory;
    address public token0;
    address public token1;

    uint112 private reserve0;
    uint112 private reserve1;
    uint32 private blockTimestampLast;

    uint256 private unlocked = 1;

    event Mint(address indexed sender, uint256 amount0, uint256 amount1);
    event Burn(address indexed sender, uint256 amount0, uint256 amount1, address indexed to);
    event Swap(
        address indexed sender,
        uint256 amount0In,
        uint256 amount1In,
        uint256 amount0Out,
        uint256 amount1Out,
        address indexed to
    );
    event Sync(uint112 reserve0, uint112 reserve1);

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

    constructor() SimpleERC20("Uniswap V2 LP", "UNI-V2") {
        factory = msg.sender;
    }

    function initialize(address _token0, address _token1) external {
        require(msg.sender == factory, "Pair: FORBIDDEN");
        token0 = _token0;
        token1 = _token1;
    }

    function getReserves() public view returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast) {
        _reserve0 = reserve0;
        _reserve1 = reserve1;
        _blockTimestampLast = blockTimestampLast;
    }

    function mint(address to) external lock returns (uint256 liquidity) {
        (uint112 _reserve0, uint112 _reserve1,) = getReserves();
        uint256 balance0 = SimpleERC20(token0).balanceOf(address(this));
        uint256 balance1 = SimpleERC20(token1).balanceOf(address(this));
        uint256 amount0 = balance0 - _reserve0;
        uint256 amount1 = balance1 - _reserve1;

        if (totalSupply == 0) {
            liquidity = _sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
            _mintTokens(address(0), MINIMUM_LIQUIDITY);
        } else {
            liquidity = _min(
                (amount0 * totalSupply) / _reserve0,
                (amount1 * totalSupply) / _reserve1
            );
        }
        require(liquidity > 0, "Pair: INSUFFICIENT_LIQUIDITY_MINTED");
        _mintTokens(to, liquidity);

        _update(balance0, balance1);
        emit Mint(msg.sender, amount0, amount1);
    }

    function burn(address to) external lock returns (uint256 amount0, uint256 amount1) {
        uint256 balance0 = SimpleERC20(token0).balanceOf(address(this));
        uint256 balance1 = SimpleERC20(token1).balanceOf(address(this));
        uint256 liquidity = balanceOf[address(this)];

        amount0 = (liquidity * balance0) / totalSupply;
        amount1 = (liquidity * balance1) / totalSupply;
        require(amount0 > 0 && amount1 > 0, "Pair: INSUFFICIENT_LIQUIDITY_BURNED");

        _burnTokens(address(this), liquidity);
        SimpleERC20(token0).transfer(to, amount0);
        SimpleERC20(token1).transfer(to, amount1);

        balance0 = SimpleERC20(token0).balanceOf(address(this));
        balance1 = SimpleERC20(token1).balanceOf(address(this));

        _update(balance0, balance1);
        emit Burn(msg.sender, amount0, amount1, to);
    }

    function swap(uint256 amount0Out, uint256 amount1Out, address to) external lock {
        require(amount0Out > 0 || amount1Out > 0, "Pair: INSUFFICIENT_OUTPUT_AMOUNT");
        (uint112 _reserve0, uint112 _reserve1,) = getReserves();
        require(amount0Out < _reserve0 && amount1Out < _reserve1, "Pair: INSUFFICIENT_LIQUIDITY");

        if (amount0Out > 0) SimpleERC20(token0).transfer(to, amount0Out);
        if (amount1Out > 0) SimpleERC20(token1).transfer(to, amount1Out);

        uint256 balance0 = SimpleERC20(token0).balanceOf(address(this));
        uint256 balance1 = SimpleERC20(token1).balanceOf(address(this));

        uint256 amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
        uint256 amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
        require(amount0In > 0 || amount1In > 0, "Pair: INSUFFICIENT_INPUT_AMOUNT");

        uint256 balance0Adjusted = balance0 * 1000 - amount0In * 3;
        uint256 balance1Adjusted = balance1 * 1000 - amount1In * 3;
        require(
            balance0Adjusted * balance1Adjusted >= uint256(_reserve0) * uint256(_reserve1) * (1000 ** 2),
            "Pair: K"
        );

        _update(balance0, balance1);
        emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
    }

    function _update(uint256 balance0, uint256 balance1) private {
        require(balance0 <= type(uint112).max && balance1 <= type(uint112).max, "Pair: OVERFLOW");
        reserve0 = uint112(balance0);
        reserve1 = uint112(balance1);
        blockTimestampLast = uint32(block.timestamp % 2 ** 32);
        emit Sync(reserve0, reserve1);
    }

    function _mintTokens(address to, uint256 amount) private {
        totalSupply += amount;
        balanceOf[to] += amount;
        emit Transfer(address(0), to, amount);
    }

    function _burnTokens(address from, uint256 amount) private {
        balanceOf[from] -= amount;
        totalSupply -= amount;
        emit Transfer(from, address(0), amount);
    }

    function _sqrt(uint256 y) private pure returns (uint256 z) {
        if (y > 3) {
            z = y;
            uint256 x = y / 2 + 1;
            while (x < z) {
                z = x;
                x = (y / x + x) / 2;
            }
        } else if (y != 0) {
            z = 1;
        }
    }

    function _min(uint256 a, uint256 b) private pure returns (uint256) {
        return a < b ? a : b;
    }
}

Your Pair contract is now fully functional. It can hold liquidity, mint and burn LP tokens, and execute swaps with fee enforcement. Next up, you will build the Factory contract that deploys these Pair contracts with deterministic addresses using CREATE2.

Build the Factory Contract

Your Pair contract handles all the pool logic, but someone needs to deploy new Pair instances when a new token pair is requested. That is the Factory's job.

The Factory is the registry and deployer for your protocol. It keeps track of every Pair ever created and deploys them at deterministic addresses using CREATE2, so anyone can compute a pair's address off-chain without querying the blockchain.

In this step, get ready to:
  • Create src/Factory.sol with state variables and the createPair function.
  • Deploy Pair contracts via CREATE2 for deterministic addresses.
  • Add a helper to enumerate all deployed pairs.
Create the Factory contract

The Factory needs to validate inputs, sort token addresses, deploy a new Pair via CREATE2, and record the deployment in its registry.

  • Create a new file src/Factory.sol inside your uniswap-v2-clone project folder.
  • Build the full Factory contract by pasting this code into the file:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {Pair} from "./Pair.sol";

contract Factory {
    // Maps token0 => token1 => pair address (stored both directions)
    mapping(address => mapping(address => address)) public getPair;
    // Array of all deployed pair addresses
    address[] public allPairs;

    event PairCreated(address indexed token0, address indexed token1, address pair, uint256 pairIndex);

    function createPair(address tokenA, address tokenB) external returns (address pair) {
        // Prevent creating a pair of the same token
        require(tokenA != tokenB, "Factory: IDENTICAL_ADDRESSES");
        // Sort tokens so token0 < token1 (canonical ordering)
        (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
        // Prevent zero-address token
        require(token0 != address(0), "Factory: ZERO_ADDRESS");
        // Prevent duplicate pairs
        require(getPair[token0][token1] == address(0), "Factory: PAIR_EXISTS");

        // Get the Pair contract creation bytecode
        bytes memory bytecode = type(Pair).creationCode;
        // Compute a unique salt from the sorted token addresses
        bytes32 salt = keccak256(abi.encodePacked(token0, token1));
        // Deploy the Pair using CREATE2 for a deterministic address
        assembly {
            pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
        }

        // Initialize the pair with its token addresses
        Pair(pair).initialize(token0, token1);
        // Record the pair in both lookup directions
        getPair[token0][token1] = pair;
        getPair[token1][token0] = pair;
        allPairs.push(pair);

        emit PairCreated(token0, token1, pair, allPairs.length);
    }

    // Returns total number of deployed pairs for enumeration
    function allPairsLength() external view returns (uint256) {
        return allPairs.length;
    }
}

What does this code do?

The Factory contract is the single entry point for creating new trading pairs. It enforces several invariants before deploying anything.

  • Token sorting: token0 is always the lower address. This guarantees one canonical pair per token combination, regardless of the order someone calls createPair.
  • Duplicate prevention: the getPair mapping stores the pair address in both directions (A-to-B and B-to-A) so lookups work regardless of argument order.
  • CREATE2 deployment: instead of the normal CREATE opcode, the Factory uses CREATE2 with a deterministic salt derived from the token addresses.
  • allPairsLength() is a convenience view function that returns the total number of deployed pairs for enumeration.
  • Save src/Factory.sol.
  • Confirm everything compiles by running:
forge build
  • You should see a successful compilation message with no errors.

Seeing a compilation error?

  • Check that the import {Pair} from "./Pair.sol"; line matches your file path exactly. The Pair contract must be in the same src/ directory.
  • Verify your Pair.sol still compiles on its own. The Factory imports the full Pair contract (it needs the creation bytecode), so any error in Pair will surface here too.
  • Make sure the pragma solidity 0.8.24; matches across all files and your foundry.toml has solc = "0.8.24".

Help me debug my Factory.sol compilation error

Understand the CREATE2 deployment

The assembly block in createPair is where the real magic happens. It uses the CREATE2 opcode to deploy the Pair at a predictable address.

Why CREATE2 instead of regular CREATE?

With the normal CREATE opcode, a contract's address depends on the deployer's nonce. Every new deployment changes the nonce, making addresses unpredictable from the outside.

CREATE2 computes the address from a formula: keccak256(0xff ++ deployer ++ salt ++ keccak256(init_code)). Because the Factory address is fixed, the Pair bytecode is fixed, and the salt is derived from the two token addresses, the resulting pair address is fully deterministic.

This means any off-chain application (a frontend, a router contract, an indexer) can compute the pair address for any two tokens without making a single blockchain query.

💡 What does the assembly block do line by line?

  • type(Pair).creationCode retrieves the compiled creation bytecode of the Pair contract. This is the code the EVM runs to deploy the contract.
  • add(bytecode, 32) skips the first 32 bytes of the bytes memory variable (which stores the array length) to point at the actual bytecode data.
  • mload(bytecode) reads the length of the bytecode from those first 32 bytes.
  • create2(0, add(bytecode, 32), mload(bytecode), salt) calls the CREATE2 opcode with 0 wei endowment, the bytecode pointer, its length, and the computed salt. It returns the deployed contract address.
Verify the Factory compiles

At this point your protocol has three contracts that all reference each other. A clean compile confirms the import chain is intact.

  • Run forge build one more time to confirm the entire project compiles cleanly.
  • You should see output listing SimpleERC20, Pair, and Factory all compiled successfully.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/Factory.sol before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/Factory.sol for comparison:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {Pair} from "./Pair.sol";

contract Factory {
    mapping(address => mapping(address => address)) public getPair;
    address[] public allPairs;

    event PairCreated(address indexed token0, address indexed token1, address pair, uint256 pairIndex);

    function createPair(address tokenA, address tokenB) external returns (address pair) {
        require(tokenA != tokenB, "Factory: IDENTICAL_ADDRESSES");
        (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
        require(token0 != address(0), "Factory: ZERO_ADDRESS");
        require(getPair[token0][token1] == address(0), "Factory: PAIR_EXISTS");

        bytes memory bytecode = type(Pair).creationCode;
        bytes32 salt = keccak256(abi.encodePacked(token0, token1));
        assembly {
            pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
        }

        Pair(pair).initialize(token0, token1);
        getPair[token0][token1] = pair;
        getPair[token1][token0] = pair;
        allPairs.push(pair);

        emit PairCreated(token0, token1, pair, allPairs.length);
    }

    function allPairsLength() external view returns (uint256) {
        return allPairs.length;
    }
}

Your protocol now has all its core contracts in place. The Factory can deploy Pair contracts on demand, and each Pair handles the AMM logic. Next up, you will write a comprehensive test suite to prove everything works correctly.

Write the Test Suite

Your contracts compile, but compilation only checks syntax. Smart contract bugs are permanent once deployed. A comprehensive test suite is the only way to prove your AMM math, fee enforcement, and security properties actually work before real tokens are at stake.

In this step, you will write a Foundry test file that exercises every critical path: minting and burning liquidity, executing swaps, verifying the K-invariant holds after fees, and confirming that invalid operations revert.

In this step, get ready to:
  • Create the test file with setup and liquidity tests.
  • Write swap tests that prove the K-invariant and fee mechanics.
  • Write security tests and run the full suite.
Create the test file with setup and liquidity tests

Forge tests are Solidity contracts that inherit from forge-std's Test base contract. This gives you assertion helpers like assertEq, cheatcodes like vm.expectRevert, and a setUp() function that runs before each test.

  • Create the file test/Pair.t.sol and add the following code for the test setup, initial liquidity test, and burn test:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import "forge-std/Test.sol";
import {SimpleERC20} from "../src/SimpleERC20.sol";
import {Pair} from "../src/Pair.sol";
import {Factory} from "../src/Factory.sol";

contract PairTest is Test {
    Factory factory;
    SimpleERC20 tokenA;
    SimpleERC20 tokenB;
    Pair pair;

    address alice = makeAddr("alice");

    // Deploys all contracts and seeds the test contract with tokens
    function setUp() public {
        factory = new Factory();
        tokenA = new SimpleERC20("Token A", "TKA");
        tokenB = new SimpleERC20("Token B", "TKB");

        address pairAddr = factory.createPair(address(tokenA), address(tokenB));
        pair = Pair(pairAddr);

        tokenA.mint(address(this), 100 ether);
        tokenB.mint(address(this), 100 ether);
    }

    // Test: first liquidity deposit mints correct LP tokens
    function test_MintInitialLiquidity() public {
        tokenA.transfer(address(pair), 1 ether);
        tokenB.transfer(address(pair), 4 ether);

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

        uint256 expected = _sqrt(1 ether * 4 ether) - 1000;
        assertEq(liquidity, expected);
        assertEq(pair.balanceOf(address(this)), expected);
    }

    // Test: burning LP tokens returns proportional underlying tokens
    function test_Burn() public {
        tokenA.transfer(address(pair), 1 ether);
        tokenB.transfer(address(pair), 4 ether);
        pair.mint(address(this));

        uint256 lpBalance = pair.balanceOf(address(this));
        pair.transfer(address(pair), lpBalance);
        pair.burn(address(this));

        assertGt(tokenA.balanceOf(address(this)), 99 ether);
        assertGt(tokenB.balanceOf(address(this)), 99 ether);
    }

What does this code do?

setUp() deploys the entire protocol fresh for every test: a Factory, two mock tokens, a Pair created through the Factory, and 100 ether of each token minted to the test contract. This isolation guarantees no test leaks state into another.

  • test_MintInitialLiquidity() verifies the first deposit follows the sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY formula exactly.
  • test_Burn() checks that burning all LP tokens returns nearly all deposited tokens. The tiny remainder is the permanently locked MINIMUM_LIQUIDITY.
  • Save test/Pair.t.sol.
  • The file is not complete yet, so do not run tests. You will add the remaining tests in the next substeps.
Write the swap tests

Swap correctness is the most critical property of an AMM. You need to prove that output amounts match the constant-product formula, that the K-invariant never decreases (it grows because fees accrue), and that impossible swaps revert.

  • In test/Pair.t.sol, add the following three test functions below test_Burn():
    // Test: swapping token0 for token1 delivers correct output
    function test_SwapToken0ForToken1() public {
        tokenA.transfer(address(pair), 5 ether);
        tokenB.transfer(address(pair), 10 ether);
        pair.mint(address(this));

        uint256 swapAmount = 1 ether;
        uint256 expectedOut = getAmountOut(swapAmount, 5 ether, 10 ether);

        tokenA.transfer(address(pair), swapAmount);
        pair.swap(0, expectedOut, alice);

        assertEq(tokenB.balanceOf(alice), expectedOut);
    }

    // Test: K only increases after a swap (fees accrue to LPs)
    function test_KInvariantHolds() public {
        tokenA.transfer(address(pair), 5 ether);
        tokenB.transfer(address(pair), 10 ether);
        pair.mint(address(this));

        (uint112 r0Before, uint112 r1Before,) = pair.getReserves();
        uint256 kBefore = uint256(r0Before) * uint256(r1Before);

        uint256 swapAmount = 1 ether;
        uint256 expectedOut = getAmountOut(swapAmount, 5 ether, 10 ether);
        tokenA.transfer(address(pair), swapAmount);
        pair.swap(0, expectedOut, alice);

        (uint112 r0After, uint112 r1After,) = pair.getReserves();
        uint256 kAfter = uint256(r0After) * uint256(r1After);

        assertGe(kAfter, kBefore);
    }

    // Test: requesting more output than reserves reverts
    function test_SwapRevertsInsufficientOutput() public {
        tokenA.transfer(address(pair), 5 ether);
        tokenB.transfer(address(pair), 10 ether);
        pair.mint(address(this));

        tokenA.transfer(address(pair), 1 ether);
        vm.expectRevert("Pair: INSUFFICIENT_LIQUIDITY");
        pair.swap(0, 11 ether, alice);
    }

What does this code do?

  • test_SwapToken0ForToken1() provisions a 5:10 pool, swaps 1 ether of tokenA in, and asserts alice receives the exact output predicted by the 0.3% fee formula.
  • test_KInvariantHolds() snapshots K (reserve0 * reserve1) before and after a swap. Because the 0.3% fee stays in the pool, K must be greater than or equal to its previous value.
  • test_SwapRevertsInsufficientOutput() tries to withdraw 11 ether from a pool holding only 10. The Pair must revert with "Pair: INSUFFICIENT_LIQUIDITY".
  • Save test/Pair.t.sol.

Why does K go up instead of staying constant?

The name "constant product" is slightly misleading. The 0.3% fee is deducted from the input but the full balance (including the fee) remains in the pool. This means reserves grow by the fee amount on every swap, pushing K upward. LPs profit from this growth when they burn their LP tokens.

Write security tests and run the suite

The final piece is a duplicate-pair guard test and the internal helper functions that your tests rely on for output calculations.

  • In test/Pair.t.sol, add the following code below test_SwapRevertsInsufficientOutput() to close out the contract:
    // Test: creating the same pair twice reverts
    function test_FactoryCreatePairDuplicate() public {
        vm.expectRevert("Factory: PAIR_EXISTS");
        factory.createPair(address(tokenA), address(tokenB));
    }

    // Helper: calculates expected output with 0.3% fee (997/1000)
    function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) internal pure returns (uint256) {
        uint256 amountInWithFee = amountIn * 997;
        uint256 numerator = amountInWithFee * reserveOut;
        uint256 denominator = reserveIn * 1000 + amountInWithFee;
        return numerator / denominator;
    }

    // Helper: integer square root (same algorithm as the Pair contract)
    function _sqrt(uint256 y) internal pure returns (uint256 z) {
        if (y > 3) {
            z = y;
            uint256 x = y / 2 + 1;
            while (x < z) {
                z = x;
                x = (y / x + x) / 2;
            }
        } else if (y != 0) {
            z = 1;
        }
    }
}

What does this code do?

  • test_FactoryCreatePairDuplicate() verifies that the Factory rejects duplicate pair creation. Since setUp() already created the tokenA/tokenB pair, calling createPair again must revert.
  • getAmountOut() mirrors the constant-product formula with the 0.3% fee (multiply input by 997 instead of 1000). Your swap tests use this to calculate the exact expected output.
  • _sqrt() duplicates the Pair's Babylonian square root so test_MintInitialLiquidity can independently compute the expected LP amount.
  • Save test/Pair.t.sol.
  • Run the full test suite with verbose output by running this command:
forge test -vvv

You should see all 6 tests passing with green checkmarks. The -vvv flag shows detailed traces for each test, including emitted events and return values.

Tests failing?

  • If you see a compilation error, make sure the closing brace } at the end of the file closes the PairTest contract. Each function must be inside the contract body.
  • If test_MintInitialLiquidity fails, check that your _sqrt helper matches the one in the Pair contract exactly. Any difference in the algorithm produces a different expected value.
  • If swap tests revert unexpectedly, verify that getAmountOut() uses 997 (not 1000). The fee-adjusted calculation must match the Pair's K-invariant check.

Help me debug my failing Foundry tests for the Uniswap V2 pair contract.

✔️ Awesome, I've got everything!

Great. Double check you have saved test/Pair.t.sol and all 6 tests pass.

ⓧ I'd like to double check the full code

Here is the complete test/Pair.t.sol file:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import "forge-std/Test.sol";
import {SimpleERC20} from "../src/SimpleERC20.sol";
import {Pair} from "../src/Pair.sol";
import {Factory} from "../src/Factory.sol";

contract PairTest is Test {
    Factory factory;
    SimpleERC20 tokenA;
    SimpleERC20 tokenB;
    Pair pair;

    address alice = makeAddr("alice");

    function setUp() public {
        factory = new Factory();
        tokenA = new SimpleERC20("Token A", "TKA");
        tokenB = new SimpleERC20("Token B", "TKB");

        address pairAddr = factory.createPair(address(tokenA), address(tokenB));
        pair = Pair(pairAddr);

        tokenA.mint(address(this), 100 ether);
        tokenB.mint(address(this), 100 ether);
    }

    function test_MintInitialLiquidity() public {
        tokenA.transfer(address(pair), 1 ether);
        tokenB.transfer(address(pair), 4 ether);

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

        uint256 expected = _sqrt(1 ether * 4 ether) - 1000;
        assertEq(liquidity, expected);
        assertEq(pair.balanceOf(address(this)), expected);
    }

    function test_Burn() public {
        tokenA.transfer(address(pair), 1 ether);
        tokenB.transfer(address(pair), 4 ether);
        pair.mint(address(this));

        uint256 lpBalance = pair.balanceOf(address(this));
        pair.transfer(address(pair), lpBalance);
        pair.burn(address(this));

        assertGt(tokenA.balanceOf(address(this)), 99 ether);
        assertGt(tokenB.balanceOf(address(this)), 99 ether);
    }

    function test_SwapToken0ForToken1() public {
        tokenA.transfer(address(pair), 5 ether);
        tokenB.transfer(address(pair), 10 ether);
        pair.mint(address(this));

        uint256 swapAmount = 1 ether;
        uint256 expectedOut = getAmountOut(swapAmount, 5 ether, 10 ether);

        tokenA.transfer(address(pair), swapAmount);
        pair.swap(0, expectedOut, alice);

        assertEq(tokenB.balanceOf(alice), expectedOut);
    }

    function test_KInvariantHolds() public {
        tokenA.transfer(address(pair), 5 ether);
        tokenB.transfer(address(pair), 10 ether);
        pair.mint(address(this));

        (uint112 r0Before, uint112 r1Before,) = pair.getReserves();
        uint256 kBefore = uint256(r0Before) * uint256(r1Before);

        uint256 swapAmount = 1 ether;
        uint256 expectedOut = getAmountOut(swapAmount, 5 ether, 10 ether);
        tokenA.transfer(address(pair), swapAmount);
        pair.swap(0, expectedOut, alice);

        (uint112 r0After, uint112 r1After,) = pair.getReserves();
        uint256 kAfter = uint256(r0After) * uint256(r1After);

        assertGe(kAfter, kBefore);
    }

    function test_SwapRevertsInsufficientOutput() public {
        tokenA.transfer(address(pair), 5 ether);
        tokenB.transfer(address(pair), 10 ether);
        pair.mint(address(this));

        tokenA.transfer(address(pair), 1 ether);
        vm.expectRevert("Pair: INSUFFICIENT_LIQUIDITY");
        pair.swap(0, 11 ether, alice);
    }

    function test_FactoryCreatePairDuplicate() public {
        vm.expectRevert("Factory: PAIR_EXISTS");
        factory.createPair(address(tokenA), address(tokenB));
    }

    function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) internal pure returns (uint256) {
        uint256 amountInWithFee = amountIn * 997;
        uint256 numerator = amountInWithFee * reserveOut;
        uint256 denominator = reserveIn * 1000 + amountInWithFee;
        return numerator / denominator;
    }

    function _sqrt(uint256 y) internal pure returns (uint256 z) {
        if (y > 3) {
            z = y;
            uint256 x = y / 2 + 1;
            while (x < z) {
                z = x;
                x = (y / x + x) / 2;
            }
        } else if (y != 0) {
            z = 1;
        }
    }
}

Your AMM protocol now has mathematical proof that every critical path works. The K-invariant holds, liquidity math is correct, and invalid operations revert safely. You have a production-grade foundation ready for the Secret Mission.

Secret mission

Build a Simplified Router

Your Pair contract works, but interacting with it requires manually calculating amounts, transferring tokens, and calling low-level functions. Real users need slippage protection and deadline enforcement to stay safe. Can you build a Router that wraps all of this into single, user-friendly transactions?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs or running services to worry about.

Decide whether to keep your project files for future reference or delete them entirely.

Resources you used:

  • The uniswap-v2-clone project folder on your Desktop (contains all Solidity source files, test files, compiled artifacts, and the forge-std library).

Keep everything running

No action needed. Choose this if you want to keep experimenting with your AMM contracts, adding new features, or revisiting the code later.

  • Your uniswap-v2-clone folder stays on your Desktop.
  • You can run forge test anytime to re-run your test suite.
  • Foundry remains installed on your machine for future Solidity projects.

Pause - I'll come back to this later

Since this is a local project with no running processes, there is nothing to pause. Your files sit on disk and use minimal storage.

  • Your project folder will be waiting on your Desktop whenever you want to return.
  • To pick back up, open your terminal, navigate to uniswap-v2-clone, and run forge test to confirm everything still compiles.

Delete - I don't want to use this again

Remove all project files from your machine. This frees up disk space and removes the compiled artifacts.

  • Delete the project folder by running this command:
rm -rf ~/Desktop/uniswap-v2-clone
  • On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\uniswap-v2-clone
  • Confirm the folder is gone by running:
ls ~/Desktop/uniswap-v2-clone

You should see a "No such file or directory" message confirming deletion.

Nice Work!

Nice Work!

You just built a fully functional Uniswap V2-style decentralized exchange protocol from scratch. Your local Foundry project now contains a complete AMM with proven mathematical invariants and security properties.

You've learned how to:

  • Implement a Pair contract with the constant-product AMM formula (x * y = k), a 0.3% swap fee, LP token minting/burning, and a reentrancy guard.
  • Build a Factory contract that deploys new Pair contracts via CREATE2 for deterministic, off-chain-computable addresses.
  • Write a comprehensive Foundry test suite proving K-invariant enforcement, correct liquidity math, and blocked reentrancy attacks.
  • Secret Mission: Built a Router contract with slippage protection and deadline enforcement, wrapping raw pair operations in a safe, user-friendly interface.

Ready to quiz yourself?