IAMUVIN

Web3 Development

Building a Decentralized Exchange (DEX) with Solidity

Uvin Vindula·June 3, 2024·14 min read
Share

TL;DR

A decentralized exchange is the backbone of any DeFi ecosystem. I have built DEX components for multiple DeFi clients — liquidity pools, swap routers, fee mechanisms, and the factory contracts that tie everything together. This guide walks you through building a complete Uniswap V2 style AMM from scratch in Solidity 0.8.24+. You will get three production-grade contracts (Pair, Factory, Router), comprehensive Foundry tests, and a deep understanding of the constant product formula that makes it all work. Every line is written with security as the first priority. If you need a DEX or AMM protocol built for your project, check out my services.


DEX Architecture Overview

Before writing a single line of Solidity, you need to understand how the pieces fit together. A Uniswap V2 style AMM has three core contracts that form a clear separation of concerns:

Pair Contract — The liquidity pool itself. Each pair holds reserves of exactly two ERC-20 tokens. It implements the constant product formula (x * y = k), mints and burns LP tokens to track liquidity provider shares, and executes swaps. One pair contract exists per unique token pair.

Factory Contract — The deployment engine. It creates new pair contracts using CREATE2 for deterministic addresses, maintains a registry of all pairs, and prevents duplicate pair creation. Think of it as the index of every trading pair on your DEX.

Router Contract — The user-facing entry point. It handles the complexity of wrapping ETH, calculating optimal swap amounts, enforcing slippage protection, and routing multi-hop swaps. Users never interact with pair contracts directly. The router does the heavy lifting.

Here is the data flow for a typical swap:

User -> Router -> Factory (lookup pair) -> Pair (execute swap) -> User receives tokens

And for adding liquidity:

User -> Router -> Factory (find/create pair) -> Pair (mint LP tokens) -> User receives LP tokens

This architecture is elegant because each contract has a single responsibility. The pair contract does not care how users found it. The factory does not care about swap math. The router does not care about reserve accounting. SRP applied to smart contracts.

One critical design decision: the pair contract uses the Checks-Effects-Interactions pattern everywhere. Every external call happens after all state changes. This is non-negotiable for DeFi contracts — reentrancy in a liquidity pool means drained funds.

Let me walk through each contract in detail.


The Pair Contract

The pair contract is where the core AMM logic lives. It holds two token reserves, implements the constant product invariant, and manages LP token minting and burning. This is the most security-critical contract in the entire DEX.

Here is the complete implementation:

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

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";

contract DEXPair is ERC20, ReentrancyGuard {
    uint256 public constant MINIMUM_LIQUIDITY = 1000;
    uint256 private constant FEE_NUMERATOR = 997;
    uint256 private constant FEE_DENOMINATOR = 1000;

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

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

    uint256 public price0CumulativeLast;
    uint256 public price1CumulativeLast;
    uint256 public kLast;

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

    error InsufficientLiquidityMinted();
    error InsufficientLiquidityBurned();
    error InsufficientOutputAmount();
    error InsufficientInputAmount();
    error InsufficientLiquidity();
    error InvalidTo();
    error KInvariantViolated();
    error Overflow();

    constructor(
        address _token0,
        address _token1
    ) ERC20("DEX LP Token", "DEX-LP") {
        factory = msg.sender;
        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 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;

        uint256 _totalSupply = totalSupply();
        if (_totalSupply == 0) {
            liquidity = Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
            _mint(address(1), MINIMUM_LIQUIDITY);
        } else {
            liquidity = Math.min(
                (amount0 * _totalSupply) / _reserve0,
                (amount1 * _totalSupply) / _reserve1
            );
        }

        if (liquidity == 0) revert InsufficientLiquidityMinted();

        _mint(to, liquidity);
        _update(balance0, balance1, _reserve0, _reserve1);

        kLast = uint256(reserve0) * uint256(reserve1);
        emit Mint(msg.sender, amount0, amount1);
    }

    function burn(
        address to
    ) external nonReentrant returns (uint256 amount0, uint256 amount1) {
        uint256 balance0 = IERC20(token0).balanceOf(address(this));
        uint256 balance1 = IERC20(token1).balanceOf(address(this));
        uint256 liquidity = balanceOf(address(this));

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

        if (amount0 == 0 || amount1 == 0) revert InsufficientLiquidityBurned();

        _burn(address(this), liquidity);

        IERC20(token0).transfer(to, amount0);
        IERC20(token1).transfer(to, amount1);

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

        _update(balance0, balance1, reserve0, reserve1);

        kLast = uint256(reserve0) * uint256(reserve1);
        emit Burn(msg.sender, amount0, amount1, to);
    }

    function swap(
        uint256 amount0Out,
        uint256 amount1Out,
        address to
    ) external nonReentrant {
        if (amount0Out == 0 && amount1Out == 0) {
            revert InsufficientOutputAmount();
        }

        (uint112 _reserve0, uint112 _reserve1, ) = getReserves();
        if (amount0Out >= _reserve0 || amount1Out >= _reserve1) {
            revert InsufficientLiquidity();
        }

        if (to == token0 || to == token1) revert InvalidTo();

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

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

        uint256 amount0In = balance0 > _reserve0 - amount0Out
            ? balance0 - (_reserve0 - amount0Out)
            : 0;
        uint256 amount1In = balance1 > _reserve1 - amount1Out
            ? balance1 - (_reserve1 - amount1Out)
            : 0;

        if (amount0In == 0 && amount1In == 0) revert InsufficientInputAmount();

        uint256 balance0Adjusted = (balance0 * FEE_DENOMINATOR) -
            (amount0In * (FEE_DENOMINATOR - FEE_NUMERATOR));
        uint256 balance1Adjusted = (balance1 * FEE_DENOMINATOR) -
            (amount1In * (FEE_DENOMINATOR - FEE_NUMERATOR));

        if (
            balance0Adjusted * balance1Adjusted <
            uint256(_reserve0) * uint256(_reserve1) * (FEE_DENOMINATOR ** 2)
        ) {
            revert KInvariantViolated();
        }

        _update(balance0, balance1, _reserve0, _reserve1);

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

    function sync() external nonReentrant {
        _update(
            IERC20(token0).balanceOf(address(this)),
            IERC20(token1).balanceOf(address(this)),
            reserve0,
            reserve1
        );
    }

    function _update(
        uint256 balance0,
        uint256 balance1,
        uint112 _reserve0,
        uint112 _reserve1
    ) private {
        if (balance0 > type(uint112).max || balance1 > type(uint112).max) {
            revert Overflow();
        }

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

        if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
            unchecked {
                price0CumulativeLast +=
                    uint256((uint224(_reserve1) << 112) / _reserve0) *
                    timeElapsed;
                price1CumulativeLast +=
                    uint256((uint224(_reserve0) << 112) / _reserve1) *
                    timeElapsed;
            }
        }

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

        emit Sync(reserve0, reserve1);
    }
}

