Section B · Technical Core · Primary

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.

The general form

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 φ:

FamilyInvariantUse case
Constant-product (Uniswap v2, Sushi, Pancake)x · y = kGeneral-purpose, any token pair
Constant-sumx + y = kPegged 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ᵢ = kMulti-token portfolios
Concentrated liquidity (Uniswap v3/v4)(x+L/√P_b)(y+L·√P_a) = L² within rangeCapital-efficient AMM
MaverickDynamic-bin concentrated liquidityDirectional 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 k recompute. The pool's swap later verifies x'·y' ≥ x·y as a single post-condition.
Drill

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).

The invariant (n=2)

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:

  1. Finding D given reserves requires Newton's method. There's no closed form.
  2. Finding y given x and D (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:

TickPricesqrtPriceX96 (hex)
−887272 (MIN_TICK)≈ 2⁻¹²⁸0x1000276a3
010x1000000000000000000000000 (= 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:

  1. 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.
  2. Hooks — every pool can attach a hook contract that runs at specific lifecycle moments.

Hook lifecycle (simplified):

HookFiresCommon use
beforeInitialize / afterInitializePool creationValidation, registration
beforeAddLiquidity / afterAddLiquidityLP joinsKYC, whitelist, JIT-resist
beforeRemoveLiquidity / afterRemoveLiquidityLP exitsLock-up enforcement
beforeSwap / afterSwapTradeDynamic fees, oracle update, custom curves
beforeDonate / afterDonateDonationReward 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.

Singleton + flash accounting

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.

  1. Start with v2: x · y = k. Let L = √k. Then x·y = L².
  2. Introduce the price P = y/x. So √P = √(y/x), and x = L/√P, y = L·√P.
  3. 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 efficiencyLow — spread thin across all pricesHigh — concentrated where volume lives
LP complexityOne-click. Pure passive.Active. Range selection, rebalancing.
Storage modelTwo reserves, one slotPer-tick liquidity net, per-position state
Swap gas~70k~120k+ (more for cross-tick)
JIT exposureMinimalHigh — MEV bots provide JIT liquidity around swaps
Best forLong-tail, low-volume pairsMajor 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.