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
- 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.
- 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.
- 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.
- 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.
- 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.