Back to Blog
BlockchainDeFiSolidityAMMSmart Contracts

Understanding AMM: Build Your Own DEX from Scratch

Learn how Automated Market Makers work by building a simple DEX. Covers the constant product formula, liquidity pools, swap mechanics, and security considerations.

Nawab Khairuzzaman9 min read
Share:
BLOG POST

Decentralized exchanges have revolutionized crypto trading. Unlike traditional order books, AMMs use mathematical formulas to determine prices and enable permissionless trading. In this guide, we'll build a simple DEX to understand exactly how they work.

What is an AMM?

An Automated Market Maker (AMM) is a smart contract that holds reserves of two (or more) tokens and allows anyone to trade between them. Instead of matching buyers with sellers, it uses a mathematical formula to price trades.

"AMMs replaced order books with math. Anyone can trade, anyone can provide liquidity, and prices are determined algorithmically."

The most common formula is the Constant Product Market Maker, used by Uniswap:

plaintext
x * y = k

Where:

  • x = reserve of token A
  • y = reserve of token B
  • k = constant (must remain unchanged after trades)

The Math Behind Swaps

Let's say a pool has 100 ETH and 200,000 USDC:

plaintext
k = 100 * 200,000 = 20,000,000

If someone wants to buy ETH with 10,000 USDC:

plaintext
New USDC reserve: 200,000 + 10,000 = 210,000
New ETH reserve: 20,000,000 / 210,000 = 95.24 ETH
ETH received: 100 - 95.24 = 4.76 ETH
Effective price: 10,000 / 4.76 = 2,100 USDC per ETH

Notice the price changed from 2,000 to 2,100 USDC/ETH. This is price impact - larger trades move the price more.

Trade SizeETH ReceivedEffective PricePrice Impact
1,000 USDC0.498 ETH2,008 USDC/ETH0.4%
10,000 USDC4.76 ETH2,100 USDC/ETH5%
50,000 USDC20 ETH2,500 USDC/ETH25%

Building the Smart Contract

Let's build a simple DEX step by step.

Core Contract Structure

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
 
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
 
contract SimpleDEX is ERC20, ReentrancyGuard {
    IERC20 public immutable tokenA;
    IERC20 public immutable tokenB;
 
    uint256 public reserveA;
    uint256 public reserveB;
 
    uint256 public constant FEE_NUMERATOR = 3;
    uint256 public constant FEE_DENOMINATOR = 1000; // 0.3% fee
 
    event LiquidityAdded(
        address indexed provider,
        uint256 amountA,
        uint256 amountB,
        uint256 liquidity
    );
 
    event LiquidityRemoved(
        address indexed provider,
        uint256 amountA,
        uint256 amountB,
        uint256 liquidity
    );
 
    event Swap(
        address indexed trader,
        address tokenIn,
        uint256 amountIn,
        uint256 amountOut
    );
 
    constructor(
        address _tokenA,
        address _tokenB
    ) ERC20("SimpleDEX LP", "SDEX-LP") {
        tokenA = IERC20(_tokenA);
        tokenB = IERC20(_tokenB);
    }
}

Liquidity Math Library

Calculate liquidity amounts correctly:

solidity
library LiquidityMath {
    function min(uint256 a, uint256 b) internal pure returns (uint256) {
        return a < b ? a : b;
    }
 
    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;
        }
    }
 
    /// @notice Calculate optimal liquidity to mint
    function calculateLiquidity(
        uint256 amountA,
        uint256 amountB,
        uint256 reserveA,
        uint256 reserveB,
        uint256 totalSupply
    ) internal pure returns (uint256) {
        if (totalSupply == 0) {
            // Initial liquidity: geometric mean
            return sqrt(amountA * amountB);
        }
        // Subsequent: proportional to existing liquidity
        return min(
            (amountA * totalSupply) / reserveA,
            (amountB * totalSupply) / reserveB
        );
    }
}

Adding Liquidity

