Advanced

AMMs Deep Dive — Project — minimal AMM pair

Build a minimal x·y=k AMM.

Developer Advanced
7/8 — AMMs Deep Dive — Project — minimal AMM pair

What to write

  1. State + events
    Track reserves, feeBps, LP supply, and a simple lock; emit Swap/Mint/Burn/Sync.
    IERC20 public immutable token0;
    IERC20 public immutable token1;
    uint256 public reserve0;
    uint256 public reserve1;
    uint256 public feeBps = 30; // 0.30%
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    bool private locked;
    
    event Swap(address indexed sender, bool zeroForOne, uint256 amountIn, uint256 amountOut, uint256 r0, uint256 r1);
    event Mint(address indexed sender, uint256 amount0, uint256 amount1, uint256 liquidity);
    event Burn(address indexed sender, uint256 amount0, uint256 amount1, uint256 liquidity);
    event Sync(uint256 r0, uint256 r1);

    Keeps the surface minimal and observable for UIs and oracles.

  2. Add liquidity (mint)
    Proportional mint with initial check.
    function addLiquidity(uint256 amount0, uint256 amount1) external returns (uint256 liquidity) {
        require(!locked, "reentrancy"); locked = true;
        token0.transferFrom(msg.sender, address(this), amount0);
        token1.transferFrom(msg.sender, address(this), amount1);
        if (totalSupply == 0) {
            liquidity = sqrt(amount0 * amount1);
        } else {
            liquidity = min((amount0 * totalSupply) / reserve0, (amount1 * totalSupply) / reserve1);
        }
        require(liquidity > 0, "no liq");
        balanceOf[msg.sender] += liquidity;
        totalSupply += liquidity;
        reserve0 += amount0;
        reserve1 += amount1;
        emit Mint(msg.sender, amount0, amount1, liquidity);
        emit Sync(reserve0, reserve1);
        locked = false;
    }

    Keeps the pool ratio and emits events for dashboards.

  3. Remove liquidity (burn)
    Return proportional reserves.
    function removeLiquidity(uint256 liquidity) external returns (uint256 amt0, uint256 amt1) {
        require(!locked, "reentrancy"); locked = true;
        require(liquidity > 0 && liquidity <= balanceOf[msg.sender], "bad liq");
        amt0 = (liquidity * reserve0) / totalSupply;
        amt1 = (liquidity * reserve1) / totalSupply;
        balanceOf[msg.sender] -= liquidity;
        totalSupply -= liquidity;
        reserve0 -= amt0;
        reserve1 -= amt1;
        token0.transfer(msg.sender, amt0);
        token1.transfer(msg.sender, amt1);
        emit Burn(msg.sender, amt0, amt1, liquidity);
        emit Sync(reserve0, reserve1);
        locked = false;
    }

    Uses proportional withdraw; emits for off-chain listeners.

  4. Swap math
    Fee-first, enforce minOut, update reserves.
    function swap(uint256 amountIn, bool zeroForOne, uint256 minOut) external returns (uint256 amountOut) {
        require(!locked, "reentrancy"); locked = true;
        uint256 fee = (amountIn * feeBps) / 10_000;
        uint256 amountInAfterFee = amountIn - fee;
        if (zeroForOne) {
            token0.transferFrom(msg.sender, address(this), amountIn);
            uint256 newX = reserve0 + amountInAfterFee;
            uint256 newY = (reserve0 * reserve1) / newX;
            amountOut = reserve1 - newY;
            require(amountOut >= minOut, "slip");
            reserve0 = newX;
            reserve1 = newY;
            token1.transfer(msg.sender, amountOut);
        } else {
            token1.transferFrom(msg.sender, address(this), amountIn);
            uint256 newY = reserve1 + amountInAfterFee;
            uint256 newX = (reserve0 * reserve1) / newY;
            amountOut = reserve0 - newX;
            require(amountOut >= minOut, "slip");
            reserve0 = newX;
            reserve1 = newY;
            token0.transfer(msg.sender, amountOut);
        }
        emit Swap(msg.sender, zeroForOne, amountInAfterFee, amountOut, reserve0, reserve1);
        emit Sync(reserve0, reserve1);
        locked = false;
    }

    Applies fee first, protects k, and enforces user slippage.

  5. Price sync (TWAP prep)
    Expose a manual sync hook to resync reserves and update cumulative prices.
    function sync() external {
        reserve0 = token0.balanceOf(address(this));
        reserve1 = token1.balanceOf(address(this));
        emit Sync(reserve0, reserve1);
    }

    Lets you resync if tokens are sent directly; hook in cumulative price tracking here.

Why Small, observable surface with fee-first math makes audits easier and UIs predictable.

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

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

