Build a minimal Automated Market Maker with Solidity.

Introduction
Hello again, hopefully I will be more consistent posting here from now on. Today we are talking about AMMs. DeFi today is impossible to imagine without AMMs (Automated Market Makers). They power decentralized exchanges like Uniswap, Curve, Balancer, and many more. Instead of relying on an order book (like Binance or Coinbase), AMMs let you swap tokens against a pool of liquidity using math.
In this tutorial, we’ll build a minimal AMM smart contract. a stripped-down version of Uniswap V2. By the end, you’ll:
Understand the x * y = k formula
See how swaps and slippage work in practice
Build and test your own AMM using Foundry + Solidity
Let’s dive in !!
What Are AMMs and Why Do They Matter?
Traditionally, exchanges rely on order books: buyers place bids, sellers place asks, and the exchange matches them. That requires market makers to provide liquidity.
AMMs replace this with a simple pool of tokens locked in a smart contract. Traders interact directly with the pool, swapping one token for another. Prices adjust automatically according to the pool’s balances.
This innovation gave us 24/7 permissionless liquidity. Anyone can:
Provide liquidity → deposit Token A + Token B and earn fees
Trade instantly → swap Token A for Token B without waiting for a counterparty
The Innovation of Uniswap: Constant Product Formula
The magic behind Uniswap is the constant product formula:
x * y = k
Where:
x= reserve of Token Ay= reserve of Token Bk= constant product
The pool must always satisfy this equation. So if you add Token A, the pool adjusts Token B to keep k unchanged → that’s how you get a swap.
Example:
Reserves: 100 ETH and 10,000 USDC → price is 1 ETH = 100 USDC
Swap in 10 ETH → new reserves must satisfy
(x + 10) * y = kYou’ll see slippage because the formula shifts the price.
What We’re Building
We’ll build:
An AMM contract that:
Accepts two ERC20 tokens
Lets users add liquidity (deposit both tokens)
Lets users swap one token for the other
Not production-ready, but it’ll show the mechanics clearly.
Project Setup
Tools & Stack
Foundry (fast, modern Solidity dev toolkit)
Solidity ^0.8.x
Local testnet (Anvil)
Initialize Project
forge init amm-tutorial
cd amm-tutorial
Install OpenZeppelin ERC20 for test tokens:
forge install OpenZeppelin/openzeppelin-contracts
AMM Contract: First Steps
First delete all default contracts, tests and scripts that come with foundry then, create src/AMM.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract AMM {
IERC20 public tokenA;
IERC20 public tokenB;
uint256 public reserveA;
uint256 public reserveB;
constructor(address _tokenA, address _tokenB) {
tokenA = IERC20(_tokenA);
tokenB = IERC20(_tokenB);
}
}
Here we:
Define the two tokens
Track reserves (amount of each token inside the pool)
Add Liquidity
function addLiquidity(uint256 amountA, uint256 amountB) external {
// Transfer tokens from provider
tokenA.transferFrom(msg.sender, address(this), amountA);
tokenB.transferFrom(msg.sender, address(this), amountB);
// Update reserves
reserveA += amountA;
reserveB += amountB;
}
💡 This allows users to deposit tokens into the pool. In a real DEX, you’d also mint LP tokens to track ownership, but we’ll keep it minimal for now.
Swap Function
Now the fun part: the swap.
function swap(address inputToken, uint256 inputAmount) external {
require(inputToken == address(tokenA) || inputToken == address(tokenB), "Invalid token");
bool isTokenA = inputToken == address(tokenA);
// Current reserves
(uint256 reserveIn, uint256 reserveOut) = isTokenA ? (reserveA, reserveB) : (reserveB, reserveA);
// Transfer input token
IERC20(inputToken).transferFrom(msg.sender, address(this), inputAmount);
// Constant product formula: (reserveIn + inputAmount) * (reserveOut - outputAmount) = reserveIn * reserveOut
uint256 inputWithFee = inputAmount * 997; // 0.3% fee
uint256 numerator = inputWithFee * reserveOut;
uint256 denominator = reserveIn * 1000 + inputWithFee;
uint256 outputAmount = numerator / denominator;
// Update reserves
if (isTokenA) {
reserveA += inputAmount;
reserveB -= outputAmount;
tokenB.transfer(msg.sender, outputAmount);
} else {
reserveB += inputAmount;
reserveA -= outputAmount;
tokenA.transfer(msg.sender, outputAmount);
}
}
Here’s what’s happening:
User sends Token A (or B) into the pool
Contract calculates how much Token B (or A) to send back
Applies a 0.3% fee (standard in Uniswap V2)
Updates reserves
Adding LP Tokens
So far, users can add liquidity, but there’s no clear way to represent their share of the pool. That’s where Liquidity Provider (LP) tokens come in.
Think of LP tokens as receipts:
When you add liquidity, you get LP tokens proportional to your share.
When you remove liquidity, you burn LP tokens to reclaim your share of Token A and Token B.
We’ll implement LP tokens as a simple ERC20 minted by the AMM contract. In the src folder create a ERC20.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract LPToken is ERC20 {
constructor() ERC20("LP Token", "LPT") {}
function mint(address to, uint amount) external {
_mint(to, amount);
}
function burn(address from, uint amount) external {
_burn(from, amount);
}
}
👉 Explanation:
We extend OpenZeppelin’s ERC20 to create
LPToken.mintis called by the AMM when liquidity is added.burnis called when liquidity is removed.
Integrating LP Tokens into the AMM
Now let’s wire LP tokens into the AMM contract.
State Variables
LPToken public lpToken;
uint public totalLiquidity;
mapping(address => uint) public liquidity;
lpToken: the LP token contract.totalLiquidity: total LP supply (backing the pool).liquidity: mapping to track how much LP each provider owns.
Updated addLiquidity
function addLiquidity(uint amountA, uint amountB) external {
require(amountA > 0 && amountB > 0, "Invalid amounts");
// Transfer tokens to pool
tokenA.transferFrom(msg.sender, address(this), amountA);
tokenB.transferFrom(msg.sender, address(this), amountB);
uint liquidityMinted;
if (totalLiquidity == 0) {
// First liquidity provider sets the initial ratio
liquidityMinted = sqrt(amountA * amountB);
} else {
// Subsequent liquidity: mint proportional LP tokens
liquidityMinted = min(
(amountA * totalLiquidity) / reserveA,
(amountB * totalLiquidity) / reserveB
);
}
require(liquidityMinted > 0, "Insufficient liquidity minted");
liquidity[msg.sender] += liquidityMinted;
totalLiquidity += liquidityMinted;
lpToken.mint(msg.sender, liquidityMinted);
reserveA += amountA;
reserveB += amountB;
}
👉 Explanation:
The first liquidity provider mints
sqrt(amountA * amountB)LP tokens (classic Uniswap math).Later providers mint proportional to existing reserves.
We update
reservesand mint LP tokens to the provider.
Removing Liquidity
function removeLiquidity(uint lpAmount) external {
require(lpAmount > 0, "Invalid LP amount");
require(liquidity[msg.sender] >= lpAmount, "Not enough LP tokens");
uint amountA = (lpAmount * reserveA) / totalLiquidity;
uint amountB = (lpAmount * reserveB) / totalLiquidity;
liquidity[msg.sender] -= lpAmount;
totalLiquidity -= lpAmount;
lpToken.burn(msg.sender, lpAmount);
reserveA -= amountA;
reserveB -= amountB;
tokenA.transfer(msg.sender, amountA);
tokenB.transfer(msg.sender, amountB);
}
👉 Explanation:
Users burn LP tokens proportional to their pool share.
They get back Token A and Token B accordingly.
Reserves are updated.
Testing with Foundry
Let’s test the full cycle: add liquidity → swap → remove liquidity.
Example Foundry Test
contract AMMTest is Test {
MockERC20 tokenA;
MockERC20 tokenB;
AMM amm;
LPToken lp;
address user = address(1);
function setUp() public {
tokenA = new MockERC20("Token A", "TKA");
tokenB = new MockERC20("Token B", "TKB");
lp = new LPToken();
amm = new AMM(address(tokenA), address(tokenB), address(lp));
tokenA.mint(user, 1000 ether);
tokenB.mint(user, 1000 ether);
vm.startPrank(user);
tokenA.approve(address(amm), type(uint).max);
tokenB.approve(address(amm), type(uint).max);
}
function testAddAndRemoveLiquidity() public {
amm.addLiquidity(100 ether, 100 ether);
assertEq(tokenA.balanceOf(address(amm)), 100 ether);
assertEq(lp.balanceOf(user), sqrt(100 ether * 100 ether));
amm.removeLiquidity(lp.balanceOf(user));
assertEq(tokenA.balanceOf(user), 1000 ether);
assertEq(tokenB.balanceOf(user), 1000 ether);
}
function testSwap() public {
amm.addLiquidity(100 ether, 100 ether);
amm.swap(address(tokenA), 10 ether);
// User spent 10 A, should receive ~9 B (with slippage)
console.log("User B balance:", tokenB.balanceOf(user));
}
}
At this point, we’ve built a complete minimal AMM with:
Swaps
Liquidity adding/removing
LP token accounting
Full test coverage


