Skip to main content

Command Palette

Search for a command to run...

Build a minimal Automated Market Maker with Solidity.

Published
6 min read
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 A

  • y = reserve of Token B

  • k = 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 = k

  • You’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.

  • mint is called by the AMM when liquidity is added.

  • burn is 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 reserves and 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