This content originally appeared on DEV Community and was authored by Saurav Kumar
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!
This content originally appeared on DEV Community and was authored by Saurav Kumar
Saurav Kumar | Sciencx (2025-10-30T18:31:02+00:00) 🧠 Day 30 of #30DaysOfSolidity — Build a Simple Token Exchange (AMM) Using Foundry. Retrieved from https://www.scien.cx/2025/10/30/%f0%9f%a7%a0-day-30-of-30daysofsolidity-build-a-simple-token-exchange-amm-using-foundry/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.