solidity
function addLiquidity(
    uint256 amountA,
    uint256 amountB
) external nonReentrant returns (uint256 liquidity) {
    require(amountA > 0 && amountB > 0, "Invalid amounts");
 
    // For subsequent deposits, enforce ratio
    if (reserveA > 0 && reserveB > 0) {
        uint256 optimalB = (amountA * reserveB) / reserveA;
        require(
            amountB >= optimalB * 99 / 100 && amountB <= optimalB * 101 / 100,
            "Invalid ratio"
        );
    }
 
    // Transfer tokens to pool
    tokenA.transferFrom(msg.sender, address(this), amountA);
    tokenB.transferFrom(msg.sender, address(this), amountB);
 
    // Calculate and mint LP tokens
    liquidity = LiquidityMath.calculateLiquidity(
        amountA,
        amountB,
        reserveA,
        reserveB,
        totalSupply()
    );
 
    require(liquidity > 0, "Insufficient liquidity minted");
    _mint(msg.sender, liquidity);
 
    // Update reserves
    reserveA += amountA;
    reserveB += amountB;
 
    emit LiquidityAdded(msg.sender, amountA, amountB, liquidity);
}

Removing Liquidity

solidity
function removeLiquidity(
    uint256 liquidity
) external nonReentrant returns (uint256 amountA, uint256 amountB) {
    require(liquidity > 0, "Invalid liquidity");
    require(balanceOf(msg.sender) >= liquidity, "Insufficient balance");
 
    uint256 _totalSupply = totalSupply();
 
    // Calculate proportional amounts
    amountA = (liquidity * reserveA) / _totalSupply;
    amountB = (liquidity * reserveB) / _totalSupply;
 
    require(amountA > 0 && amountB > 0, "Insufficient amounts");
 
    // Burn LP tokens
    _burn(msg.sender, liquidity);
 
    // Update reserves
    reserveA -= amountA;
    reserveB -= amountB;
 
    // Transfer tokens to user
    tokenA.transfer(msg.sender, amountA);
    tokenB.transfer(msg.sender, amountB);
 
    emit LiquidityRemoved(msg.sender, amountA, amountB, liquidity);
}

The Swap Function

This is where the magic happens:

solidity
function swap(
    address tokenIn,
    uint256 amountIn,
    uint256 minAmountOut
) external nonReentrant returns (uint256 amountOut) {
    require(amountIn > 0, "Invalid input amount");
    require(
        tokenIn == address(tokenA) || tokenIn == address(tokenB),
        "Invalid token"
    );
 
    bool isTokenA = tokenIn == address(tokenA);
    (
        IERC20 inputToken,
        IERC20 outputToken,
        uint256 reserveIn,
        uint256 reserveOut
    ) = isTokenA
        ? (tokenA, tokenB, reserveA, reserveB)
        : (tokenB, tokenA, reserveB, reserveA);
 
    // Transfer input tokens
    inputToken.transferFrom(msg.sender, address(this), amountIn);
 
    // Calculate output with fee
    uint256 amountInWithFee = amountIn * (FEE_DENOMINATOR - FEE_NUMERATOR);
    amountOut = (amountInWithFee * reserveOut) /
        (reserveIn * FEE_DENOMINATOR + amountInWithFee);
 
    require(amountOut >= minAmountOut, "Slippage exceeded");
    require(amountOut < reserveOut, "Insufficient liquidity");
 
    // Update reserves
    if (isTokenA) {
        reserveA += amountIn;
        reserveB -= amountOut;
    } else {
        reserveB += amountIn;
        reserveA -= amountOut;
    }
 
    // Transfer output tokens
    outputToken.transfer(msg.sender, amountOut);
 
    emit Swap(msg.sender, tokenIn, amountIn, amountOut);
}

Price and Quote Functions

solidity
/// @notice Get current price of tokenA in terms of tokenB
function getPrice() external view returns (uint256) {
    require(reserveA > 0, "No liquidity");
    return (reserveB * 1e18) / reserveA;
}
 
