DEV Community

Cover image for 🧠 Day 30 of #30DaysOfSolidity β€” Build a Simple Token Exchange (AMM) Using Foundry
Saurav Kumar
Saurav Kumar

Posted on

🧠 Day 30 of #30DaysOfSolidity β€” Build a Simple Token Exchange (AMM) Using Foundry

Building a decentralized exchange (DEX) might sound complex, but today we’ll create a simple, constant-product Automated Market Maker (AMM) from scratch using Solidity and Foundry β€” the foundation of DeFi platforms like Uniswap.

This marks the final day of #30DaysOfSolidity, and it’s the perfect way to wrap up the journey β€” by building your own mini DEX πŸš€


πŸ’‘ What You’ll Learn

  • How token swaps work without an order book
  • How to create a liquidity pool and mint LP tokens
  • How to calculate swap outputs using x * y = k
  • How to apply trading fees and maintain balance

🧱 Project Overview

Our decentralized exchange allows users to:

  1. Add liquidity β€” deposit two tokens to form a trading pair.
  2. Swap tokens β€” trade one token for another using the pool.
  3. Remove liquidity β€” withdraw tokens by burning LP tokens.

We’ll build three main contracts:

  • ExchangeFactory – creates and tracks trading pairs.
  • ExchangePair – manages swaps, liquidity, and reserves.
  • MockERC20 – simple mintable tokens for testing.

πŸ“‚ Folder Structure

day-30-solidity/
β”œβ”€ src/
β”‚  β”œβ”€ ERC20.sol
β”‚  β”œβ”€ MockERC20.sol
β”‚  β”œβ”€ ExchangeFactory.sol
β”‚  └─ ExchangePair.sol
β”œβ”€ script/
β”‚  └─ Deploy.s.sol
β”œβ”€ test/
β”‚  └─ Exchange.t.sol
└─ README.md
Enter fullscreen mode Exit fullscreen mode

🧩 Step 1 β€” Minimal ERC20 Implementation (ERC20.sol)

We start with a lightweight ERC20 token for LP tokens used inside pairs.

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

