Tutoriel·8 min de lecture·Par Solingo

Build a Uniswap V2 Style AMM from Scratch

Learn how an automated market maker really works by building a minimal Uniswap V2 Pair: the x*y=k invariant, LP token minting, the 0.3 percent swap fee, and the getAmountOut math.

# Build a Uniswap V2 Style AMM from Scratch

Automated market makers replaced the order book with a simple equation. Instead of matching buyers and sellers, a Uniswap V2 pool holds two token reserves and lets anyone trade against them at a price the math sets automatically. In this tutorial we rebuild a minimal Pair contract from scratch so the famous invariant, the fee, and the liquidity accounting stop being magic.

The constant product invariant

A pool holds two reserves, call them x and y. The core rule is:

x * y = k

The product k must never decrease during a swap. When a trader adds token X to the pool, the reserve of X grows, so the reserve of Y must shrink to keep the product at least equal to k. That falling Y is exactly what the trader receives.

Two consequences fall out of this single equation:

  • Price is just a ratio. The marginal price of X in terms of Y is y / x. As you buy Y, x rises and y falls, so each unit of Y costs more than the last. This is slippage, and it is built into the curve, not bolted on.
  • The pool can never be fully drained. As x goes to infinity the price of Y goes to infinity too, so you can never get the last unit out. Reserves are asymptotic.

Adding liquidity and minting LP tokens

Liquidity providers deposit both tokens and receive LP tokens that represent their share of the pool. The contract must mint LP tokens proportionally so that no provider can dilute another.

The rule Uniswap V2 uses:

  • For the first deposit, LP supply is sqrt(amount0 * amount1), minus a tiny permanently locked amount (MINIMUM_LIQUIDITY, 1000 wei). Locking that dust prevents an attacker from manipulating share price when supply is near zero.
  • For later deposits, the contract mints min(amount0 * totalSupply / reserve0, amount1 * totalSupply / reserve1). Using the minimum forces depositors to add tokens in the current ratio. Anything you add beyond the ratio is a gift to existing holders.

The geometric mean (sqrt) for the first mint makes the initial LP amount independent of the absolute token decimals and resistant to cheap inflation.

The swap with a 0.3 percent fee

Every swap charges a 0.3 percent fee that stays in the pool, which is how liquidity providers earn. Uniswap V2 expresses this elegantly by checking the invariant after subtracting the fee from the input amount.

The canonical getAmountOut formula, for an input amountIn against reserves reserveIn and reserveOut:

amountInWithFee = amountIn * 997

numerator = amountInWithFee * reserveOut

denominator = reserveIn * 1000 + amountInWithFee

amountOut = numerator / denominator

The 997 / 1000 factor is the 0.3 percent fee. Note that the fee is applied to the input, then the constant product math runs on the fee adjusted amount. Because everything is integer arithmetic, multiply before you divide to avoid truncation, and never reorder the operations.

A minimal Pair contract

Here is a compact but correct Pair. It keeps the essential pieces (reserves, mint, burn, swap, the fee check) and uses a simple ERC20 base for the LP token. It deliberately omits the price oracle and flash swap hooks so the core is readable.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

interface IERC20 {

function balanceOf(address) external view returns (uint256);

function transfer(address to, uint256 value) external returns (bool);

function transferFrom(address from, address to, uint256 value) external returns (bool);

}

function sqrt(uint256 y) 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;

}

}