Let me break down the critical decisions in this contract.

MINIMUM_LIQUIDITY: The first 1000 LP tokens are permanently locked by minting them to address(1). This prevents an attacker from manipulating the LP token price when a pool is nearly empty. Without this, someone could donate tokens to inflate the value of a single LP token and grief future liquidity providers. This is a subtle but essential defense.

Reserves vs balances: The contract tracks reserves separately from actual token balances. The _update function syncs them after every operation. This separation lets the contract detect how many tokens were actually transferred in (the difference between current balance and last-known reserve). It is the core mechanism that makes the "transfer then call" pattern work.

Fee calculation: The 0.3% fee is applied by multiplying input amounts by 997/1000 when checking the k invariant. The fee tokens stay in the pool, increasing k over time. This means LP token holders accumulate fees automatically — their share of the pool grows with every swap.

TWAP oracle: The cumulative price tracking enables time-weighted average price (TWAP) oracles. External contracts can sample price0CumulativeLast at two different timestamps and compute the average price over that window. This is manipulation-resistant because an attacker would need to sustain the manipulated price for the entire averaging period.


The Factory Contract

The factory contract creates and indexes pair contracts. It uses CREATE2 for deterministic addressing, which means anyone can compute a pair's address without querying the chain. This is critical for gas-efficient routing.

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

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

