AMM Math, From x·y=k to Hooks
The single most important technical area for this role. Derive the invariants, understand the rounding, and know each generation of AMM well enough to implement a small version of it in an interview.
CFMMs — the general theory
A Constant Function Market Maker is any AMM whose pricing is determined by holding a function of reserves constant.
For reserves R = (r₁, r₂, ..., rₙ), a CFMM pool defines an invariant φ(R) = k. A trade R → R' is admissible iff φ(R') ≥ φ(R) (with equality before fees).
Every AMM you've heard of is a choice of φ:
| Family | Invariant | Use case |
|---|---|---|
| Constant-product (Uniswap v2, Sushi, Pancake) | x · y = k | General-purpose, any token pair |
| Constant-sum | x + y = k | Pegged pairs, infinite-depth |
| StableSwap (Curve) | An·Σxᵢ + D = A·D·n + D^(n+1)/(n^n·Πxᵢ) | Stable-to-stable pairs |
| Weighted geometric mean (Balancer) | Π xᵢ^wᵢ = k | Multi-token portfolios |
| Concentrated liquidity (Uniswap v3/v4) | (x+L/√P_b)(y+L·√P_a) = L² within range | Capital-efficient AMM |
| Maverick | Dynamic-bin concentrated liquidity | Directional LP |
Constant-product (x · y = k)
The original. Uniswap v1 and v2. Sushi, Pancake, Aerodrome (v2 pools), and a thousand forks. Master this first; everything else generalizes from it.
The math
Reserves x (token0) and y (token1). Invariant: x · y = k. A user deposits Δx and receives Δy. With a fee f (e.g. 0.003 for 30bps):
(x + Δx·(1-f)) · (y - Δy) = k = x·y
⇒ Δy = y · Δx·(1-f) / (x + Δx·(1-f))
That single equation is the most important formula in DeFi. Memorize the derivation, not the result.
The Uniswap v2 implementation
// UniswapV2Library.getAmountOut — the canonical reference
function getAmountOut(
uint256 amountIn,
uint256 reserveIn,
uint256 reserveOut
) internal pure returns (uint256 amountOut) {
require(amountIn > 0, "INSUFFICIENT_INPUT_AMOUNT");
require(reserveIn > 0 && reserveOut > 0, "INSUFFICIENT_LIQUIDITY");
uint256 amountInWithFee = amountIn * 997; // 30 bps fee → multiply by 1000-3
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = (reserveIn * 1000) + amountInWithFee;
amountOut = numerator / denominator; // floor — protocol-favorable rounding
}Things to notice:
- Fee encoded as 997/1000. Integer math, no fixed-point library needed.
- Floor division on the last line. Rounding favors the protocol — user gets at most what the formula says.
- No
krecompute. The pool'sswaplater verifiesx'·y' ≥ x·yas a single post-condition.
Practice deriving getAmountOut and writing it from memory in under 5 minutes. It is a routine first-coding-round question.
StableSwap — Curve's invariant
For pegged-to-pegged pairs (USDC/USDT/DAI, stETH/ETH), constant-product wastes liquidity. StableSwap blends constant-sum (flat, infinite-depth) with constant-product (curved, robust at extremes).
A · n · (x+y) + D = A · D · n + D^(n+1) / (n^n · x · y) where A is the amplification factor.
Two things you must know:
- Finding
Dgiven reserves requires Newton's method. There's no closed form. - Finding
ygivenxandD(i.e. computing swap output) also requires Newton's method, on a different polynomial.
// Curve get_D — Newton iteration to find invariant D
function getD(uint256[2] memory xp, uint256 A) internal pure returns (uint256 D) {
uint256 S = xp[0] + xp[1];
if (S == 0) return 0;
D = S;
uint256 Ann = A * 2 * 2; // A · n^n
for (uint256 i; i < 255; ++i) {
uint256 D_P = (D * D / (xp[0] * 2)) * D / (xp[1] * 2);
uint256 D_prev = D;
D = (Ann * S + D_P * 2) * D / ((Ann - 1) * D + 3 * D_P);
// converged?
if (D > D_prev) { if (D - D_prev <= 1) break; }
else { if (D_prev - D <= 1) break; }
}
}The Newton loop runs up to 255 iterations but typically converges in <15. The break at delta-of-1 is critical — without it, oscillation costs gas.
Python reference for differential testing
def get_D(xp, A, n=2):
S = sum(xp)
if S == 0: return 0
D = S
Ann = A * n**n
for _ in range(255):
D_P = D
for x in xp:
D_P = D_P * D // (x * n)
Dprev = D
D = (Ann*S + D_P*n) * D // ((Ann - 1)*D + (n+1)*D_P)
if abs(D - Dprev) <= 1: return D
raise RuntimeError("did not converge")Concentrated liquidity (Uniswap v3)
The conceptual leap of v3: LPs choose a price range [P_a, P_b]. Within range, they earn fees and act like a normal CPMM, but with virtual reserves. Outside range, they earn nothing but also bear no risk.
The invariant inside a single range:
(x + L/√P_b) · (y + L·√P_a) = L²
Where L is the position's "liquidity" — a single number that captures the position's depth.
Why √P, not P?
Because the math collapses beautifully. Define √P = √(y/x). Then:
- Real reserves:
x = L / √P − L / √P_b,y = L · √P − L · √P_a - Swapping token0→token1 strictly decreases √P; swapping token1→token0 strictly increases it.
- Within a tick range, swap amounts are linear in the change of √P. Discrete jumps happen only at tick boundaries.
The swap step (single tick range)
// Conceptual — computes Δy when √P moves from √P_start to √P_target
function getAmount1Delta(uint160 sqrtRatioAX96, uint160 sqrtRatioBX96, uint128 liquidity)
internal pure returns (uint256)
{
if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);
return FullMath.mulDiv(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96);
}Tick math and sqrtPriceX96
Prices are stored as sqrtPriceX96 — the square root of price, in Q64.96 fixed-point. Ticks are integers i such that price = 1.0001^i. So:
sqrtPrice = 1.0001^(i/2) = (√1.0001)^i
Conversion table:
| Tick | Price | sqrtPriceX96 (hex) |
|---|---|---|
| −887272 (MIN_TICK) | ≈ 2⁻¹²⁸ | 0x1000276a3 |
| 0 | 1 | 0x1000000000000000000000000 (= 2⁹⁶) |
| +887272 (MAX_TICK) | ≈ 2¹²⁸ | ≈2²⁵⁶/Q96 |
TickMath.getSqrtRatioAtTick is one of the most-tested helpers in DeFi. It's implemented as a sequence of conditional multiplications by precomputed magic constants — a "bit-by-bit" exponentiation. Read it once carefully; it's beautiful.
// Excerpt — bit-decomposition of |tick|
uint256 absTick = uint256(tick < 0 ? -int256(tick) : int256(tick));
require(absTick <= uint256(MAX_TICK), "T");
uint256 ratio = absTick & 0x1 != 0
? 0xfffcb933bd6fad37aa2d162d1a594001
: 0x100000000000000000000000000000000;
if (absTick & 0x2 != 0) ratio = (ratio * 0xfff97272373d413259a46990580e213a) >> 128;
if (absTick & 0x4 != 0) ratio = (ratio * 0xfff2e50f5f656932ef12357cf3c7fdcc) >> 128;
// ... up to 0x80000
// invert if positive tick
if (tick > 0) ratio = type(uint256).max / ratio;
sqrtPriceX96 = uint160((ratio >> 32) + (ratio % (1 << 32) == 0 ? 0 : 1));Hooks and singleton architecture (v4)
Uniswap v4's two big bets:
- Singleton PoolManager — one contract holds all pools' state. Old v3 had a factory + per-pool deployments. Singleton enables flash-accounting across pools and massive deployment-cost reduction.
- Hooks — every pool can attach a hook contract that runs at specific lifecycle moments.
Hook lifecycle (simplified):
| Hook | Fires | Common use |
|---|---|---|
beforeInitialize / afterInitialize | Pool creation | Validation, registration |
beforeAddLiquidity / afterAddLiquidity | LP joins | KYC, whitelist, JIT-resist |
beforeRemoveLiquidity / afterRemoveLiquidity | LP exits | Lock-up enforcement |
beforeSwap / afterSwap | Trade | Dynamic fees, oracle update, custom curves |
beforeDonate / afterDonate | Donation | Reward distribution |
Hooks return delta values that can adjust the underlying swap. This is where "invent new paradigms" lives — limit orders, dynamic fees, on-chain MEV capture, all become hook implementations.
Inside a v4 transaction, the PoolManager doesn't transfer tokens after every swap — it tracks net deltas. The caller must settle all deltas to zero before the transaction ends. This is essentially flash accounting: the user can chain swaps, hooks, and external calls and only the net flow moves tokens.
Worked derivation: v2 → v3
One of the cleanest interview moves is showing how v3's "virtual reserves" reduce to v2 math when a range covers all prices.
- Start with v2:
x · y = k. LetL = √k. Thenx·y = L². - Introduce the price
P = y/x. So√P = √(y/x), andx = L/√P,y = L·√P. - Now restrict the range. Outside
[P_a, P_b], the position holds only one asset. Subtract the "wasted" reserves at the boundary:
x_real = L/√P − L/√P_b
y_real = L·√P − L·√P_a
Plug into x_real·y_real at boundaries: position holds all token0 at P = P_a, all token1 at P = P_b. Within range, behaves like a CPMM with virtual reserves x + L/√P_b and y + L·√P_a.
That five-line derivation is enough to anchor every v3 question in an interview.
The "implement swap in 30 lines" exercise
// Minimal CPMM swap. No router, no callback. Two tokens.
contract MiniPool {
IERC20 public immutable token0;
IERC20 public immutable token1;
uint112 public reserve0;
uint112 public reserve1;
uint256 private constant FEE_NUM = 997;
uint256 private constant FEE_DEN = 1000;
error K();
event Swap(address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out);
constructor(IERC20 _t0, IERC20 _t1) { token0 = _t0; token1 = _t1; }
// Pull-based — user must transfer tokens in before calling
function swap(uint256 amount0Out, uint256 amount1Out, address to) external {
require(amount0Out > 0 || amount1Out > 0, "OUT");
(uint112 r0, uint112 r1) = (reserve0, reserve1);
require(amount0Out < r0 && amount1Out < r1, "LIQ");
if (amount0Out > 0) token0.transfer(to, amount0Out);
if (amount1Out > 0) token1.transfer(to, amount1Out);
uint256 b0 = token0.balanceOf(address(this));
uint256 b1 = token1.balanceOf(address(this));
uint256 in0 = b0 > r0 - amount0Out ? b0 - (r0 - amount0Out) : 0;
uint256 in1 = b1 > r1 - amount1Out ? b1 - (r1 - amount1Out) : 0;
require(in0 > 0 || in1 > 0, "IN");
// K-check with fee adjusted
uint256 adj0 = b0 * FEE_DEN - in0 * (FEE_DEN - FEE_NUM);
uint256 adj1 = b1 * FEE_DEN - in1 * (FEE_DEN - FEE_NUM);
if (adj0 * adj1 < uint256(r0) * r1 * FEE_DEN * FEE_DEN) revert K();
reserve0 = uint112(b0); reserve1 = uint112(b1);
emit Swap(msg.sender, in0, in1, amount0Out, amount1Out);
}
}This is the bones of UniswapV2Pair.swap, minus the flash callback and mint/sync. Be able to write it without notes.
CFMMs vs CLMMs — when each wins
| CFMM (v2-style) | CLMM (v3-style) | |
|---|---|---|
| Capital efficiency | Low — spread thin across all prices | High — concentrated where volume lives |
| LP complexity | One-click. Pure passive. | Active. Range selection, rebalancing. |
| Storage model | Two reserves, one slot | Per-tick liquidity net, per-position state |
| Swap gas | ~70k | ~120k+ (more for cross-tick) |
| JIT exposure | Minimal | High — MEV bots provide JIT liquidity around swaps |
| Best for | Long-tail, low-volume pairs | Major pairs, stable-volatile pairs |
Modern AMMs ship both, often in the same contract surface. v4 hooks make it trivial to support custom curves alongside the default CLMM.