contract MiniPair {

uint256 public constant MINIMUM_LIQUIDITY = 1000;

address public immutable token0;

address public immutable token1;

uint112 private reserve0;

uint112 private reserve1;

uint256 public totalSupply;

mapping(address => uint256) public balanceOf;

uint256 private unlocked = 1;

modifier lock() {

require(unlocked == 1, "LOCKED");

unlocked = 0;

_;

unlocked = 1;

}

constructor(address _token0, address _token1) {

token0 = _token0;

token1 = _token1;

}

function getReserves() public view returns (uint112, uint112) {

return (reserve0, reserve1);

}

function _mint(address to, uint256 value) private {

totalSupply += value;

balanceOf[to] += value;

}

function _burn(address from, uint256 value) private {

balanceOf[from] -= value;

totalSupply -= value;

}

function _update(uint256 bal0, uint256 bal1) private {

require(bal0 <= type(uint112).max && bal1 <= type(uint112).max, "OVERFLOW");

reserve0 = uint112(bal0);

reserve1 = uint112(bal1);

}

// Caller must transfer tokens to this contract before calling mint.

function mint(address to) external lock returns (uint256 liquidity) {

(uint112 r0, uint112 r1) = getReserves();

uint256 bal0 = IERC20(token0).balanceOf(address(this));

uint256 bal1 = IERC20(token1).balanceOf(address(this));

uint256 amount0 = bal0 - r0;

uint256 amount1 = bal1 - r1;

if (totalSupply == 0) {

liquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;

_mint(address(0), MINIMUM_LIQUIDITY); // permanently locked

} else {

uint256 l0 = (amount0 * totalSupply) / r0;

uint256 l1 = (amount1 * totalSupply) / r1;

liquidity = l0 < l1 ? l0 : l1;

}

require(liquidity > 0, "INSUFFICIENT_LIQUIDITY_MINTED");

_mint(to, liquidity);

_update(bal0, bal1);

}

// Caller must transfer LP tokens to this contract before calling burn.

function burn(address to) external lock returns (uint256 amount0, uint256 amount1) {

uint256 bal0 = IERC20(token0).balanceOf(address(this));

uint256 bal1 = IERC20(token1).balanceOf(address(this));

uint256 liquidity = balanceOf[address(this)];

amount0 = (liquidity * bal0) / totalSupply;

amount1 = (liquidity * bal1) / totalSupply;

require(amount0 > 0 && amount1 > 0, "INSUFFICIENT_LIQUIDITY_BURNED");

_burn(address(this), liquidity);

require(IERC20(token0).transfer(to, amount0), "T0_FAIL");

require(IERC20(token1).transfer(to, amount1), "T1_FAIL");

_update(

IERC20(token0).balanceOf(address(this)),

IERC20(token1).balanceOf(address(this))

);

}

// Caller sends input tokens in first, then requests amountOut.

function swap(uint256 amount0Out, uint256 amount1Out, address to) external lock {

require(amount0Out > 0 || amount1Out > 0, "INSUFFICIENT_OUTPUT");

(uint112 r0, uint112 r1) = getReserves();

require(amount0Out < r0 && amount1Out < r1, "INSUFFICIENT_LIQUIDITY");

if (amount0Out > 0) require(IERC20(token0).transfer(to, amount0Out), "T0_FAIL");

if (amount1Out > 0) require(IERC20(token1).transfer(to, amount1Out), "T1_FAIL");

uint256 bal0 = IERC20(token0).balanceOf(address(this));

uint256 bal1 = IERC20(token1).balanceOf(address(this));

uint256 amount0In = bal0 > r0 - amount0Out ? bal0 - (r0 - amount0Out) : 0;

uint256 amount1In = bal1 > r1 - amount1Out ? bal1 - (r1 - amount1Out) : 0;

require(amount0In > 0 || amount1In > 0, "INSUFFICIENT_INPUT");

// Apply 0.3% fee, then enforce x*y=k on the adjusted balances.

uint256 bal0Adj = bal0 * 1000 - amount0In * 3;

uint256 bal1Adj = bal1 * 1000 - amount1In * 3;

require(

bal0Adj * bal1Adj >= uint256(r0) * uint256(r1) * (1000 ** 2),

"K"

);

_update(bal0, bal1);

}

}

Notice the pattern: the contract never pulls tokens with transferFrom inside swap. The router (or the caller) sends input tokens first, then calls swap. The Pair only checks that the final balances satisfy the invariant. This optimistic transfer design is what later enables flash swaps.

A helper for callers

Most users do not compute amount0Out by hand. A router wraps the math. The pure quote function mirrors the formula above:

function getAmountOut(

uint256 amountIn,

uint256 reserveIn,

uint256 reserveOut

) pure returns (uint256 amountOut) {

require(amountIn > 0, "INSUFFICIENT_INPUT");

require(reserveIn > 0 && reserveOut > 0, "INSUFFICIENT_LIQUIDITY");

uint256 amountInWithFee = amountIn * 997;

uint256 numerator = amountInWithFee * reserveOut;

uint256 denominator = reserveIn * 1000 + amountInWithFee;

amountOut = numerator / denominator;

}

Security notes

A toy AMM is easy. A safe one is not. Watch for these:

  • Reentrancy. The lock modifier guards every state changing function. Without it, a malicious token with a callback in transfer could re-enter swap mid execution and break the invariant check.
  • The K check uses post transfer balances. Always read balanceOf after sending output and after the input arrives. Trusting the caller supplied numbers instead of on chain balances is a classic theft vector.
  • MINIMUM_LIQUIDITY matters. Burning the first 1000 wei stops the inflation attack where a donor manipulates the share price for the first depositor. Do not skip it.
  • Fee on transfer and rebasing tokens break the math. If a token takes a cut on transfer, the balance the Pair reads is less than expected and the K check can fail or, worse, accounting drifts. Pools either block these tokens or document the risk.
  • Integer truncation. Multiply before dividing, and keep the fee scaling (* 1000, * 997) exactly as shown. Reordering silently rounds value out of the pool.
  • Oracle manipulation. A spot price read from getReserves is trivially manipulated within one block. Production code accumulates a time weighted average price (TWAP) instead. This minimal Pair has no oracle, so never use its instantaneous price as a source of truth.
  • Practice it hands-on

    Reading the invariant is one thing. Watching k hold while you push trades through, or watching a fee on transfer token corrupt the reserves, makes it click. You can build and break a Pair like this one, step by step, in the interactive Solidity environment at app.solingo-blockchain.xyz. Deploy the contract, add liquidity, run a swap, and verify the product never drops below k.

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement