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.
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:
x * y = kWhere:
x= reserve of token Ay= reserve of token Bk= constant (must remain unchanged after trades)
The Math Behind Swaps
Let's say a pool has 100 ETH and 200,000 USDC:
k = 100 * 200,000 = 20,000,000If someone wants to buy ETH with 10,000 USDC:
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 ETHNotice the price changed from 2,000 to 2,100 USDC/ETH. This is price impact - larger trades move the price more.
| Trade Size | ETH Received | Effective Price | Price Impact |
|---|---|---|---|
| 1,000 USDC | 0.498 ETH | 2,008 USDC/ETH | 0.4% |
| 10,000 USDC | 4.76 ETH | 2,100 USDC/ETH | 5% |
| 50,000 USDC | 20 ETH | 2,500 USDC/ETH | 25% |
Building the Smart Contract
Let's build a simple DEX step by step.
Core Contract Structure
// 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:
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
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
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:
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
/// @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:
// 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:
// 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:
// 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:
// 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
| Risk | Mitigation |
|---|---|
| Price manipulation | Use TWAPs, not spot prices |
| Sandwich attacks | Enforce tight slippage |
| Liquidity removal | Time-locks for large withdrawals |
| Oracle manipulation | Multiple price sources |
Next Steps
This simple DEX demonstrates core AMM concepts. Production DEXs add:
- Multi-pool routing - Find best path across multiple pools
- Concentrated liquidity - Uniswap V3 style position management
- Flash swaps - Borrow without collateral within one transaction
- Governance - DAO-controlled fee parameters
- 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.
Resources
- Uniswap V2 Core Contracts
- Constant Function Market Makers Paper
- DeFi Security Best Practices
- Foundry Testing Framework
Nawab Khairuzzaman
Full-Stack Web & Blockchain Developer with 6+ years of experience building scalable applications.