contract ERC20 {
    string public name;
    string public symbol;
    uint8 public immutable decimals = 18;
    uint256 public totalSupply;

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

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

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

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

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

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

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

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

    function _transfer(address from, address to, uint256 amount) internal {
        require(balanceOf[from] >= amount, "ERC20: balance");
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        emit Transfer(from, to, amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

🧩 Step 2 β€” Mock Tokens (MockERC20.sol)

We use mintable tokens for local testing.

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

import "./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);
    }
}
Enter fullscreen mode Exit fullscreen mode

🧩 Step 3 β€” Exchange Pair Contract (ExchangePair.sol)

This is the heart of our AMM.
It uses the constant product formula x * y = k and 0.3% swap fee.

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

import "./ERC20.sol";

interface IERC20Minimal {
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function transfer(address to, uint256 amount) external returns (bool);
    function balanceOf(address owner) external view returns (uint256);
}

contract ExchangePair is ERC20 {
    IERC20Minimal public token0;
    IERC20Minimal public token1;

    uint112 private reserve0;
    uint112 private reserve1;

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

    uint256 public constant FEE_NUM = 3;    // 0.3%
    uint256 public constant FEE_DEN = 1000;

    constructor(address _token0, address _token1) ERC20("Simple LP", "sLP") {
        require(_token0 != _token1, "IDENTICAL");
        token0 = IERC20Minimal(_token0);
        token1 = IERC20Minimal(_token1);
    }

    function getReserves() public view returns (uint112, uint112) {
        return (reserve0, reserve1);
    }

    function mint(address to) external returns (uint256 liquidity) {
        uint256 balance0 = token0.balanceOf(address(this));
        uint256 balance1 = token1.balanceOf(address(this));
        uint256 amount0 = balance0 - reserve0;
        uint256 amount1 = balance1 - reserve1;

        if (totalSupply == 0) {
            liquidity = _sqrt(amount0 * amount1);
        } else {
            liquidity = min((amount0 * totalSupply) / reserve0, (amount1 * totalSupply) / reserve1);
        }
        require(liquidity > 0, "INSUFFICIENT_LIQUIDITY");
        _mint(to, liquidity);

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

    function burn(address to) external returns (uint256 amount0, uint256 amount1) {
        uint256 _totalSupply = totalSupply;
        uint256 liquidity = balanceOf[msg.sender];
        _burn(msg.sender, liquidity);

        amount0 = (liquidity * token0.balanceOf(address(this))) / _totalSupply;
        amount1 = (liquidity * token1.balanceOf(address(this))) / _totalSupply;

        require(amount0 > 0 && amount1 > 0, "INSUFFICIENT_BURN");

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

        _update(token0.balanceOf(address(this)), token1.balanceOf(address(this)));
        emit Burn(msg.sender, to, amount0, amount1, liquidity);
    }

    function swap(uint256 amount0Out, uint256 amount1Out, address to) external {
        require(amount0Out > 0 || amount1Out > 0, "NO_OUTPUT");
        (uint112 _reserve0, uint112 _reserve1) = getReserves();

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

        uint256 balance0 = token0.balanceOf(address(this));
        uint256 balance1 = 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, "NO_INPUT");

        uint256 balance0Adj = (balance0 * FEE_DEN) - (amount0In * FEE_NUM);
        uint256 balance1Adj = (balance1 * FEE_DEN) - (amount1In * FEE_NUM);
        require(balance0Adj * balance1Adj >= uint256(_reserve0) * uint256(_reserve1) * (FEE_DEN**2), "K");

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

    function _update(uint256 b0, uint256 b1) private {
        reserve0 = uint112(b0);
        reserve1 = uint112(b1);
        emit Sync(reserve0, reserve1);
    }

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

    function min(uint256 a, uint256 b) private pure returns (uint256) {
        return a < b ? a : b;
    }
}
Enter fullscreen mode Exit fullscreen mode

🧩 Step 4 β€” Factory Contract (ExchangeFactory.sol)

Manages creation and registry of pairs.

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

import "./ExchangePair.sol";

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

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

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

        ExchangePair newPair = new ExchangePair(token0, token1);
        pair = address(newPair);
        getPair[token0][token1] = pair;
        getPair[token1][token0] = pair;
        allPairs.push(pair);

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

    function allPairsLength() external view returns (uint256) {
        return allPairs.length;
    }
}
Enter fullscreen mode Exit fullscreen mode

πŸ§ͺ Step 5 β€” Testing the Exchange (Exchange.t.sol)

A quick Foundry test verifying liquidity and swaps.

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

import "forge-std/Test.sol";
import "../src/ExchangeFactory.sol";
import "../src/MockERC20.sol";
import "../src/ExchangePair.sol";

contract ExchangeTest is Test {
    ExchangeFactory factory;
    MockERC20 tokenA;
    MockERC20 tokenB;
    ExchangePair pair;

    function setUp() public {
        factory = new ExchangeFactory();
        tokenA = new MockERC20("Token A", "TKA");
        tokenB = new MockERC20("Token B", "TKB");
        address pairAddr = factory.createPair(address(tokenA), address(tokenB));
        pair = ExchangePair(pairAddr);

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

    function testLiquidityAndSwap() public {
        tokenA.transfer(address(pair), 1000 ether);
        tokenB.transfer(address(pair), 1000 ether);
        pair.mint(address(this));

        tokenA.transfer(address(pair), 10 ether);
        pair.swap(0, 9 ether, address(this));

        (uint112 r0, uint112 r1) = pair.getReserves();
        assertTrue(r0 > 0 && r1 > 0);
    }
}
Enter fullscreen mode Exit fullscreen mode

βš™οΈ Run Locally

  1. Install Foundry
   curl -L https://foundry.paradigm.xyz | bash
   foundryup
Enter fullscreen mode Exit fullscreen mode
  1. Clone project
   git clone <your_repo_url>
   cd day-30-solidity
Enter fullscreen mode Exit fullscreen mode
  1. Run tests
   forge test -vv
Enter fullscreen mode Exit fullscreen mode
  1. Deploy
   forge script script/Deploy.s.sol:DeployScript --broadcast --rpc-url <RPC_URL>
Enter fullscreen mode Exit fullscreen mode

πŸ“Š How the AMM Maintains Balance

Every swap obeys:

x * y = k
Enter fullscreen mode Exit fullscreen mode

Where:

  • x and y are the reserves of both tokens.
  • k is a constant that should never decrease. By adjusting x or y, the price automatically rebalances.

The swap fee slightly increases reserves over time, rewarding liquidity providers.


🧠 What You’ve Learned

  • Built a mini-Uniswap from scratch
  • Understood the constant-product formula
  • Implemented token swaps, mint/burn LP tokens
  • Used Foundry for professional-grade Solidity testing

🏁 Final Thoughts

This completes Day 30 of #30DaysOfSolidity πŸŽ‰
You’ve now built:

  • A stablecoin
  • Lending/Borrowing systems
  • DAOs, Escrow, NFT Marketplaces
  • and finally β€” a Decentralized Exchange.

Each day has taken you a step closer to real-world DeFi mastery.

If you found this helpful, drop a ❀️ on Dev.to and share your version!

Top comments (0)