/// @notice Calculate output amount for a given input
function getAmountOut(
    address tokenIn,
    uint256 amountIn
) external view returns (uint256) {
    bool isTokenA = tokenIn == address(tokenA);
    (uint256 reserveIn, uint256 reserveOut) = isTokenA
        ? (reserveA, reserveB)
        : (reserveB, reserveA);
 
    uint256 amountInWithFee = amountIn * (FEE_DENOMINATOR - FEE_NUMERATOR);
    return (amountInWithFee * reserveOut) /
        (reserveIn * FEE_DENOMINATOR + amountInWithFee);
}
 
/// @notice Calculate price impact for a given trade
function getPriceImpact(
    address tokenIn,
    uint256 amountIn
) external view returns (uint256) {
    bool isTokenA = tokenIn == address(tokenA);
    (uint256 reserveIn, uint256 reserveOut) = isTokenA
        ? (reserveA, reserveB)
        : (reserveB, reserveA);
 
    // Spot price (no trade)
    uint256 spotPrice = (reserveOut * 1e18) / reserveIn;
 
    // Execution price (with trade)
    uint256 amountOut = this.getAmountOut(tokenIn, amountIn);
    uint256 executionPrice = (amountOut * 1e18) / amountIn;
 
    // Price impact as percentage (scaled by 1e18)
    return ((spotPrice - executionPrice) * 1e18) / spotPrice;
}

Frontend Integration

Here's a basic React swap component using the hooks from my Web3 wallet integration guide:

tsx
// components/SwapInterface.tsx
'use client'
 
import { useState } from 'react'
import { useAccount, useReadContract, useWriteContract } from 'wagmi'
import { parseUnits, formatUnits } from 'viem'
 
const DEX_ADDRESS = '0x...' as const
const DEX_ABI = [...] as const
 
export function SwapInterface() {
  const { address } = useAccount()
  const [inputAmount, setInputAmount] = useState('')
  const [tokenIn, setTokenIn] = useState<'A' | 'B'>('A')
 
  // Get quote
  const { data: outputAmount } = useReadContract({
    address: DEX_ADDRESS,
    abi: DEX_ABI,
    functionName: 'getAmountOut',
    args: [
      tokenIn === 'A' ? TOKEN_A_ADDRESS : TOKEN_B_ADDRESS,
      parseUnits(inputAmount || '0', 18)
    ],
    query: { enabled: !!inputAmount }
  })
 
  // Get price impact
  const { data: priceImpact } = useReadContract({
    address: DEX_ADDRESS,
    abi: DEX_ABI,
    functionName: 'getPriceImpact',
    args: [
      tokenIn === 'A' ? TOKEN_A_ADDRESS : TOKEN_B_ADDRESS,
      parseUnits(inputAmount || '0', 18)
    ],
    query: { enabled: !!inputAmount }
  })
 
  const { writeContract, isPending } = useWriteContract()
 
  const handleSwap = () => {
    const minOutput = outputAmount
      ? (outputAmount * 99n) / 100n // 1% slippage
      : 0n
 
    writeContract({
      address: DEX_ADDRESS,
      abi: DEX_ABI,
      functionName: 'swap',
      args: [
        tokenIn === 'A' ? TOKEN_A_ADDRESS : TOKEN_B_ADDRESS,
        parseUnits(inputAmount, 18),
        minOutput
      ]
    })
  }
 
  const impactPercent = priceImpact
    ? Number(formatUnits(priceImpact, 16)).toFixed(2)
    : '0'
 
  return (
    <div className="p-6 bg-gray-900 rounded-xl max-w-md">
      <div className="space-y-4">
        <div>
          <label className="text-gray-400 text-sm">You pay</label>
          <div className="flex gap-2 mt-1">
            <input
              type="number"
              value={inputAmount}
              onChange={(e) => setInputAmount(e.target.value)}
              placeholder="0.0"
              className="flex-1 bg-gray-800 rounded-lg p-3"
            />
            <select
              value={tokenIn}
              onChange={(e) => setTokenIn(e.target.value as 'A' | 'B')}
              className="bg-gray-800 rounded-lg px-4"
            >
              <option value="A">ETH</option>
              <option value="B">USDC</option>
            </select>
          </div>
        </div>
 
        <div>
          <label className="text-gray-400 text-sm">You receive</label>
          <div className="bg-gray-800 rounded-lg p-3 mt-1">
            {outputAmount
              ? formatUnits(outputAmount, 18)
              : '0.0'}
          </div>
        </div>
 
        {Number(impactPercent) > 1 && (
          <div className="text-yellow-500 text-sm">
            Price impact: {impactPercent}%
          </div>
        )}
 
        <button
          onClick={handleSwap}
          disabled={!inputAmount || isPending}
          className="w-full py-3 bg-blue-500 rounded-lg font-semibold
                     disabled:opacity-50 hover:bg-blue-600"
        >
          {isPending ? 'Swapping...' : 'Swap'}
        </button>
      </div>
    </div>
  )
}

