Project 3: Dex

Project 3: Decentralized Exchange (DEX) with Automated Market Maker

Overview

In this project, you'll build a decentralized exchange (DEX) with an automated market maker (AMM) based on the constant product formula (x * y = k). This project combines concepts from multiple modules, including ERC20 tokens, liquidity pools, security considerations, gas optimization, and advanced smart contract development.

Learning Objectives

Project Structure

dex-project/
├── contracts/
│   ├── DEXFactory.sol
│   ├── DEXPair.sol
│   ├── DEXRouter.sol
│   └── interfaces/
│       ├── IDEX.sol
│       └── IERC20.sol
├── test/
│   ├── DEXFactory.test.js
│   ├── DEXPair.test.js
│   ├── DEXRouter.test.js
│   └── integration.test.js
├── scripts/
│   ├── deploy.js
│   └── setup.js
└── README.md

Step 1: Setting Up the Development Environment

  1. Install Node.js and npm if you haven't already
  2. Create a new directory for your project
  3. Initialize a new Hardhat project:
mkdir dex-project
cd dex-project
npm init -y
npm install --save-dev hardhat @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers @openzeppelin/contracts
npx hardhat
  1. Select "Create a basic sample project" when prompted
  2. Configure your hardhat.config.js file:
require("@nomiclabs/hardhat-waffle");

module.exports = {
  solidity: {
    version: "0.8.17",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    }
  },
  networks: {
    hardhat: {
    },
    goerli: {
      url: "https://goerli.infura.io/v3/YOUR_INFURA_KEY",
      accounts: ["YOUR_PRIVATE_KEY"]
    }
  }
};

Step 2: Creating the Interfaces

First, let's create the interfaces for our DEX. Create a directory called interfaces in the contracts directory.

Create a file named IDEX.sol in the contracts/interfaces directory:

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

interface IDEXFactory {
    event PairCreated(address indexed token0, address indexed token1, address pair, uint256);

    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(uint256) external view returns (address pair);
    function allPairsLength() external view returns (uint256);

    function createPair(address tokenA, address tokenB) external returns (address pair);

    function setFeeTo(address) external;
    function setFeeToSetter(address) external;
}

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

    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);

    function MINIMUM_LIQUIDITY() external pure returns (uint256);
    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 (uint256);
    function price1CumulativeLast() external view returns (uint256);
    function kLast() external view returns (uint256);

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

    function initialize(address, address) external;
}

interface IDEXRouter {
    function factory() external view returns (address);

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

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

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

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

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

Create a file named IERC20.sol in the contracts/interfaces directory:

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

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

    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function decimals() external view returns (uint8);
    function totalSupply() external view returns (uint256);
    function balanceOf(address owner) external view returns (uint256);
    function allowance(address owner, address spender) external view returns (uint256);