contract MiniAMM {
    IERC20 public immutable token0;
    IERC20 public immutable token1;
    uint256 public reserve0;
    uint256 public reserve1;
    uint256 public feeBps = 30; // 0.30%
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    bool private locked;

    event Swap(address indexed sender, bool zeroForOne, uint256 amountIn, uint256 amountOut, uint256 r0, uint256 r1);
    event Mint(address indexed sender, uint256 amount0, uint256 amount1, uint256 liquidity);
    event Burn(address indexed sender, uint256 amount0, uint256 amount1, uint256 liquidity);
    event Sync(uint256 r0, uint256 r1);

    constructor(address t0, address t1) {
        token0 = IERC20(t0);
        token1 = IERC20(t1);
    }

    modifier noReentrancy() { require(!locked, "reentrancy"); locked = true; _; locked = false; }

    function addLiquidity(uint256 amount0, uint256 amount1) external noReentrancy returns (uint256 liquidity) {
        token0.transferFrom(msg.sender, address(this), amount0);
        token1.transferFrom(msg.sender, address(this), amount1);
        if (totalSupply == 0) {
            liquidity = sqrt(amount0 * amount1);
        } else {
            liquidity = min((amount0 * totalSupply) / reserve0, (amount1 * totalSupply) / reserve1);
        }
        require(liquidity > 0, "no liq");
        balanceOf[msg.sender] += liquidity;
        totalSupply += liquidity;
        reserve0 += amount0;
        reserve1 += amount1;
        emit Mint(msg.sender, amount0, amount1, liquidity);
        emit Sync(reserve0, reserve1);
    }

    function removeLiquidity(uint256 liquidity) external noReentrancy returns (uint256 amt0, uint256 amt1) {
        require(liquidity > 0 && liquidity <= balanceOf[msg.sender], "bad liq");
        amt0 = (liquidity * reserve0) / totalSupply;
        amt1 = (liquidity * reserve1) / totalSupply;
        balanceOf[msg.sender] -= liquidity;
        totalSupply -= liquidity;
        reserve0 -= amt0;
        reserve1 -= amt1;
        token0.transfer(msg.sender, amt0);
        token1.transfer(msg.sender, amt1);
        emit Burn(msg.sender, amt0, amt1, liquidity);
        emit Sync(reserve0, reserve1);
    }

    function swap(uint256 amountIn, bool zeroForOne, uint256 minOut) external noReentrancy returns (uint256 amountOut) {
        uint256 fee = (amountIn * feeBps) / 10_000;
        uint256 amountInAfterFee = amountIn - fee;
        if (zeroForOne) {
            token0.transferFrom(msg.sender, address(this), amountIn);
            uint256 newX = reserve0 + amountInAfterFee;
            uint256 newY = (reserve0 * reserve1) / newX;
            amountOut = reserve1 - newY;
            require(amountOut >= minOut, "slip");
            reserve0 = newX;
            reserve1 = newY;
            token1.transfer(msg.sender, amountOut);
        } else {
            token1.transferFrom(msg.sender, address(this), amountIn);
            uint256 newY = reserve1 + amountInAfterFee;
            uint256 newX = (reserve0 * reserve1) / newY;
            amountOut = reserve0 - newX;
            require(amountOut >= minOut, "slip");
            reserve0 = newX;
            reserve1 = newY;
            token0.transfer(msg.sender, amountOut);
        }
        emit Swap(msg.sender, zeroForOne, amountInAfterFee, amountOut, reserve0, reserve1);
        emit Sync(reserve0, reserve1);
    }

    function sync() external {
        reserve0 = token0.balanceOf(address(this));
        reserve1 = token1.balanceOf(address(this));
        emit Sync(reserve0, reserve1);
    }

    function sqrt(uint256 x) internal pure returns (uint256 y) { // Babylonian
        if (x == 0) return 0;
        uint256 z = (x + 1) / 2;
        y = x;
        while (z < y) { y = z; z = (x / z + z) / 2; }
    }
    function min(uint256 a, uint256 b) internal pure returns (uint256) { return a < b ? a : b; }
}

Try itProperty tests: k never shrinks after fees, minOut enforced, zero-liquidity swaps revert, LP shares stay proportional. Add TWAP tracking in sync for oracle readers.

Key points
  • State & setup:Reserve0, reserve1, feeBps, LP totalSupply/balances, simple reentrancy guard, and events (Swap/Mint/Burn/Sync) for UIs/oracles.
  • Adding liquidity (mint):Scale LP shares by existing ratio; prevent zero-liquidity first mints; emit Mint and Sync.
  • Removing liquidity (burn):Return proportional token amounts; update reserves; emit Burn and Sync.
  • Swap flow:Apply fee to input, solve output via x·y=k, enforce minOut, update reserves, emit Swap.
  • Oracle hooks:Track price cumulatives each swap/sync for TWAP readers; clamp on low liquidity.
  • Safety:Guard zero-liquidity swaps, fee bounds, and reentrancy; sync reserves after external transfers.
← Previous section
Next section →