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:
- Add liquidity β deposit two tokens to form a trading pair.
- Swap tokens β trade one token for another using the pool.
- 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
  
  
  π§© 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);
    }
}
  
  
  π§© 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);
    }
}
  
  
  π§© 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;
    }
}
  
  
  π§© 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;
    }
}
  
  
  π§ͺ 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);
    }
}
βοΈ Run Locally
- Install Foundry
   curl -L https://foundry.paradigm.xyz | bash
   foundryup
- Clone project
   git clone <your_repo_url>
   cd day-30-solidity
- Run tests
   forge test -vv
- Deploy
   forge script script/Deploy.s.sol:DeployScript --broadcast --rpc-url <RPC_URL>
π How the AMM Maintains Balance
Every swap obeys:
x * y = k
Where:
- 
xandyare the reserves of both tokens.
- 
kis a constant that should never decrease. By adjustingxory, 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)