    function approve(address spender, uint256 value) external returns (bool);
    function transfer(address to, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
}

Step 3: Implementing the DEX Factory

Create a file named DEXFactory.sol in the contracts directory:

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

import "./interfaces/IDEX.sol";
import "./DEXPair.sol";

/**
 * @title DEXFactory
 * @dev Factory contract for creating and managing DEX pairs
 */
contract DEXFactory is IDEXFactory {
    address public override feeTo;
    address public override feeToSetter;

    mapping(address => mapping(address => address)) public override getPair;
    address[] public override allPairs;

    /**
     * @dev Constructor
     * @param _feeToSetter Address that can change the fee recipient
     */
    constructor(address _feeToSetter) {
        feeToSetter = _feeToSetter;
    }

    /**
     * @dev Get the number of pairs
     * @return The number of pairs created
     */
    function allPairsLength() external view override returns (uint256) {
        return allPairs.length;
    }

    /**
     * @dev Create a new pair
     * @param tokenA First token address
     * @param tokenB Second token address
     * @return pair Address of the created pair
     */
    function createPair(address tokenA, address tokenB) external override returns (address pair) {
        require(tokenA != tokenB, "DEX: IDENTICAL_ADDRESSES");

        // Sort tokens to ensure deterministic pair addresses
        (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);

        require(token0 != address(0), "DEX: ZERO_ADDRESS");
        require(getPair[token0][token1] == address(0), "DEX: PAIR_EXISTS");

        // Create pair contract
        bytes memory bytecode = type(DEXPair).creationCode;
        bytes32 salt = keccak256(abi.encodePacked(token0, token1));

        assembly {
            pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
        }

        // Initialize pair
        IDEXPair(pair).initialize(token0, token1);

        // Store pair mappings
        getPair[token0][token1] = pair;
        getPair[token1][token0] = pair;
        allPairs.push(pair);

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

    /**
     * @dev Set the fee recipient
     * @param _feeTo Address to receive fees
     */
    function setFeeTo(address _feeTo) external override {
        require(msg.sender == feeToSetter, "DEX: FORBIDDEN");
        feeTo = _feeTo;
    }

    /**
     * @dev Set the fee setter
     * @param _feeToSetter Address that can change the fee recipient
     */
    function setFeeToSetter(address _feeToSetter) external override {
        require(msg.sender == feeToSetter, "DEX: FORBIDDEN");
        feeToSetter = _feeToSetter;
    }
}

Step 4: Implementing the DEX Pair

Create a file named DEXPair.sol in the contracts directory:

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

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "./interfaces/IDEX.sol";
import "./interfaces/IERC20.sol";

/**
 * @title DEXPair
 * @dev Liquidity pool for a pair of tokens
 */
contract DEXPair is IDEXPair, ERC20, ReentrancyGuard {
    uint256 public constant override MINIMUM_LIQUIDITY = 10**3;

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

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

    uint256 public override price0CumulativeLast;
    uint256 public override price1CumulativeLast;
    uint256 public override kLast; // reserve0 * reserve1, as of immediately after the most recent liquidity event

    uint256 private unlocked = 1;

    /**
     * @dev Modifier to prevent reentrancy
     */
    modifier lock() {
        require(unlocked == 1, "DEX: LOCKED");
        unlocked = 0;
        _;
        unlocked = 1;
    }

    /**
     * @dev Constructor
     */
    constructor() ERC20("DEX-LP", "DEX-LP") {
        factory = msg.sender;
    }

    /**
     * @dev Initialize the pair with tokens
     * @param _token0 First token address
     * @param _token1 Second token address
     */
    function initialize(address _token0, address _token1) external override {
        require(msg.sender == factory, "DEX: FORBIDDEN");
        token0 = _token0;
        token1 = _token1;
    }

    /**
     * @dev Get the reserves of the pair
     * @return _reserve0 Reserve of token0
     * @return _reserve1 Reserve of token1
     * @return _blockTimestampLast Timestamp of the last update
     */
    function getReserves() public view override returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast) {
        _reserve0 = reserve0;
        _reserve1 = reserve1;
        _blockTimestampLast = blockTimestampLast;
    }

    /**
     * @dev Update reserves and, on the first call per block, price accumulators
     * @param balance0 Current balance of token0
     * @param balance1 Current balance of token1
     */
    function _update(uint256 balance0, uint256 balance1) private {
        require(balance0 <= type(uint112).max && balance1 <= type(uint112).max, "DEX: OVERFLOW");

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

        if (timeElapsed > 0 && reserve0 != 0 && reserve1 != 0) {
            // Update price accumulators
            price0CumulativeLast += uint256(reserve1) * timeElapsed / reserve0;
            price1CumulativeLast += uint256(reserve0) * timeElapsed / reserve1;
        }

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

        emit Sync(reserve0, reserve1);
    }

    /**
     * @dev Mint liquidity tokens
     * @param to Address to receive liquidity tokens
     * @return liquidity Amount of liquidity tokens minted
     */
    function mint(address to) external override lock nonReentrant returns (uint256 liquidity) {
        (uint112 _reserve0, uint112 _reserve1,) = getReserves();

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

        uint256 amount0 = balance0 - _reserve0;
        uint256 amount1 = balance1 - _reserve1;

        // Calculate protocol fee
        bool feeOn = _mintFee(_reserve0, _reserve1);

        // Calculate liquidity amount
        uint256 _totalSupply = totalSupply();

        if (_totalSupply == 0) {
            // Initial liquidity
            liquidity = Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
            _mint(address(0), MINIMUM_LIQUIDITY); // Permanently lock the first MINIMUM_LIQUIDITY tokens
        } else {
            // Subsequent liquidity
            liquidity = Math.min(
                (amount0 * _totalSupply) / _reserve0,
                (amount1 * _totalSupply) / _reserve1
            );
        }

        require(liquidity > 0, "DEX: INSUFFICIENT_LIQUIDITY_MINTED");

        _mint(to, liquidity);

        _update(balance0, balance1);

        if (feeOn) kLast = uint256(reserve0) * reserve1; // Update kLast if fee is on

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

    /**
     * @dev Burn liquidity tokens
     * @param to Address to receive tokens
     * @return amount0 Amount of token0 returned
     * @return amount1 Amount of token1 returned
     */
    function burn(address to) external override lock nonReentrant returns (uint256 amount0, uint256 amount1) {
        (uint112 _reserve0, uint112 _reserve1,) = getReserves();

        address _token0 = token0;
        address _token1 = token1;

        uint256 balance0 = IERC20(_token0).balanceOf(address(this));
        uint256 balance1 = IERC20(_token1).balanceOf(address(this));

        uint256 liquidity = balanceOf(address(this));

        // Calculate protocol fee
        bool feeOn = _mintFee(_reserve0, _reserve1);

        // Calculate token amounts
        uint256 _totalSupply = totalSupply();

        amount0 = (liquidity * balance0) / _totalSupply;
        amount1 = (liquidity * balance1) / _totalSupply;

        require(amount0 > 0 && amount1 > 0, "DEX: INSUFFICIENT_LIQUIDITY_BURNED");

        _burn(address(this), liquidity);

        // Transfer tokens to recipient
        IERC20(_token0).transfer(to, amount0);
        IERC20(_token1).transfer(to, amount1);

        // Update reserves
        balance0 = IERC20(_token0).balanceOf(address(this));
        balance1 = IERC20(_token1).balanceOf(address(this));

        _update(balance0, balance1);

        if (feeOn) kLast = uint256(reserve0) * reserve1; // Update kLast if fee is on

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

    /**
     * @dev Swap tokens
     * @param amount0Out Amount of token0 to output
     * @param amount1Out Amount of token1 to output
     * @param to Address to receive tokens
     * @param data Additional data for callbacks
     */
    function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external override lock nonReentrant {
        require(amount0Out > 0 || amount1Out > 0, "DEX: INSUFFICIENT_OUTPUT_AMOUNT");

        (uint112 _reserve0, uint112 _reserve1,) = getReserves();

        require(amount0Out < _reserve0 && amount1Out < _reserve1, "DEX: INSUFFICIENT_LIQUIDITY");

        uint256 balance0;
        uint256 balance1;

        {
            address _token0 = token0;
            address _token1 = token1;

            require(to != _token0 && to != _token1, "DEX: INVALID_TO");

            // Transfer tokens to recipient
            if (amount0Out > 0) IERC20(_token0).transfer(to, amount0Out);
            if (amount1Out > 0) IERC20(_token1).transfer(to, amount1Out);

            // Handle flash swap if data is provided
            if (data.length > 0) {
                // Call the callback function
                IDEXCallee(to).dexCall(msg.sender, amount0Out, amount1Out, data);
            }

            // Check balances after swap
            balance0 = IERC20(_token0).balanceOf(address(this));
            balance1 = IERC20(_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, "DEX: INSUFFICIENT_INPUT_AMOUNT");

        // Apply fee: 0.3%
        uint256 balance0Adjusted = balance0 * 1000 - amount0In * 3;
        uint256 balance1Adjusted = balance1 * 1000 - amount1In * 3;

        // Check k value
        require(balance0Adjusted * balance1Adjusted >= uint256(_reserve0) * _reserve1 * 1000**2, "DEX: K");

        _update(balance0, balance1);

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

    /**
     * @dev Force balances to match reserves
     * @param to Address to receive excess tokens
     */
    function skim(address to) external override lock {
        address _token0 = token0;
        address _token1 = token1;

        IERC20(_token0).transfer(to, IERC20(_token0).balanceOf(address(this)) - reserve0);
        IERC20(_token1).transfer(to, IERC20(_token1).balanceOf(address(this)) - reserve1);
    }

    /**
     * @dev Force reserves to match balances
     */
    function sync() external override lock {
        _update(
            IERC20(token0).balanceOf(address(this)),
            IERC20(token1).balanceOf(address(this))
        );
    }

    /**
     * @dev Mint fee to feeTo address if applicable
     * @param _reserve0 Reserve of token0
     * @param _reserve1 Reserve of token1
     * @return feeOn Whether fee is enabled
     */
    function _mintFee(uint112 _reserve0, uint112 _reserve1) private returns (bool feeOn) {
        address feeTo = IDEXFactory(factory).feeTo();
        feeOn = feeTo != address(0);

        uint256 _kLast = kLast; // Gas savings

        if (feeOn) {
            if (_kLast != 0) {
                uint256 rootK = Math.sqrt(uint256(_reserve0) * _reserve1);
                uint256 rootKLast = Math.sqrt(_kLast);

                if (rootK > rootKLast) {
                    uint256 numerator = totalSupply() * (rootK - rootKLast);
                    uint256 denominator = rootK * 5 + rootKLast;
                    uint256 liquidity = numerator / denominator;

                    if (liquidity > 0) _mint(feeTo, liquidity);
                }
            }
        } else if (_kLast != 0) {
            kLast = 0;
        }
    }
}

/**
 * @title Math
 * @dev Math utilities
 */
library Math {
    /**
     * @dev Calculate the square root of a number
     * @param y The number
     * @return z The square root
     */
    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;
        }
    }

    /**
     * @dev Return the minimum of two numbers
     * @param x First number
     * @param y Second number
     * @return The minimum
     */
    function min(uint256 x, uint256 y) internal pure returns (uint256) {
        return x < y ? x : y;
    }
}

/**
 * @title IDEXCallee
 * @dev Interface for flash swap callbacks
 */
interface IDEXCallee {
    function dexCall(address sender, uint256 amount0, uint256 amount1, bytes calldata data) external;
}

Step 5: Implementing the DEX Router

Create a file named DEXRouter.sol in the contracts directory:

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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "./interfaces/IDEX.sol";

/**
 * @title DEXRouter
 * @dev Router for interacting with DEX pairs
 */
contract DEXRouter is IDEXRouter, ReentrancyGuard {
    address public immutable override factory;

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

    /**
     * @dev Constructor
     * @param _factory Address of the factory contract
     */
    constructor(address _factory) {
        factory = _factory;
    }

    /**
     * @dev Add liquidity to a pair
     * @param tokenA First token address
     * @param tokenB Second token address
     * @param amountADesired Desired amount of tokenA
     * @param amountBDesired Desired amount of tokenB
     * @param amountAMin Minimum amount of tokenA
     * @param amountBMin Minimum amount of tokenB
     * @param to Address to receive LP tokens
     * @param deadline Deadline for the transaction
     * @return amountA Amount of tokenA added
     * @return amountB Amount of tokenB added
     * @return liquidity Amount of LP tokens minted
     */
    function addLiquidity(
        address tokenA,
        address tokenB,
        uint256 amountADesired,
        uint256 amountBDesired,
        uint256 amountAMin,
        uint256 amountBMin,
        address to,
        uint256 deadline
    ) external override ensure(deadline) nonReentrant returns (uint256 amountA, uint256 amountB, uint256 liquidity) {
        // Create pair if it doesn't exist
        address pair = IDEXFactory(factory).getPair(tokenA, tokenB);
        if (pair == address(0)) {
            pair = IDEXFactory(factory).createPair(tokenA, tokenB);
        }

        // Calculate optimal amounts
        (amountA, amountB) = _calculateLiquidityAmounts(
            tokenA,
            tokenB,
            amountADesired,
            amountBDesired,
            amountAMin,
            amountBMin
        );

        // Transfer tokens to pair
        _safeTransferFrom(tokenA, msg.sender, pair, amountA);
        _safeTransferFrom(tokenB, msg.sender, pair, amountB);

        // Mint LP tokens
        liquidity = IDEXPair(pair).mint(to);
    }

    /**
     * @dev Remove liquidity from a pair
     * @param tokenA First token address
     * @param tokenB Second token address
     * @param liquidity Amount of LP tokens to burn
     * @param amountAMin Minimum amount of tokenA
     * @param amountBMin Minimum amount of tokenB
     * @param to Address to receive tokens
     * @param deadline Deadline for the transaction
     * @return amountA Amount of tokenA received
     * @return amountB Amount of tokenB received
     */
    function removeLiquidity(
        address tokenA,
        address tokenB,
        uint256 liquidity,
        uint256 amountAMin,
        uint256 amountBMin,
        address to,
        uint256 deadline
    ) external override ensure(deadline) nonReentrant returns (uint256 amountA, uint256 amountB) {
        // Get pair address
        address pair = IDEXFactory(factory).getPair(tokenA, tokenB);
        require(pair != address(0), "DEXRouter: PAIR_DOES_NOT_EXIST");

        // Transfer LP tokens to pair
        IDEXPair(pair).transferFrom(msg.sender, pair, liquidity);

        // Burn LP tokens
        (amountA, amountB) = IDEXPair(pair).burn(to);

        // Sort tokens
        (address token0,) = _sortTokens(tokenA, tokenB);
        (amountA, amountB) = tokenA == token0 ? (amountA, amountB) : (amountB, amountA);

        // Check minimum amounts
        require(amountA >= amountAMin, "DEXRouter: INSUFFICIENT_A_AMOUNT");
        require(amountB >= amountBMin, "DEXRouter: INSUFFICIENT_B_AMOUNT");
    }

    /**
     * @dev Swap exact tokens for tokens
     * @param amountIn Exact amount of input tokens
     * @param amountOutMin Minimum amount of output tokens
     * @param path Path of token addresses
     * @param to Address to receive output tokens
     * @param deadline Deadline for the transaction
     * @return amounts Amounts of tokens swapped
     */
    function swapExactTokensForTokens(
        uint256 amountIn,
        uint256 amountOutMin,
        address[] calldata path,
        address to,
        uint256 deadline
    ) external override ensure(deadline) nonReentrant returns (uint256[] memory amounts) {
        // Calculate amounts
        amounts = getAmountsOut(amountIn, path);
        require(amounts[amounts.length - 1] >= amountOutMin, "DEXRouter: INSUFFICIENT_OUTPUT_AMOUNT");

        // Transfer input tokens to first pair
        _safeTransferFrom(
            path[0],
            msg.sender,
            IDEXFactory(factory).getPair(path[0], path[1]),
            amounts[0]
        );

        // Execute swap
        _swap(amounts, path, to);
    }

    /**
     * @dev Swap tokens for exact tokens
     * @param amountOut Exact amount of output tokens
     * @param amountInMax Maximum amount of input tokens
     * @param path Path of token addresses
     * @param to Address to receive output tokens
     * @param deadline Deadline for the transaction
     * @return amounts Amounts of tokens swapped
     */
    function swapTokensForExactTokens(
        uint256 amountOut,
        uint256 amountInMax,
        address[] calldata path,
        address to,
        uint256 deadline
    ) external override ensure(deadline) nonReentrant returns (uint256[] memory amounts) {
        // Calculate amounts
        amounts = getAmountsIn(amountOut, path);
        require(amounts[0] <= amountInMax, "DEXRouter: EXCESSIVE_INPUT_AMOUNT");

        // Transfer input tokens to first pair
        _safeTransferFrom(
            path[0],
            msg.sender,
            IDEXFactory(factory).getPair(path[0], path[1]),
            amounts[0]
        );

        // Execute swap
        _swap(amounts, path, to);
    }

    /**
     * @dev Quote the amount of output tokens
     * @param amountA Amount of input tokens
     * @param reserveA Reserve of input tokens
     * @param reserveB Reserve of output tokens
     * @return amountB Amount of output tokens
     */
    function quote(uint256 amountA, uint256 reserveA, uint256 reserveB) public pure override returns (uint256 amountB) {
        require(amountA > 0, "DEXRouter: INSUFFICIENT_AMOUNT");
        require(reserveA > 0 && reserveB > 0, "DEXRouter: INSUFFICIENT_LIQUIDITY");

        amountB = (amountA * reserveB) / reserveA;
    }

    /**
     * @dev Calculate the amount of output tokens for a given input
     * @param amountIn Amount of input tokens
     * @param reserveIn Reserve of input tokens
     * @param reserveOut Reserve of output tokens
     * @return amountOut Amount of output tokens
     */
    function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) public pure override returns (uint256 amountOut) {
        require(amountIn > 0, "DEXRouter: INSUFFICIENT_INPUT_AMOUNT");
        require(reserveIn > 0 && reserveOut > 0, "DEXRouter: INSUFFICIENT_LIQUIDITY");

        uint256 amountInWithFee = amountIn * 997; // 0.3% fee
        uint256 numerator = amountInWithFee * reserveOut;
        uint256 denominator = reserveIn * 1000 + amountInWithFee;

        amountOut = numerator / denominator;
    }

    /**
     * @dev Calculate the amount of input tokens for a given output
     * @param amountOut Amount of output tokens
     * @param reserveIn Reserve of input tokens
     * @param reserveOut Reserve of output tokens
     * @return amountIn Amount of input tokens
     */
    function getAmountIn(uint256 amountOut, uint256 reserveIn, uint256 reserveOut) public pure override returns (uint256 amountIn) {
        require(amountOut > 0, "DEXRouter: INSUFFICIENT_OUTPUT_AMOUNT");
        require(reserveIn > 0 && reserveOut > 0, "DEXRouter: INSUFFICIENT_LIQUIDITY");

        uint256 numerator = reserveIn * amountOut * 1000;
        uint256 denominator = (reserveOut - amountOut) * 997;

        amountIn = (numerator / denominator) + 1;
    }

    /**
     * @dev Calculate amounts out for a path
     * @param amountIn Amount of input tokens
     * @param path Path of token addresses
     * @return amounts Amounts of tokens
     */
    function getAmountsOut(uint256 amountIn, address[] calldata path) public view override returns (uint256[] memory amounts) {
        require(path.length >= 2, "DEXRouter: INVALID_PATH");

        amounts = new uint256[](path.length);
        amounts[0] = amountIn;

        for (uint256 i = 0; i < path.length - 1; i++) {
            (uint256 reserveIn, uint256 reserveOut) = _getReserves(path[i], path[i + 1]);
            amounts[i + 1] = getAmountOut(amounts[i], reserveIn, reserveOut);
        }
    }

    /**
     * @dev Calculate amounts in for a path
     * @param amountOut Amount of output tokens
     * @param path Path of token addresses
     * @return amounts Amounts of tokens
     */
    function getAmountsIn(uint256 amountOut, address[] calldata path) public view override returns (uint256[] memory amounts) {
        require(path.length >= 2, "DEXRouter: INVALID_PATH");

        amounts = new uint256[](path.length);
        amounts[amounts.length - 1] = amountOut;

        for (uint256 i = path.length - 1; i > 0; i--) {
            (uint256 reserveIn, uint256 reserveOut) = _getReserves(path[i - 1], path[i]);
            amounts[i - 1] = getAmountIn(amounts[i], reserveIn, reserveOut);
        }
    }

    /**
     * @dev Sort tokens by address
     * @param tokenA First token address
     * @param tokenB Second token address
     * @return token0 Lower token address
     * @return token1 Higher token address
     */
    function _sortTokens(address tokenA, address tokenB) internal pure returns (address token0, address token1) {
        require(tokenA != tokenB, "DEXRouter: IDENTICAL_ADDRESSES");
        (token0, token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
        require(token0 != address(0), "DEXRouter: ZERO_ADDRESS");
    }

    /**
     * @dev Get reserves for a pair
     * @param tokenA First token address
     * @param tokenB Second token address
     * @return reserveA Reserve of tokenA
     * @return reserveB Reserve of tokenB
     */
    function _getReserves(address tokenA, address tokenB) internal view returns (uint256 reserveA, uint256 reserveB) {
        (address token0,) = _sortTokens(tokenA, tokenB);

        address pair = IDEXFactory(factory).getPair(tokenA, tokenB);
        require(pair != address(0), "DEXRouter: PAIR_DOES_NOT_EXIST");

        (uint112 reserve0, uint112 reserve1,) = IDEXPair(pair).getReserves();
        (reserveA, reserveB) = tokenA == token0 ? (reserve0, reserve1) : (reserve1, reserve0);
    }

    /**
     * @dev Calculate optimal liquidity amounts
     * @param tokenA First token address
     * @param tokenB Second token address
     * @param amountADesired Desired amount of tokenA
     * @param amountBDesired Desired amount of tokenB
     * @param amountAMin Minimum amount of tokenA
     * @param amountBMin Minimum amount of tokenB
     * @return amountA Optimal amount of tokenA
     * @return amountB Optimal amount of tokenB
     */
    function _calculateLiquidityAmounts(
        address tokenA,
        address tokenB,
        uint256 amountADesired,
        uint256 amountBDesired,
        uint256 amountAMin,
        uint256 amountBMin
    ) internal view returns (uint256 amountA, uint256 amountB) {
        // Get pair reserves
        (uint256 reserveA, uint256 reserveB) = (0, 0);

        address pair = IDEXFactory(factory).getPair(tokenA, tokenB);
        if (pair != address(0)) {
            (reserveA, reserveB) = _getReserves(tokenA, tokenB);
        }

        if (reserveA == 0 && reserveB == 0) {
            // Initial liquidity
            (amountA, amountB) = (amountADesired, amountBDesired);
        } else {
            // Calculate optimal amounts
            uint256 amountBOptimal = quote(amountADesired, reserveA, reserveB);

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

    /**
     * @dev Execute a swap
     * @param amounts Amounts of tokens
     * @param path Path of token addresses
     * @param _to Address to receive output tokens
     */
    function _swap(uint256[] memory amounts, address[] memory path, address _to) internal {
        for (uint256 i = 0; i < path.length - 1; i++) {
            (address input, address output) = (path[i], path[i + 1]);
            (address token0,) = _sortTokens(input, output);

            uint256 amountOut = amounts[i + 1];
            (uint256 amount0Out, uint256 amount1Out) = input == token0 ? (uint256(0), amountOut) : (amountOut, uint256(0));

            address to = i < path.length - 2 ? IDEXFactory(factory).getPair(output, path[i + 2]) : _to;

            IDEXPair(IDEXFactory(factory).getPair(input, output)).swap(
                amount0Out,
                amount1Out,
                to,
                new bytes(0)
            );
        }
    }

    /**
     * @dev Safe transfer from
     * @param token Token address
     * @param from From address
     * @param to To address
     * @param value Amount to transfer
     */
    function _safeTransferFrom(address token, address from, address to, uint256 value) private {
        (bool success, bytes memory data) = token.call(
            abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, value)
        );

        require(
            success && (data.length == 0 || abi.decode(data, (bool))),
            "DEXRouter: TRANSFER_FAILED"
        );
    }
}

Step 6: Writing Tests

Create test files for each contract to ensure they work as expected. Here's an example for the DEXFactory contract:

// test/DEXFactory.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("DEXFactory", function () {
  let factory;
  let owner;
  let feeSetter;
  let feeRecipient;
  let user;

  beforeEach(async function () {
    [owner, feeSetter, feeRecipient, user] = await ethers.getSigners();

    const DEXFactory = await ethers.getContractFactory("DEXFactory");
    factory = await DEXFactory.deploy(feeSetter.address);
    await factory.deployed();
  });

  describe("Deployment", function () {
    it("Should set the fee setter correctly", async function () {
      expect(await factory.feeToSetter()).to.equal(feeSetter.address);
    });

    it("Should initialize with no fee recipient", async function () {
      expect(await factory.feeTo()).to.equal(ethers.constants.AddressZero);
    });

    it("Should initialize with no pairs", async function () {
      expect(await factory.allPairsLength()).to.equal(0);
    });
  });

  describe("Pair Creation", function () {
    let tokenA;
    let tokenB;

    beforeEach(async function () {
      // Deploy test tokens
      const TestToken = await ethers.getContractFactory("TestToken");
      tokenA = await TestToken.deploy("Token A", "TKNA", ethers.utils.parseEther("1000000"));
      tokenB = await TestToken.deploy("Token B", "TKNB", ethers.utils.parseEther("1000000"));

      // Ensure tokenA address is less than tokenB for deterministic testing
      if (tokenA.address > tokenB.address) {
        [tokenA, tokenB] = [tokenB, tokenA];
      }
    });

    it("Should create a pair", async function () {
      await expect(factory.createPair(tokenA.address, tokenB.address))
        .to.emit(factory, "PairCreated")
        .withArgs(tokenA.address, tokenB.address, expect.anything(), 1);

      expect(await factory.allPairsLength()).to.equal(1);

      const pairAddress = await factory.getPair(tokenA.address, tokenB.address);
      expect(pairAddress).to.not.equal(ethers.constants.AddressZero);

      // Check reverse mapping
      expect(await factory.getPair(tokenB.address, tokenA.address)).to.equal(pairAddress);
    });

    it("Should fail if tokens are identical", async function () {
      await expect(factory.createPair(tokenA.address, tokenA.address))
        .to.be.revertedWith("DEX: IDENTICAL_ADDRESSES");
    });

    it("Should fail if token address is zero", async function () {
      await expect(factory.createPair(tokenA.address, ethers.constants.AddressZero))
        .to.be.revertedWith("DEX: ZERO_ADDRESS");
    });

    it("Should fail if pair already exists", async function () {
      await factory.createPair(tokenA.address, tokenB.address);

      await expect(factory.createPair(tokenA.address, tokenB.address))
        .to.be.revertedWith("DEX: PAIR_EXISTS");
    });
  });

  describe("Fee Management", function () {
    it("Should allow fee setter to set fee recipient", async function () {
      await factory.connect(feeSetter).setFeeTo(feeRecipient.address);
      expect(await factory.feeTo()).to.equal(feeRecipient.address);
    });

    it("Should allow fee setter to change fee setter", async function () {
      await factory.connect(feeSetter).setFeeToSetter(user.address);
      expect(await factory.feeToSetter()).to.equal(user.address);
    });

    it("Should fail if non-fee setter tries to set fee recipient", async function () {
      await expect(factory.connect(user).setFeeTo(feeRecipient.address))
        .to.be.revertedWith("DEX: FORBIDDEN");
    });

    it("Should fail if non-fee setter tries to change fee setter", async function () {
      await expect(factory.connect(user).setFeeToSetter(user.address))
        .to.be.revertedWith("DEX: FORBIDDEN");
    });
  });
});

// TestToken contract for testing
const TestToken = await ethers.getContractFactory("TestToken");
const tokenA = await TestToken.deploy("Token A", "TKNA", ethers.utils.parseEther("1000000"));

Step 7: Deployment Scripts

Create a deployment script to deploy your contracts:

// scripts/deploy.js
const { ethers } = require("hardhat");

async function main() {
  const [deployer] = await ethers.getSigners();
  console.log("Deploying contracts with the account:", deployer.address);

  // Deploy factory
  const DEXFactory = await ethers.getContractFactory("DEXFactory");
  const factory = await DEXFactory.deploy(deployer.address);
  await factory.deployed();
  console.log("DEXFactory deployed to:", factory.address);

  // Deploy router
  const DEXRouter = await ethers.getContractFactory("DEXRouter");
  const router = await DEXRouter.deploy(factory.address);
  await router.deployed();
  console.log("DEXRouter deployed to:", router.address);

  // For testing, deploy some tokens
  const TestToken = await ethers.getContractFactory("TestToken");

  const tokenA = await TestToken.deploy("Token A", "TKNA", ethers.utils.parseEther("1000000"));
  await tokenA.deployed();
  console.log("Token A deployed to:", tokenA.address);

  const tokenB = await TestToken.deploy("Token B", "TKNB", ethers.utils.parseEther("1000000"));
  await tokenB.deployed();
  console.log("Token B deployed to:", tokenB.address);

  // Create pair
  await factory.createPair(tokenA.address, tokenB.address);
  console.log("Pair created for Token A and Token B");

  const pairAddress = await factory.getPair(tokenA.address, tokenB.address);
  console.log("Pair address:", pairAddress);

  console.log("Deployment completed!");
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Step 8: Creating a Test Token Contract

For testing purposes, create a simple ERC20 token contract:

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

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

/**
 * @title TestToken
 * @dev A simple ERC20 token for testing
 */
contract TestToken is ERC20 {
    /**
     * @dev Constructor
     * @param name Token name
     * @param symbol Token symbol
     * @param initialSupply Initial supply of tokens
     */
    constructor(
        string memory name,
        string memory symbol,
        uint256 initialSupply
    ) ERC20(name, symbol) {
        _mint(msg.sender, initialSupply);
    }
}

Step 9: Running the Project

  1. Compile the contracts:
npx hardhat compile
  1. Run the tests:
npx hardhat test
  1. Deploy to a testnet:
npx hardhat run scripts/deploy.js --network goerli

Step 10: Interacting with the DEX

Here's a script to interact with the DEX:

// scripts/interact.js
const { ethers } = require("hardhat");

async function main() {
  const [deployer] = await ethers.getSigners();

  // Contract addresses (replace with your deployed addresses)
  const factoryAddress = "YOUR_FACTORY_ADDRESS";
  const routerAddress = "YOUR_ROUTER_ADDRESS";
  const tokenAAddress = "YOUR_TOKEN_A_ADDRESS";
  const tokenBAddress = "YOUR_TOKEN_B_ADDRESS";

  // Get contract instances
  const factory = await ethers.getContractAt("DEXFactory", factoryAddress);
  const router = await ethers.getContractAt("DEXRouter", routerAddress);
  const tokenA = await ethers.getContractAt("TestToken", tokenAAddress);
  const tokenB = await ethers.getContractAt("TestToken", tokenBAddress);

  // Get pair address
  const pairAddress = await factory.getPair(tokenAAddress, tokenBAddress);
  const pair = await ethers.getContractAt("DEXPair", pairAddress);

  console.log("Pair address:", pairAddress);

  // Approve router to spend tokens
  await tokenA.approve(routerAddress, ethers.utils.parseEther("1000"));
  await tokenB.approve(routerAddress, ethers.utils.parseEther("1000"));

  console.log("Approved router to spend tokens");

  // Add liquidity
  const amountA = ethers.utils.parseEther("100");
  const amountB = ethers.utils.parseEther("100");

  await router.addLiquidity(
    tokenAAddress,
    tokenBAddress,
    amountA,
    amountB,
    0,
    0,
    deployer.address,
    Math.floor(Date.now() / 1000) + 60 * 20 // 20 minutes from now
  );

  console.log("Added liquidity");

  // Get reserves
  const reserves = await pair.getReserves();
  console.log("Reserve0:", ethers.utils.formatEther(reserves[0]));
  console.log("Reserve1:", ethers.utils.formatEther(reserves[1]));

  // Swap tokens
  const swapAmount = ethers.utils.parseEther("1");

  // Get amount out
  const amountOut = await router.getAmountOut(
    swapAmount,
    reserves[0],
    reserves[1]
  );

  console.log("Expected amount out:", ethers.utils.formatEther(amountOut));

  // Execute swap
  await router.swapExactTokensForTokens(
    swapAmount,
    0, // Min amount out
    [tokenAAddress, tokenBAddress],
    deployer.address,
    Math.floor(Date.now() / 1000) + 60 * 20 // 20 minutes from now
  );

  console.log("Swapped tokens");

  // Get updated reserves
  const updatedReserves = await pair.getReserves();
  console.log("Updated Reserve0:", ethers.utils.formatEther(updatedReserves[0]));
  console.log("Updated Reserve1:", ethers.utils.formatEther(updatedReserves[1]));

  // Get token balances
  const balanceA = await tokenA.balanceOf(deployer.address);
  const balanceB = await tokenB.balanceOf(deployer.address);

  console.log("Token A balance:", ethers.utils.formatEther(balanceA));
  console.log("Token B balance:", ethers.utils.formatEther(balanceB));
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Conclusion

In this project, you've built a decentralized exchange (DEX) with an automated market maker (AMM) based on the constant product formula. This project demonstrates several advanced Solidity concepts:

  1. Automated Market Maker: Implemented the constant product formula (x * y = k) for price discovery
  2. Liquidity Pools: Created pools for token pairs where users can provide liquidity
  3. Token Swapping: Implemented token swapping functionality with price slippage protection
  4. Protocol Fees: Added support for protocol fees to generate revenue
  5. Flash Swaps: Implemented the foundation for flash swaps

You've applied concepts from multiple modules, including:

This project demonstrates how to build a complex DeFi application with multiple interacting contracts. You can extend it by adding features like:

The DEX you've built provides a solid foundation for understanding how decentralized exchanges work and how to implement complex financial logic in smart contracts.