logo
Published on

Uniswap V2 — A Deep Dive into the Math

Overview

This post walks through Uniswap V2’s math and maps each concept to the actual Pair contract implementation. Equations use the constant-product model; code snippets are short quotes from the public Uniswap v2-core repository with attribution.

Note on snippets: Small excerpts are quoted from Uniswap v2-core (GPL-3.0) for explanation. See the full source: https://github.com/Uniswap/v2-core/blob/master/contracts/UniswapV2Pair.sol


Constant product and price

The core invariant maintained by every pair is:

xy=kx \cdot y = k

Where:

  • xx = reserve of token0, yy = reserve of token1
  • kk = constant product (ignoring fees and protocol-fee adjustments)

The instantaneous price is given by the reserve ratio:

p01=yx,p10=xyp_{0\to1} = \frac{y}{x}, \quad p_{1\to0} = \frac{x}{y}

Swaps move you along this curve: input pushes one reserve up and the other down such that xykx'y' \ge k after fees.


Fees and the 997/1000 factor

V2 charges a 0.30% fee on the input amount. The Pair contract enforces the invariant with fee-adjusted balances. In swap(), it computes adjusted balances and requires the product not to shrink:

// UniswapV2Pair.swap — fee-adjusted K check (excerpt)
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
require(
	balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2),
	'UniswapV2: K'
);

That’s equivalent to applying an effective multiplier of 997/1000 to the input side. If a trader sends dxdx of token0, the effective amount is:

dxafter fee=dx9971000dx_{\text{after fee}} = dx \cdot \frac{997}{1000}

Deriving output amounts

Given reserves (x,y)(x, y) and input dxdx of token0, with fee applied, the post-trade reserves must satisfy:

(x+dxafter fee)(ydy)=k=xy(x + dx_{\text{after fee}}) \cdot (y - dy) = k = x \cdot y

Solving for the output dydy:

dy=ykx+dx9971000=yxyx+dx9971000dy = y - \frac{k}{x + dx \cdot \frac{997}{1000}} = y - \frac{x \cdot y}{x + dx \cdot \frac{997}{1000}}

Numeric example:

  • Start: x=1000x=1000, y=1000y=1000, k=1,000,000k=1{,}000{,}000.
  • Trader sends dx=100dx=100dxfee=97.7dx_{\text{fee}}=97.7.
  • x=1097.7x' = 1097.7, y=k/x911.2y' = k/x' \approx 911.2dy88.8dy \approx 88.8.

This matches the behavior enforced by the fee-adjusted K-check above.


LP tokens and liquidity math

LP tokens represent a pro-rata share of the pool. For an existing pool (total supply TT), adding (dx,dy)(dx, dy) mints:

extliquidityminted=min(dxxT,dyyT) ext{liquidity minted} = \min\left( \frac{dx}{x} T,\, \frac{dy}{y} T \right)

At initialization (when T=0T=0), V2 mints roughly:

dxdy\sqrt{dx \cdot dy}

(minus a small MINIMUM_LIQUIDITY burned to address(0)):

// UniswapV2Pair — constants (excerpt)
uint public constant MINIMUM_LIQUIDITY = 10**3;

Protocol fees (if enabled via Factory) are minted using the kLast mechanism when liquidity changes. The relevant part computes square roots of the product of reserves:

// UniswapV2Pair._mintFee (excerpt)
uint rootK = Math.sqrt(uint(_reserve0).mul(_reserve1));
uint rootKLast = Math.sqrt(kLast);
// if rootK > rootKLast, mint a portion of growth to feeTo

Price oracles (TWAP)

Pairs maintain cumulative prices to enable time-weighted average prices (TWAP) without external price feeds. The pair updates cumulative prices inside _update whenever time has elapsed:

// UniswapV2Pair._update — cumulative price (exact logic abridged)
uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired
if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
	// UQ112x112 fixed point: encode(reserve1).uqdiv(reserve0)
	price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
	price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
}
reserve0 = uint112(balance0);
reserve1 = uint112(balance1);
blockTimestampLast = blockTimestamp;

Key details:

  • Prices are stored as UQ112x112 fixed-point numbers. Let Q112 = 2^112.
  • Cumulatives add “price × seconds.” They only change when _update runs (e.g., on swap/mint/burn/sync).
  • Consumers form a TWAP by taking two cumulative snapshots and dividing the delta by elapsed seconds, then decoding by Q112.
	ext{TWAP}_{0\to1} = \frac{\Delta\,\text{price0CumulativeLast}}{\Delta t \cdot Q_{112}}
extTWAP01=Δprice0CumulativeLastΔtQ112 ext{TWAP}_{0\to1} = \frac{\Delta\,\text{price0CumulativeLast}}{\Delta t \cdot Q_{112}}

Practical usage tips:

  • Snapshots must bracket your window. If no on-chain interaction occurs within a window, perform a cheap sync() (or any state-changing call) at one end to advance cumulatives.
  • To avoid a “same-block” manipulation, use windows spanning multiple blocks (commonly ≥ 10–15 minutes for volatile pairs).
  • A common pattern is “counterfactual” reading: compute current cumulatives off-chain by adding (currentPrice * (now - blockTimestampLast)) to the stored cumulative before forming deltas.

Worked micro-example (UQ112x112):

  • Suppose at t0 reserves are (x=1000, y=2000) → price0 = y/x = 2.0.
  • After 300s with price ~2.0, price0CumulativeLast increases by 2.0 * 300 * Q112.
  • If at t1=600s price is ~1.5 for the next 300s, delta cumulative adds 1.5 * 300 * Q112.
  • TWAP over [0,600] is (2.0*300 + 1.5*300)/600 = 1.75.

Flash swaps: zero-upfront-capital flows

V2 can optimistically send tokens and require repayment (or equivalent value) in the same transaction. The flow is inside swap():

// UniswapV2Pair.swap — transfer out, optional callback, then invariant check (abridged)
if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);
if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);

uint balance0 = IERC20(token0).balanceOf(address(this));
uint balance1 = IERC20(token1).balanceOf(address(this));
uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT');

// fee-adjusted K check (997/1000)
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');

What this means in practice:

  1. You can receive amount0Out/amount1Out before paying.
  2. Inside your uniswapV2Call, you must return enough value so that—after fees—the fee-adjusted K check passes.
  3. Repayment can be either returning the same token(s) or performing a trade elsewhere and repaying with the opposite token.
	ext{Repay condition:}\quad (x' \cdot y')_{\text{after fees}} \ge x \cdot y
extRepaycondition:(xy)after feesxy ext{Repay condition:}\quad (x' \cdot y')_{\text{after fees}} \ge x \cdot y

Notes and safeguards:

  • No “flash minting” of LP tokens occurs; only existing reserves are borrowed.
  • Reentrancy is mitigated by the simple post-callback balance accounting and final K check, plus the minimal surface of Pair.
  • Protocol fees (fee-on) are unrelated to swap fees; they accrue on liquidity events via kLast, not per-swap.
  • Typical strategies: triangular arbitrage across pairs, refinancing collateral, deleveraging/leveraging within a single atomic tx.

Edge cases and gotchas

  • Fee-on-transfer tokens can break assumptions; Router02 includes helpers, but pairs still enforce balance-based accounting.
  • Slippage must be handled at the caller via amountOutMin/amountInMax (in the Router), not inside the Pair.
  • Single-block manipulation is mitigated by using TWAPs rather than spot prices for sensitive logic.

References

Attribution: Code excerpts are from Uniswap v2-core (GPL-3.0). Copyright © Uniswap contributors.