contract DEXFactory {
    address public feeTo;
    address public feeToSetter;

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

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

    error IdenticalAddresses();
    error ZeroAddress();
    error PairExists();
    error Forbidden();

    constructor(address _feeToSetter) {
        feeToSetter = _feeToSetter;
    }

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

    function createPair(
        address tokenA,
        address tokenB
    ) external returns (address pair) {
        if (tokenA == tokenB) revert IdenticalAddresses();

        (address token0, address token1) = tokenA < tokenB
            ? (tokenA, tokenB)
            : (tokenB, tokenA);

        if (token0 == address(0)) revert ZeroAddress();
        if (getPair[token0][token1] != address(0)) revert PairExists();

        bytes32 salt = keccak256(abi.encodePacked(token0, token1));
        pair = address(new DEXPair{salt: salt}(token0, token1));

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

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

    function setFeeTo(address _feeTo) external {
        if (msg.sender != feeToSetter) revert Forbidden();
        feeTo = _feeTo;
    }

    function setFeeToSetter(address _feeToSetter) external {
        if (msg.sender != feeToSetter) revert Forbidden();
        feeToSetter = _feeToSetter;
    }
}

The factory is intentionally simple. A few things worth noting:

Token sorting: Tokens are always sorted by address (token0 < token1). This ensures that the pair for TOKEN_A/TOKEN_B is the same contract as TOKEN_B/TOKEN_A. No duplicates, no confusion.

CREATE2 salt: The salt is derived from the sorted token addresses. This means the pair address is deterministic — you can compute it off-chain without ever calling the factory. The router uses this to save gas on pair lookups.

Fee governance: The feeTo address receives a share of LP fees when set. The feeToSetter is the only address that can change fee configuration. In production, this should be a multi-sig or a DAO governance contract, never an EOA.

No pausability on the factory: I intentionally left pause functionality out of the factory. Once a pair is created, it should exist permanently. The factory's job is creation and indexing, not lifecycle management. If you need emergency controls, put them on individual pair contracts or on the router.


The Router Contract

The router is the most complex contract because it handles all the user-facing logic — calculating amounts, enforcing deadlines, managing slippage, and supporting both ETH and ERC-20 swaps. Users should never interact with pair contracts directly.

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

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {DEXFactory} from "./DEXFactory.sol";
import {DEXPair} from "./DEXPair.sol";

interface IWETH {
    function deposit() external payable;
    function withdraw(uint256) external;
    function transfer(address to, uint256 value) external returns (bool);
}

contract DEXRouter {
    using SafeERC20 for IERC20;

    address public immutable factory;
    address public immutable WETH;

    error Expired();
    error InsufficientAAmount();
    error InsufficientBAmount();
    error InsufficientOutputAmount();
    error ExcessiveInputAmount();
    error InvalidPath();
    error InsufficientLiquidity();
    error ZeroAmount();

    modifier ensure(uint256 deadline) {
        if (block.timestamp > deadline) revert Expired();
        _;
    }

    constructor(address _factory, address _weth) {
        factory = _factory;
        WETH = _weth;
    }

    receive() external payable {}

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

        address pair = DEXFactory(factory).getPair(tokenA, tokenB);
        IERC20(tokenA).safeTransferFrom(msg.sender, pair, amountA);
        IERC20(tokenB).safeTransferFrom(msg.sender, pair, amountB);
        liquidity = DEXPair(pair).mint(to);
    }

    function removeLiquidity(
        address tokenA,
        address tokenB,
        uint256 liquidity,
        uint256 amountAMin,
        uint256 amountBMin,
        address to,
        uint256 deadline
    ) public ensure(deadline) returns (uint256 amountA, uint256 amountB) {
        address pair = DEXFactory(factory).getPair(tokenA, tokenB);
        IERC20(pair).safeTransferFrom(msg.sender, pair, liquidity);
        (uint256 amount0, uint256 amount1) = DEXPair(pair).burn(to);

        (address token0, ) = _sortTokens(tokenA, tokenB);
        (amountA, amountB) = tokenA == token0
            ? (amount0, amount1)
            : (amount1, amount0);

        if (amountA < amountAMin) revert InsufficientAAmount();
        if (amountB < amountBMin) revert InsufficientBAmount();
    }

    function swapExactTokensForTokens(
        uint256 amountIn,
        uint256 amountOutMin,
        address[] calldata path,
        address to,
        uint256 deadline
    ) external ensure(deadline) returns (uint256[] memory amounts) {
        amounts = getAmountsOut(amountIn, path);
        if (amounts[amounts.length - 1] < amountOutMin) {
            revert InsufficientOutputAmount();
        }

        address pair = DEXFactory(factory).getPair(path[0], path[1]);
        IERC20(path[0]).safeTransferFrom(msg.sender, pair, amounts[0]);
        _swap(amounts, path, to);
    }

    function swapTokensForExactTokens(
        uint256 amountOut,
        uint256 amountInMax,
        address[] calldata path,
        address to,
        uint256 deadline
    ) external ensure(deadline) returns (uint256[] memory amounts) {
        amounts = getAmountsIn(amountOut, path);
        if (amounts[0] > amountInMax) revert ExcessiveInputAmount();

        address pair = DEXFactory(factory).getPair(path[0], path[1]);
        IERC20(path[0]).safeTransferFrom(msg.sender, pair, amounts[0]);
        _swap(amounts, path, to);
    }

    function getAmountsOut(
        uint256 amountIn,
        address[] memory path
    ) public view returns (uint256[] memory amounts) {
        if (path.length < 2) revert InvalidPath();
        amounts = new uint256[](path.length);
        amounts[0] = amountIn;

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

    function getAmountsIn(
        uint256 amountOut,
        address[] memory path
    ) public view returns (uint256[] memory amounts) {
        if (path.length < 2) revert InvalidPath();
        amounts = new uint256[](path.length);
        amounts[amounts.length - 1] = amountOut;

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

    function quote(
        uint256 amountA,
        uint256 reserveA,
        uint256 reserveB
    ) public pure returns (uint256 amountB) {
        if (amountA == 0) revert ZeroAmount();
        if (reserveA == 0 || reserveB == 0) revert InsufficientLiquidity();
        amountB = (amountA * reserveB) / reserveA;
    }

    function _calculateLiquidityAmounts(
        address tokenA,
        address tokenB,
        uint256 amountADesired,
        uint256 amountBDesired,
        uint256 amountAMin,
        uint256 amountBMin
    ) internal returns (uint256 amountA, uint256 amountB) {
        address pair = DEXFactory(factory).getPair(tokenA, tokenB);
        if (pair == address(0)) {
            pair = DEXFactory(factory).createPair(tokenA, tokenB);
            return (amountADesired, amountBDesired);
        }

        (uint256 reserveA, uint256 reserveB) = _getReserves(tokenA, tokenB);

        if (reserveA == 0 && reserveB == 0) {
            return (amountADesired, amountBDesired);
        }

        uint256 amountBOptimal = quote(amountADesired, reserveA, reserveB);
        if (amountBOptimal <= amountBDesired) {
            if (amountBOptimal < amountBMin) revert InsufficientBAmount();
            return (amountADesired, amountBOptimal);
        }

        uint256 amountAOptimal = quote(amountBDesired, reserveB, reserveA);
        assert(amountAOptimal <= amountADesired);
        if (amountAOptimal < amountAMin) revert InsufficientAAmount();
        return (amountAOptimal, amountBDesired);
    }

    function _swap(
        uint256[] memory amounts,
        address[] memory path,
        address _to
    ) internal {
        for (uint256 i; i < path.length - 1; ) {
            (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
                ? DEXFactory(factory).getPair(output, path[i + 2])
                : _to;

            DEXPair(DEXFactory(factory).getPair(input, output)).swap(
                amount0Out,
                amount1Out,
                to
            );

            unchecked {
                ++i;
            }
        }
    }

    function _getReserves(
        address tokenA,
        address tokenB
    ) internal view returns (uint256 reserveA, uint256 reserveB) {
        (address token0, ) = _sortTokens(tokenA, tokenB);
        address pair = DEXFactory(factory).getPair(tokenA, tokenB);
        (uint112 reserve0, uint112 reserve1, ) = DEXPair(pair).getReserves();
        (reserveA, reserveB) = tokenA == token0
            ? (uint256(reserve0), uint256(reserve1))
            : (uint256(reserve1), uint256(reserve0));
    }

    function _sortTokens(
        address tokenA,
        address tokenB
    ) internal pure returns (address token0, address token1) {
        (token0, token1) = tokenA < tokenB
            ? (tokenA, tokenB)
            : (tokenB, tokenA);
    }

    function _getAmountOut(
        uint256 amountIn,
        uint256 reserveIn,
        uint256 reserveOut
    ) internal pure returns (uint256 amountOut) {
        if (amountIn == 0) revert ZeroAmount();
        if (reserveIn == 0 || reserveOut == 0) revert InsufficientLiquidity();
        uint256 amountInWithFee = amountIn * 997;
        uint256 numerator = amountInWithFee * reserveOut;
        uint256 denominator = (reserveIn * 1000) + amountInWithFee;
        amountOut = numerator / denominator;
    }

    function _getAmountIn(
        uint256 amountOut,
        uint256 reserveIn,
        uint256 reserveOut
    ) internal pure returns (uint256 amountIn) {
        if (amountOut == 0) revert ZeroAmount();
        if (reserveIn == 0 || reserveOut == 0) revert InsufficientLiquidity();
        uint256 numerator = reserveIn * amountOut * 1000;
        uint256 denominator = (reserveOut - amountOut) * 997;
        amountIn = (numerator / denominator) + 1;
    }
}

The router is the contract that needs the most careful attention to user experience. Let me explain the key design decisions.


Adding Liquidity

Adding liquidity is the most common user action after swapping. The process seems simple — deposit two tokens and receive LP tokens — but the implementation has several subtleties that matter.

When a user wants to add liquidity to an existing pool, the router must ensure the token amounts maintain the current price ratio. If the pool is currently 1 ETH = 2000 USDC, and you try to add 1 ETH + 1000 USDC, the router will adjust your amounts to maintain the ratio.

The _calculateLiquidityAmounts function handles this. It takes "desired" amounts (the maximum the user is willing to deposit) and "minimum" amounts (the least they will accept). The router finds the optimal split:

  1. First, it checks if the pair exists. If not, it creates one and uses the desired amounts as-is. The first liquidity provider sets the initial price.
  2. If the pair exists, it quotes amountBOptimal based on amountADesired and the current reserves.
  3. If amountBOptimal is within the user's range, it uses (amountADesired, amountBOptimal).
  4. Otherwise, it quotes amountAOptimal based on amountBDesired and uses (amountAOptimal, amountBDesired).

This ensures the user always gets the best possible ratio without exceeding their desired amounts or going below their minimums.

For the first liquidity provider, the LP tokens minted are calculated as the geometric mean of the two deposited amounts:

liquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY

The MINIMUM_LIQUIDITY subtraction permanently locks the first 1000 units of LP tokens to prevent the pool from ever being fully drained. This is a defense against a specific attack where someone creates a pool with tiny amounts, manipulates the LP token price, then griefs future depositors.

For subsequent deposits, LP tokens are proportional to the smaller of the two ratios:

liquidity = min(
    (amount0 * totalSupply) / reserve0,
    (amount1 * totalSupply) / reserve1
)

Using min instead of max penalizes imbalanced deposits. If you deposit tokens in a ratio that differs from the current reserves, you get LP tokens based on the smaller proportion. The excess tokens effectively become a donation to existing LPs. This incentivizes balanced deposits.


Removing Liquidity

Removing liquidity is the reverse — burn LP tokens and receive your proportional share of both tokens. The math is straightforward:

amount0 = (liquidity * balance0) / totalSupply
amount1 = (liquidity * balance1) / totalSupply

Your share is always proportional to your LP tokens relative to the total supply. If you hold 10% of all LP tokens, you receive 10% of both token reserves.

The router enforces minimum output amounts (amountAMin, amountBMin) to protect against front-running. Without these minimums, an attacker could sandwich your removal transaction — swap to move the price before your tx, then swap back after — extracting value from your withdrawal.

One important nuance: the LP tokens must be transferred to the pair contract before calling burn. The pair detects the LP tokens in its own balance and burns them. This "transfer then call" pattern is consistent with how the pair handles all operations. The router handles this seamlessly — the user just needs to approve the router to spend their LP tokens.

In a production deployment, I always recommend adding a removeLiquidityWithPermit function that uses EIP-2612 permit signatures. This lets users approve and remove in a single transaction, saving gas and improving UX. I left it out of this guide to keep the core concepts clear, but it is essential for a real product.


Swap Execution

Swap execution is where the constant product formula does its magic. The formula is simple:

x * y = k (before fees)

Where x and y are the token reserves and k is the invariant that must never decrease. After a swap:

  • The input reserve increases (user deposited tokens)
  • The output reserve decreases (user received tokens)
  • k must be greater than or equal to the pre-swap value

The _getAmountOut function calculates how many tokens you receive for a given input:

amountOut = (amountIn * 997 * reserveOut) / (reserveIn * 1000 + amountIn * 997)

The 997/1000 factor is the 0.3% fee. The input is effectively reduced by 0.3% before calculating the output. This fee stays in the pool, increasing k over time.

The _swap function supports multi-hop routing through the path array. If you want to swap TOKEN_A for TOKEN_C but there is no direct pair, the router can route through TOKEN_B:

path = [TOKEN_A, TOKEN_B, TOKEN_C]

The router executes two swaps in sequence, sending the intermediate tokens directly to the next pair contract. No tokens touch the router contract itself. This is more gas-efficient and reduces the attack surface.

The swap function on the pair contract uses an optimistic transfer pattern: it sends the output tokens first, then verifies the k invariant. This is safe because the verification happens atomically within the same transaction. If the invariant check fails, the entire transaction reverts, including the token transfers.


Fee Collection

The 0.3% swap fee is the lifeblood of a DEX. Without competitive fees, liquidity providers have no reason to deposit capital. Too high, and traders go elsewhere. 0.3% is the Uniswap V2 standard for a reason — it strikes a balance between LP incentives and trader costs.

Here is how fees flow through the system:

  1. User swaps 1000 USDC for ETH
  2. The effective input is 1000 * 0.997 = 997 USDC for the AMM calculation
  3. The 3 USDC fee stays in the pool, increasing the USDC reserve
  4. The k invariant increases by the fee amount
  5. All LP token holders now have a claim on a slightly larger pool

Fees compound automatically. There is no "claim fees" button. Your LP tokens represent a growing share of the pool. When you remove liquidity, you get your original deposit plus accumulated fees, minus any impermanent loss.

The factory contract also supports a protocol fee via the feeTo address. When set, 1/6th of LP fees (0.05% of swap volume) goes to the protocol. This is implemented as a mint of additional LP tokens to the feeTo address during liquidity events. The protocol fee is separate from the LP fee — LPs still receive 0.25%, and the protocol receives 0.05%.

I always recommend starting with protocol fees disabled and enabling them via governance once the DEX has meaningful volume. Turning on fees too early discourages early liquidity providers.


Slippage Protection

Slippage is the difference between the expected price and the actual execution price. In a DEX, slippage comes from two sources:

  1. Price impact: Large trades relative to pool liquidity move the price significantly. A 100 ETH swap in a pool with 500 ETH will have massive price impact.
  2. Front-running: MEV bots can see your pending transaction and trade ahead of you, moving the price before your transaction executes.

The router provides three layers of slippage protection:

Minimum output amounts: amountOutMin in swapExactTokensForTokens ensures you receive at least this many tokens. If the price moves too much between submission and execution, the transaction reverts instead of executing at a bad price.

Maximum input amounts: amountInMax in swapTokensForExactTokens caps how much you are willing to spend. Same protection, different direction.

Deadlines: The ensure(deadline) modifier rejects transactions that have been sitting in the mempool too long. If your transaction is not mined within the deadline, it reverts. This prevents stale transactions from executing at outdated prices.

In practice, I recommend frontends set slippage tolerance between 0.5% and 1% for stable pairs and 1-3% for volatile pairs. The deadline should be 20-30 minutes from submission. These values balance execution success rate against protection.

For additional MEV protection in production, consider integrating with a private mempool service like Flashbots Protect. This routes transactions through a private channel that MEV bots cannot see, eliminating sandwich attacks entirely.


Testing the Full DEX

Testing a DEX requires covering every state transition and edge case. I use Foundry because it is the fastest test framework for Solidity and supports fuzz testing out of the box. Here is the comprehensive test suite:

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

import {Test, console2} from "forge-std/Test.sol";
import {DEXFactory} from "../src/DEXFactory.sol";
import {DEXPair} from "../src/DEXPair.sol";
import {DEXRouter} from "../src/DEXRouter.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockERC20 is ERC20 {
    constructor(
        string memory name,
        string memory symbol
    ) ERC20(name, symbol) {}

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

contract MockWETH is MockERC20 {
    constructor() MockERC20("Wrapped Ether", "WETH") {}

    function deposit() external payable {
        _mint(msg.sender, msg.value);
    }

    function withdraw(uint256 amount) external {
        _burn(msg.sender, amount);
        payable(msg.sender).transfer(amount);
    }

    receive() external payable {
        _mint(msg.sender, msg.value);
    }
}

contract DEXTest is Test {
    DEXFactory public factory;
    DEXRouter public router;
    MockERC20 public tokenA;
    MockERC20 public tokenB;
    MockERC20 public tokenC;
    MockWETH public weth;

    address public alice = makeAddr("alice");
    address public bob = makeAddr("bob");
    address public deployer = makeAddr("deployer");

    uint256 constant INITIAL_BALANCE = 1_000_000e18;

    function setUp() public {
        vm.startPrank(deployer);

        tokenA = new MockERC20("Token A", "TKA");
        tokenB = new MockERC20("Token B", "TKB");
        tokenC = new MockERC20("Token C", "TKC");
        weth = new MockWETH();

        factory = new DEXFactory(deployer);
        router = new DEXRouter(address(factory), address(weth));

        vm.stopPrank();

        tokenA.mint(alice, INITIAL_BALANCE);
        tokenB.mint(alice, INITIAL_BALANCE);
        tokenC.mint(alice, INITIAL_BALANCE);
        tokenA.mint(bob, INITIAL_BALANCE);
        tokenB.mint(bob, INITIAL_BALANCE);

        vm.startPrank(alice);
        tokenA.approve(address(router), type(uint256).max);
        tokenB.approve(address(router), type(uint256).max);
        tokenC.approve(address(router), type(uint256).max);
        vm.stopPrank();

        vm.startPrank(bob);
        tokenA.approve(address(router), type(uint256).max);
        tokenB.approve(address(router), type(uint256).max);
        vm.stopPrank();
    }

    function test_CreatePair() public {
        address pair = factory.createPair(
            address(tokenA),
            address(tokenB)
        );

        assertTrue(pair != address(0));
        assertEq(factory.allPairsLength(), 1);
        assertEq(
            factory.getPair(address(tokenA), address(tokenB)),
            pair
        );
        assertEq(
            factory.getPair(address(tokenB), address(tokenA)),
            pair
        );
    }

    function test_RevertCreateDuplicatePair() public {
        factory.createPair(address(tokenA), address(tokenB));
        vm.expectRevert(DEXFactory.PairExists.selector);
        factory.createPair(address(tokenA), address(tokenB));
    }

    function test_RevertCreatePairIdenticalTokens() public {
        vm.expectRevert(DEXFactory.IdenticalAddresses.selector);
        factory.createPair(address(tokenA), address(tokenA));
    }

    function test_AddLiquidity() public {
        vm.startPrank(alice);

        (uint256 amountA, uint256 amountB, uint256 liquidity) = router
            .addLiquidity(
                address(tokenA),
                address(tokenB),
                100_000e18,
                200_000e18,
                100_000e18,
                200_000e18,
                alice,
                block.timestamp + 1 hours
            );

        assertEq(amountA, 100_000e18);
        assertEq(amountB, 200_000e18);
        assertGt(liquidity, 0);

        address pair = factory.getPair(
            address(tokenA),
            address(tokenB)
        );
        assertGt(DEXPair(pair).balanceOf(alice), 0);

        vm.stopPrank();
    }

    function test_AddLiquidityMaintainsRatio() public {
        vm.startPrank(alice);

        router.addLiquidity(
            address(tokenA),
            address(tokenB),
            100_000e18,
            200_000e18,
            100_000e18,
            200_000e18,
            alice,
            block.timestamp + 1 hours
        );

        (uint256 amountA, uint256 amountB, ) = router.addLiquidity(
            address(tokenA),
            address(tokenB),
            50_000e18,
            200_000e18,
            0,
            0,
            alice,
            block.timestamp + 1 hours
        );

        assertEq(amountA, 50_000e18);
        assertEq(amountB, 100_000e18);

        vm.stopPrank();
    }

    function test_RemoveLiquidity() public {
        vm.startPrank(alice);

        (, , uint256 liquidity) = router.addLiquidity(
            address(tokenA),
            address(tokenB),
            100_000e18,
            200_000e18,
            100_000e18,
            200_000e18,
            alice,
            block.timestamp + 1 hours
        );

        address pair = factory.getPair(
            address(tokenA),
            address(tokenB)
        );
        IERC20(pair).approve(address(router), liquidity);

        uint256 balanceABefore = tokenA.balanceOf(alice);
        uint256 balanceBBefore = tokenB.balanceOf(alice);

        router.removeLiquidity(
            address(tokenA),
            address(tokenB),
            liquidity,
            0,
            0,
            alice,
            block.timestamp + 1 hours
        );

        assertGt(tokenA.balanceOf(alice), balanceABefore);
        assertGt(tokenB.balanceOf(alice), balanceBBefore);

        vm.stopPrank();
    }

    function test_SwapExactTokensForTokens() public {
        vm.startPrank(alice);

        router.addLiquidity(
            address(tokenA),
            address(tokenB),
            100_000e18,
            100_000e18,
            100_000e18,
            100_000e18,
            alice,
            block.timestamp + 1 hours
        );

        vm.stopPrank();

        vm.startPrank(bob);

        address[] memory path = new address[](2);
        path[0] = address(tokenA);
        path[1] = address(tokenB);

        uint256 balanceBBefore = tokenB.balanceOf(bob);

        router.swapExactTokensForTokens(
            1_000e18,
            0,
            path,
            bob,
            block.timestamp + 1 hours
        );

        uint256 amountOut = tokenB.balanceOf(bob) - balanceBBefore;
        assertGt(amountOut, 0);
        assertLt(amountOut, 1_000e18);

        vm.stopPrank();
    }

    function test_SwapTokensForExactTokens() public {
        vm.startPrank(alice);

        router.addLiquidity(
            address(tokenA),
            address(tokenB),
            100_000e18,
            100_000e18,
            100_000e18,
            100_000e18,
            alice,
            block.timestamp + 1 hours
        );

        vm.stopPrank();

        vm.startPrank(bob);

        address[] memory path = new address[](2);
        path[0] = address(tokenA);
        path[1] = address(tokenB);

        uint256 balanceABefore = tokenA.balanceOf(bob);

        router.swapTokensForExactTokens(
            500e18,
            10_000e18,
            path,
            bob,
            block.timestamp + 1 hours
        );

        uint256 amountIn = balanceABefore - tokenA.balanceOf(bob);
        assertGt(amountIn, 500e18);
        assertEq(tokenB.balanceOf(bob) - INITIAL_BALANCE, 500e18);

        vm.stopPrank();
    }

    function test_MultiHopSwap() public {
        vm.startPrank(alice);

        router.addLiquidity(
            address(tokenA),
            address(tokenB),
            50_000e18,
            50_000e18,
            50_000e18,
            50_000e18,
            alice,
            block.timestamp + 1 hours
        );

        router.addLiquidity(
            address(tokenB),
            address(tokenC),
            50_000e18,
            50_000e18,
            50_000e18,
            50_000e18,
            alice,
            block.timestamp + 1 hours
        );

        vm.stopPrank();

        vm.startPrank(bob);

        address[] memory path = new address[](3);
        path[0] = address(tokenA);
        path[1] = address(tokenB);
        path[2] = address(tokenC);

        uint256 balanceCBefore = tokenC.balanceOf(bob);

        router.swapExactTokensForTokens(
            1_000e18,
            0,
            path,
            bob,
            block.timestamp + 1 hours
        );

        assertGt(tokenC.balanceOf(bob), balanceCBefore);

        vm.stopPrank();
    }

    function test_SlippageProtection() public {
        vm.startPrank(alice);

        router.addLiquidity(
            address(tokenA),
            address(tokenB),
            100_000e18,
            100_000e18,
            100_000e18,
            100_000e18,
            alice,
            block.timestamp + 1 hours
        );

        vm.stopPrank();

        vm.startPrank(bob);

        address[] memory path = new address[](2);
        path[0] = address(tokenA);
        path[1] = address(tokenB);

        vm.expectRevert(DEXRouter.InsufficientOutputAmount.selector);
        router.swapExactTokensForTokens(
            1_000e18,
            999e18,
            path,
            bob,
            block.timestamp + 1 hours
        );

        vm.stopPrank();
    }

    function test_DeadlineExpired() public {
        vm.startPrank(alice);

        vm.expectRevert(DEXRouter.Expired.selector);
        router.addLiquidity(
            address(tokenA),
            address(tokenB),
            100_000e18,
            100_000e18,
            100_000e18,
            100_000e18,
            alice,
            block.timestamp - 1
        );

        vm.stopPrank();
    }

    function test_FeeAccumulation() public {
        vm.startPrank(alice);

        router.addLiquidity(
            address(tokenA),
            address(tokenB),
            100_000e18,
            100_000e18,
            100_000e18,
            100_000e18,
            alice,
            block.timestamp + 1 hours
        );

        vm.stopPrank();

        vm.startPrank(bob);

        address[] memory path = new address[](2);
        path[0] = address(tokenA);
        path[1] = address(tokenB);

        for (uint256 i; i < 10; ++i) {
            router.swapExactTokensForTokens(
                1_000e18,
                0,
                path,
                bob,
                block.timestamp + 1 hours
            );

            path[0] = address(tokenB);
            path[1] = address(tokenA);

            router.swapExactTokensForTokens(
                1_000e18,
                0,
                path,
                bob,
                block.timestamp + 1 hours
            );

            path[0] = address(tokenA);
            path[1] = address(tokenB);
        }

        vm.stopPrank();

        address pair = factory.getPair(
            address(tokenA),
            address(tokenB)
        );
        (uint112 reserve0, uint112 reserve1, ) = DEXPair(pair).getReserves();
        uint256 k = uint256(reserve0) * uint256(reserve1);

        assertGt(k, 100_000e18 * 100_000e18);
    }

    function testFuzz_SwapAmounts(uint256 amountIn) public {
        amountIn = bound(amountIn, 1e15, 10_000e18);

        vm.startPrank(alice);

        router.addLiquidity(
            address(tokenA),
            address(tokenB),
            100_000e18,
            100_000e18,
            100_000e18,
            100_000e18,
            alice,
            block.timestamp + 1 hours
        );

        vm.stopPrank();

        vm.startPrank(bob);

        address[] memory path = new address[](2);
        path[0] = address(tokenA);
        path[1] = address(tokenB);

        uint256[] memory amounts = router.getAmountsOut(amountIn, path);

        uint256 balanceBBefore = tokenB.balanceOf(bob);

        router.swapExactTokensForTokens(
            amountIn,
            0,
            path,
            bob,
            block.timestamp + 1 hours
        );

        uint256 actualOut = tokenB.balanceOf(bob) - balanceBBefore;
        assertEq(actualOut, amounts[1]);

        vm.stopPrank();
    }
}

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

This test suite covers:

  • Pair creation: Verifying deterministic addressing, duplicate prevention, and input validation.
  • Liquidity operations: Adding initial liquidity, adding proportional liquidity, and removing liquidity with balance verification.
  • Swap mechanics: Exact input swaps, exact output swaps, multi-hop routing, and fee accumulation verification.
  • Protection mechanisms: Slippage revert, deadline expiry, and invariant preservation.
  • Fuzz testing: Randomized swap amounts to catch edge cases the fixed tests miss.

Run the full suite with:

bash
forge test -vvv

For gas benchmarking:

bash
forge test --gas-report

I typically see gas costs around 120k for a simple swap and 180k for adding liquidity. If your numbers are significantly higher, check for unnecessary storage reads or redundant SLOAD operations.


Security Checklist

Security is not an afterthought — it is the foundation. Every DEX I have built or reviewed follows this checklist before touching mainnet:

Reentrancy protection:

  • All state-changing functions use nonReentrant modifier on the pair contract
  • Checks-Effects-Interactions pattern followed everywhere
  • No callbacks to untrusted contracts during state transitions

Integer safety:

  • Solidity 0.8.24+ provides built-in overflow/underflow protection
  • unchecked blocks used only for operations proven safe (timestamp arithmetic, loop counters)
  • Reserve values stored as uint112 to fit two slots plus timestamp into one storage slot

Access control:

  • Factory fee configuration restricted to feeToSetter
  • No admin functions on pair contracts — they are autonomous
  • Router has no special privileges — it is a helper, not an owner

Oracle manipulation resistance:

  • TWAP oracle uses cumulative prices, not spot prices
  • Price manipulation requires sustaining the fake price for the entire observation window
  • External contracts should use multi-block TWAP for any price-sensitive operations

Front-running and MEV protection:

  • Deadline parameter prevents stale transactions from executing
  • Minimum output amounts protect against sandwich attacks
  • In production, integrate Flashbots Protect or a similar private mempool

Economic attacks:

  • MINIMUM_LIQUIDITY prevents LP token price manipulation on empty pools
  • K invariant check prevents any swap that would decrease the product
  • Fee-on-transfer tokens are not natively supported — handle them with a separate router function

Token compatibility:

  • Uses SafeERC20 for all token transfers in the router
  • Handles non-standard ERC-20 tokens that do not return booleans
  • Does not assume decimals() is 18 — calculations are unit-agnostic

Deployment security:

  • Deploy factory first, then router with factory address
  • Verify all contracts on Etherscan immediately
  • Use a multi-sig for feeToSetter, never an EOA
  • Test on a fork of mainnet using forge test --fork-url before deploying

Additional checks I run on every DEX audit:

 Flash loan attack vectors analyzed
 Price oracle manipulation tested
 Sandwich attack simulation passed
 Rug pull resistance verified (no admin drain functions)
 Gas griefing scenarios tested
 Block timestamp manipulation considered
 CREATE2 address collision probability acceptable
 All events indexed for subgraph compatibility

If you skip any item on this checklist, you are not ready for mainnet. I have seen eight-figure losses from DEX exploits that would have been caught by a single line on this list. Do not be a statistic.


Key Takeaways

Building a DEX is one of the most technically demanding projects in DeFi. Here is what matters:

  1. The constant product formula is elegant but unforgiving. x * y = k is four characters of math that governs billions of dollars. Understand it completely before writing any Solidity. Know how fees affect k. Know how impermanent loss works. Know why concentrated liquidity (Uniswap V3) exists.
  1. Separation of concerns saves lives. Factory creates pairs. Pairs manage reserves. Router handles UX. Each contract has one job. When something goes wrong — and something always goes wrong — you know exactly where to look.
  1. Security is not a feature, it is a requirement. Reentrancy guards, overflow protection, slippage limits, deadline enforcement. None of these are optional. Every missing protection is an exploit waiting to happen.
  1. Test with Foundry fuzz testing. Fixed test cases catch the bugs you imagine. Fuzz testing catches the bugs you never imagined. Run testFuzz_ functions with at least 10,000 iterations before any deployment.
  1. Fees must compound automatically. LP providers should never need to claim fees manually. The reserve growth mechanism handles this elegantly. Keep it simple.
  1. Multi-hop routing is essential. Not every token pair will have direct liquidity. The router must support arbitrary-length paths through intermediate pairs. This is where DEX aggregation starts.
  1. Fork-test on mainnet before deploying. Use forge test --fork-url to test against real token contracts, real WETH, and real liquidity conditions. Mocks are good. Forks are better.

The contracts in this guide are a solid foundation for a production DEX. They follow the patterns that have secured billions in TVL across Uniswap, SushiSwap, and their forks. But no contract is complete without a professional audit. Get at least two independent audits — one from a firm and one from an independent auditor — before deploying to mainnet with real user funds.

If you are building a DEX or any DeFi protocol and need engineering support, I work with teams on smart contract development, security reviews, and full-stack DeFi builds. Check out my services or reach out directly.


*Uvin Vindula is a Web3 and AI engineer based between Sri Lanka and the UK, building production-grade decentralized applications and DeFi protocols. Follow his work at iamuvin.com or connect on X @iamuvin.*

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.