Security Considerations

AMMs have unique security concerns. Here's what to watch for:

Slippage Protection

Always enforce minimum output amounts:

solidity
// BAD: No slippage protection
function swap(address tokenIn, uint256 amountIn) external {
    uint256 amountOut = calculateOutput(amountIn);
    // User might get front-run and receive less than expected
}
 
// GOOD: User specifies minimum acceptable output
function swap(
    address tokenIn,
    uint256 amountIn,
    uint256 minAmountOut // User's slippage tolerance
) external {
    uint256 amountOut = calculateOutput(amountIn);
    require(amountOut >= minAmountOut, "Slippage exceeded");
}

Reentrancy Protection

Use the checks-effects-interactions pattern and reentrancy guards:

solidity
// Always update state BEFORE external calls
function swap(...) external nonReentrant {
    // 1. Checks
    require(amountIn > 0, "Invalid amount");
 
    // 2. Effects (update state)
    reserveA += amountIn;
    reserveB -= amountOut;
 
    // 3. Interactions (external calls)
    tokenA.transferFrom(msg.sender, address(this), amountIn);
    tokenB.transfer(msg.sender, amountOut);
}

Flash Loan Attacks

Be aware of single-block price manipulation:

solidity
// Use time-weighted average prices (TWAP) for oracles
// Don't rely on spot price for important decisions
 
// Consider using block-based price updates
uint256 public lastPriceBlock;
uint256 public lastPrice;
 
function updatePrice() internal {
    if (block.number > lastPriceBlock) {
        lastPrice = (reserveB * 1e18) / reserveA;
        lastPriceBlock = block.number;
    }
}

Other Considerations

RiskMitigation
Price manipulationUse TWAPs, not spot prices
Sandwich attacksEnforce tight slippage
Liquidity removalTime-locks for large withdrawals
Oracle manipulationMultiple price sources

Next Steps

This simple DEX demonstrates core AMM concepts. Production DEXs add:

  1. Multi-pool routing - Find best path across multiple pools
  2. Concentrated liquidity - Uniswap V3 style position management
  3. Flash swaps - Borrow without collateral within one transaction
  4. Governance - DAO-controlled fee parameters
  5. Fee tiers - Different fees for different pairs

Conclusion

AMMs are elegant systems that enable permissionless trading through pure math. By building one yourself, you gain deep insight into:

  • How liquidity pools work
  • Why price impact exists
  • How fees accumulate for LPs
  • Security considerations in DeFi

For gas optimization techniques to make your DEX more efficient, check out my Solidity gas optimization guide.

Want Your Own Exchange?

I build production-ready cryptocurrency exchanges with advanced trading engines, secure wallet systems, and real-time order matching.

View Gig

Resources

  • Uniswap V2 Core Contracts
  • Constant Function Market Makers Paper
  • DeFi Security Best Practices
  • Foundry Testing Framework
N

Nawab Khairuzzaman

Full-Stack Web & Blockchain Developer with 6+ years of experience building scalable applications.

Comments

Related Posts