Advanced AMM Mathematics
A comprehensive reference for the mathematics underlying automated market makers (AMMs), covering derivations, implementations, and numerical examples for production-grade DeFi engineering.
Table of Contents
- Uniswap V2 — Constant Product AMM
- Uniswap V3 — Concentrated Liquidity
- Uniswap V3 Tick Math & Fixed-Point Arithmetic
- Curve StableSwap
- Curve V2 (Tricrypto)
- Impermanent Loss
- Balancer Weighted Pools
- TWAP Oracles
- Liquidity Bootstrapping Pools (LBP)
- LP Position P&L
- Optimal Fee Tier Selection
- Concentrated Liquidity Strategies
1. Uniswap V2 — Constant Product AMM
1.1 The Constant Product Invariant
The invariant is:
x * y = k
where x is the reserve of token X, y is the reserve of token Y, and k is a constant maintained by all trades (adjusted upward only by fees).
Geometric interpretation: The invariant defines a hyperbola in (x, y) space. All valid pool states lie on this curve. Swaps move the state along the curve; liquidity deposits/withdrawals scale k.
1.2 Spot Price Derivation
The marginal price of X in terms of Y is the negative derivative of y with respect to x:
y = k / x
dy/dx = -k / x² = -y / x
Therefore the spot price (how many Y per unit X) is:
P = y / x
This is the price at zero trade size. Real trades face price impact.
1.3 Swap Formula Derivation (with Fees)
Let Δx be tokens X sent to the pool. The protocol applies a fee γ = 1 - fee (e.g. γ = 0.997 for 0.3% fee). The effective input is Δx · γ. The invariant must hold after the swap:
(x + Δx · γ) · (y - Δy) = x · y = k
Solving for Δy:
y - Δy = k / (x + Δx · γ)
Δy = y - k / (x + Δx · γ)
Δy = y · (1 - x / (x + Δx · γ))
Δy = y · Δx · γ / (x + Δx · γ)
Final swap formula:
Δy = (y · Δx · γ) / (x + Δx · γ)
Price impact is the deviation from spot price. The effective price is:
P_effective = Δy / Δx = y · γ / (x + Δx · γ)
P_spot = y / x
Price Impact = 1 - P_effective / P_spot
= 1 - x · γ / (x + Δx · γ)
= Δx · γ / (x + Δx · γ)
For small trades (Δx << x), price impact ≈ Δx · γ / x.
1.4 LP Share Calculation
When adding liquidity (Δx, Δy) to a pool with reserves (x, y) and total supply S of LP tokens:
LP_minted = S · min(Δx / x, Δy / y)
The min ensures the ratio is maintained. On first deposit, S = sqrt(Δx · Δy) (geometric mean, per Uniswap V2 implementation, minus MINIMUM_LIQUIDITY = 1000 burned to address(0)).
Withdrawing liquidity: Burning l LP tokens returns:
Δx_out = l / S · x
Δy_out = l / S · y
1.5 Fee Math
Fees accrue in-place: every swap increases k by leaving some fee in the reserves. After a swap of Δx in with 0.3% fee:
k_new = (x + Δx · γ) · (y - Δy)
= x · y [invariant holds]
= k
But the fee (0.3% of Δx) remains in x-reserve, meaning:
x_new = x + Δx (full Δx stays)
y_new = y - Δy
k_new = x_new · y_new > k
The fee causes k to grow, accruing to all LP holders pro-rata.
Protocol fee: Uniswap V2 has a protocol fee switch. When active, 1/6th of the 0.3% LP fee (i.e., 0.05%) goes to the protocol. This is implemented by minting LP tokens to feeTo at each liquidity event, computed as:
fee_LP = S · (sqrt(k_new) - sqrt(k_old)) / (n · sqrt(k_new) + sqrt(k_old))
where n = 6 (the fee denominator).
1.6 Solidity Implementation (Core Math)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
library UniswapV2Math {
// Given input amount and reserves, compute output amount
// fee = 30 (basis points * 10, i.e., 0.30%)
function getAmountOut(
uint256 amountIn,
uint256 reserveIn,
uint256 reserveOut,
uint256 feeBps // e.g. 30 for 0.30%
) internal pure returns (uint256 amountOut) {
require(amountIn > 0, "INSUFFICIENT_INPUT");
require(reserveIn > 0 && reserveOut > 0, "INSUFFICIENT_LIQUIDITY");
uint256 amountInWithFee = amountIn * (10000 - feeBps);
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = reserveIn * 10000 + amountInWithFee;
amountOut = numerator / denominator;
}
// Compute LP tokens minted when adding liquidity
function computeLPMinted(
uint256 amount0,
uint256 amount1,
uint256 reserve0,
uint256 reserve1,
uint256 totalSupply
) internal pure returns (uint256 liquidity) {
if (totalSupply == 0) {
liquidity = sqrt(amount0 * amount1) - 1000; // MINIMUM_LIQUIDITY
} else {
liquidity = min(
(amount0 * totalSupply) / reserve0,
(amount1 * totalSupply) / reserve1
);
}
require(liquidity > 0, "INSUFFICIENT_LIQUIDITY_MINTED");
}
function sqrt(uint256 y) internal 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; }
}
function min(uint256 a, uint256 b) internal pure returns (uint256) {
return a < b ? a : b;
}
}
1.7 Python Simulation
import numpy as np
class UniswapV2Pool:
def __init__(self, x: float, y: float, fee: float = 0.003):
self.x = x
self.y = y
self.fee = fee # e.g. 0.003 = 0.3%
self.k = x * y
def spot_price(self) -> float:
"""Price of X in terms of Y (Y per X)."""
return self.y / self.x
def swap_x_for_y(self, dx: float) -> float:
"""Swap dx of X, return dy of Y received."""
gamma = 1 - self.fee
dy = (self.y * dx * gamma) / (self.x + dx * gamma)
self.x += dx
self.y -= dy
return dy
def price_impact(self, dx: float) -> float:
"""Price impact as a fraction."""
gamma = 1 - self.fee
return (dx * gamma) / (self.x + dx * gamma)
def add_liquidity(self, dx: float, dy: float, total_supply: float) -> float:
if total_supply == 0:
lp = np.sqrt(dx * dy)
else:
lp = min(dx / self.x, dy / self.y) * total_supply
self.x += dx
self.y += dy
return lp
# Numerical example
pool = UniswapV2Pool(1_000_000, 2_000_000) # 1M X, 2M Y, spot = 2.0
print(f"Spot price: {pool.spot_price():.4f} Y/X")
dy = pool.swap_x_for_y(10_000)
print(f"Swap 10,000 X -> {dy:.2f} Y")
print(f"New spot: {pool.spot_price():.4f}")
print(f"Price impact: {pool.price_impact(10_000)*100:.4f}%") # before swap
Numerical example:
- Pool: x=1,000,000 USDC, y=1,000,000 ETH equivalent, k=10^12
- Swap dx=1,000 X, fee=0.3%, gamma=0.997
- dy = (1,000,000 · 1,000 · 0.997) / (1,000,000 + 1,000 · 0.997) = 997,000,000 / 1,000,997 ≈ 996.00
- Price impact = 1,000 · 0.997 / 1,000,997 ≈ 0.0997%
2. Uniswap V3 — Concentrated Liquidity
2.1 Virtual vs Real Reserves
V3 generalizes V2 by allowing LPs to concentrate liquidity within a price range [P_a, P_b]. The key insight is that a position in range [P_a, P_b] is equivalent to a V2 position with virtual reserves that are offset by amounts corresponding to the endpoints.
Define:
sqrt_P = sqrt(P)— square root of current pricesqrt_Pa = sqrt(P_a)— lower boundsqrt_Pb = sqrt(P_b)— upper boundL— liquidity (the fundamental unit, analogous to sqrt(k) in V2)
Virtual reserves (the V2 equivalent pool that this position "pretends" to be):
x_virtual = L / sqrt_P
y_virtual = L * sqrt_P
Real reserves (actual tokens held by the position):
When price is within range (Pa <= P <= Pb):
x_real = L * (1/sqrt_P - 1/sqrt_Pb)
y_real = L * (sqrt_P - sqrt_Pa)
When price is below Pa (position is all X):
x_real = L * (1/sqrt_Pa - 1/sqrt_Pb)
y_real = 0
When price is above Pb (position is all Y):
x_real = 0
y_real = L * (sqrt_Pb - sqrt_Pa)
2.2 Invariant in V3
Within a tick range, V3 behaves as a V2 pool with modified reserves:
(x + L/sqrt_Pb) * (y + L*sqrt_Pa) = L²
This is the V3 invariant within a single tick range. This is derived by substituting the real reserve formulas and verifying the product equals L².
Proof:
(x_real + L/sqrt_Pb) * (y_real + L*sqrt_Pa)
= (L*(1/sqrt_P - 1/sqrt_Pb) + L/sqrt_Pb) * (L*(sqrt_P - sqrt_Pa) + L*sqrt_Pa)
= (L/sqrt_P) * (L*sqrt_P)
= L² ✓
2.3 Liquidity Calculation from Position
Given a position with amounts (x, y) at current price P within range [Pa, Pb]:
L_from_x = x / (1/sqrt_P - 1/sqrt_Pb) = x * sqrt_P * sqrt_Pb / (sqrt_Pb - sqrt_P)
L_from_y = y / (sqrt_P - sqrt_Pa)
L = min(L_from_x, L_from_y)
The min ensures the position fits within both constraints. In practice, when providing liquidity, you specify one token amount and the contract computes the other.
2.4 Swap Math Within a Tick
For a swap moving price from sqrt_P to sqrt_P_next (within a single tick range), the amount consumed and produced:
Swapping X for Y (price decreases, sqrt_P decreases):
Δx = L * (1/sqrt_P_next - 1/sqrt_P) [X consumed]
Δy = L * (sqrt_P - sqrt_P_next) [Y produced]
Swapping Y for X (price increases, sqrt_P increases):
Δy = L * (sqrt_P_next - sqrt_P) [Y consumed]
Δx = L * (1/sqrt_P - 1/sqrt_P_next) [X produced]
2.5 Position Value Calculation
The value of a V3 LP position in terms of token Y (with Y as numeraire):
V = x * P + y
= L * (1/sqrt_P - 1/sqrt_Pb) * P + L * (sqrt_P - sqrt_Pa)
= L * (sqrt_P - sqrt_P²/sqrt_Pb - sqrt_Pa + sqrt_P ... )
Simplifying:
V = L * (2*sqrt_P - sqrt_Pa - P/sqrt_Pb)
This is valid when Pa <= P <= Pb. Outside the range, the position holds only one token and the value is simply its token amount times current price.
2.6 Fee Accrual in V3
Unlike V2, V3 fees are tracked per-unit-liquidity using fee growth globals and per-position fee accumulators.
feeGrowthGlobal: accumulates fee per unit of liquidity, ever-increasing:
feeGrowthGlobal += fee_amount / liquidity_active
feeGrowthInsideX128: tracks fee growth inside a tick range [tickLower, tickUpper]. When a position is updated, it records feeGrowthInsideLastX128. At withdrawal:
fees_owed = liquidity * (feeGrowthInside_current - feeGrowthInsideLastX128) / 2^128
This design allows O(1) fee calculation regardless of how many swaps occurred.
2.7 Solidity Snippet: Liquidity from Amounts
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./FullMath.sol";
import "./FixedPoint96.sol";
library LiquidityAmounts {
// Compute liquidity from amount0 (token X) and price range
function getLiquidityForAmount0(
uint160 sqrtRatioAX96,
uint160 sqrtRatioBX96,
uint256 amount0
) internal pure returns (uint128 liquidity) {
if (sqrtRatioAX96 > sqrtRatioBX96)
(sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);
uint256 intermediate = FullMath.mulDiv(
sqrtRatioAX96, sqrtRatioBX96, FixedPoint96.Q96
);
return uint128(FullMath.mulDiv(
amount0, intermediate, sqrtRatioBX96 - sqrtRatioAX96
));
}
// Compute liquidity from amount1 (token Y) and price range
function getLiquidityForAmount1(
uint160 sqrtRatioAX96,
uint160 sqrtRatioBX96,
uint256 amount1
) internal pure returns (uint128 liquidity) {
if (sqrtRatioAX96 > sqrtRatioBX96)
(sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);
return uint128(FullMath.mulDiv(
amount1, FixedPoint96.Q96, sqrtRatioBX96 - sqrtRatioAX96
));
}
// Combine both — uses min liquidity
function getLiquidityForAmounts(
uint160 sqrtRatioX96,
uint160 sqrtRatioAX96,
uint160 sqrtRatioBX96,
uint256 amount0,
uint256 amount1
) internal pure returns (uint128 liquidity) {
if (sqrtRatioAX96 > sqrtRatioBX96)
(sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);
if (sqrtRatioX96 <= sqrtRatioAX96) {
liquidity = getLiquidityForAmount0(sqrtRatioAX96, sqrtRatioBX96, amount0);
} else if (sqrtRatioX96 < sqrtRatioBX96) {
uint128 liq0 = getLiquidityForAmount0(sqrtRatioX96, sqrtRatioBX96, amount0);
uint128 liq1 = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioX96, amount1);
liquidity = liq0 < liq1 ? liq0 : liq1;
} else {
liquidity = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioBX96, amount1);
}
}
}
3. Uniswap V3 Tick Math & Fixed-Point Arithmetic
3.1 Q64.96 Fixed-Point Format
Uniswap V3 represents sqrtPriceX96 as a Q64.96 fixed-point number: a 160-bit unsigned integer where the lower 96 bits are the fractional part.
sqrtPriceX96 = sqrt(price) * 2^96
price = (sqrtPriceX96 / 2^96)^2
The choice of 96 bits gives sufficient precision while fitting in a uint160 (which fits in a 256-bit EVM word alongside other data). Q64.96 means: 64 bits for the integer part, 96 bits for the fraction.
Constants:
Q96 = 2^96 = 79228162514264337593543950336
MIN_SQRT_RATIO = 4295128739 (price ≈ 2^(-184))
MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342 (price ≈ 2^(184))
3.2 Tick to Price Mapping
The tick system divides the price range into discrete intervals. Tick i corresponds to:
price(i) = 1.0001^i
sqrt_price(i) = 1.0001^(i/2) = sqrt(1.0001)^i
Each tick represents a 0.01% (1 basis point) price move (since 1.0001 - 1 = 0.0001 = 1 bps). This is why tick spacing matters: a fee tier of 0.05% uses tick spacing 10 (10 bps), 0.3% uses spacing 60, and 1% uses spacing 200.
Tick boundaries:
MIN_TICK = -887272
MAX_TICK = 887272
These bounds ensure sqrtPriceX96 fits in uint160.
3.3 getSqrtRatioAtTick — Derivation
The core formula:
sqrtPriceX96 = sqrt(1.0001^tick) * 2^96
= 1.00005^tick * 2^96
Because 1.0001 = (1.00005)^2, so sqrt(1.0001^tick) = 1.00005^tick.
TickMath.sol implementation strategy: Instead of computing 1.00005^tick directly (which would require floating point), the contract decomposes tick into its binary representation and multiplies precomputed powers of 1.00005 for each set bit.
tick = sum over bits: bit_i * 2^i
sqrtP = product over set bits: 1.00005^(2^i)
Each 1.00005^(2^i) is a precomputed Q128.128 constant. The multiplication uses 512-bit intermediate arithmetic to avoid overflow.
Key constants (first few, in Q128.128 form):
bit 0: 1.00005^1 → 0xfffcb933bd6fad37aa2d162d1a594001
bit 1: 1.00005^2 → 0xfff97272373d413259a46990580e213a
bit 2: 1.00005^4 → 0xfff2e50f5f656932ef12357cf3c7fdcc
...
The final result is divided by 2^32 to obtain Q64.96 format (the constants are in Q128.128).
3.4 getTickAtSqrtRatio — Binary Search + Log
Given a sqrtPriceX96, find the tick:
tick = floor(log_{1.0001}(price))
= floor(2 * log_{1.0001}(sqrtPrice))
= floor(log(sqrtPrice^2) / log(1.0001))
Implementation approach in TickMath.sol:
Compute
log2(sqrtPriceX96 / 2^96)using bit manipulation:- Find the most significant bit of the ratio
- Iteratively square and shift to compute the fractional part of log2
Convert log2 to log_{1.0001}:
log_{1.0001}(x) = log2(x) / log2(1.0001) 1/log2(1.0001) ≈ 1/0.0000144269... ≈ 69314.71...In fixed-point: multiply by
255738958999603826347141(a Q128 constant)Apply floor to get the tick, then verify by checking
getSqrtRatioAtTick(tick) <= sqrtRatioX96 < getSqrtRatioAtTick(tick+1).
3.5 Solidity: TickMath Core (Simplified)
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;
library TickMathSimplified {
int24 internal constant MIN_TICK = -887272;
int24 internal constant MAX_TICK = 887272;
uint160 internal constant MIN_SQRT_RATIO = 4295128739;
uint160 internal constant MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342;
// Compute sqrtPriceX96 from tick using precomputed magic constants
function getSqrtRatioAtTick(int24 tick) internal pure returns (uint160 sqrtPriceX96) {
uint256 absTick = tick < 0 ? uint256(-int256(tick)) : uint256(int256(tick));
require(absTick <= uint256(int256(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;
if (absTick & 0x8 != 0) ratio = (ratio * 0xffe5caca7e10e4e61c3624eaa0941cd0) >> 128;
if (absTick & 0x10 != 0) ratio = (ratio * 0xffcb9843d60f6159c9db58835c926644) >> 128;
if (absTick & 0x20 != 0) ratio = (ratio * 0xff973b41fa98c081472e6896dfb254c0) >> 128;
if (absTick & 0x40 != 0) ratio = (ratio * 0xff2ea16466c96a3843ec78b326b52861) >> 128;
if (absTick & 0x80 != 0) ratio = (ratio * 0xfe5dee046a99a2a811c461f1969c3053) >> 128;
if (absTick & 0x100 != 0) ratio = (ratio * 0xfcbe86c7900a88aedcffc83b479aa3a4) >> 128;
if (absTick & 0x200 != 0) ratio = (ratio * 0xf987a7253ac413176f2b074cf7815e54) >> 128;
if (absTick & 0x400 != 0) ratio = (ratio * 0xf3392b0822b70005940c7a398e4b70f3) >> 128;
if (absTick & 0x800 != 0) ratio = (ratio * 0xe7159475a2c29b7443b29c7fa6e889d9) >> 128;
if (absTick & 0x1000 != 0) ratio = (ratio * 0xd097f3bdfd2022b8845ad8f792aa5825) >> 128;
if (absTick & 0x2000 != 0) ratio = (ratio * 0xa9f746462d870fdf8a65dc1f90e061e5) >> 128;
if (absTick & 0x4000 != 0) ratio = (ratio * 0x70d869a156d2a1b890bb3df62baf32f7) >> 128;
if (absTick & 0x8000 != 0) ratio = (ratio * 0x31be135f97d08fd981231505542fcfa6) >> 128;
if (absTick & 0x10000 != 0) ratio = (ratio * 0x9aa508b5b7a84e1c677de54f3e99bc9) >> 128;
if (absTick & 0x20000 != 0) ratio = (ratio * 0x5d6af8dedb81196699c329225ee604) >> 128;
if (absTick & 0x40000 != 0) ratio = (ratio * 0x2216e584f5fa1ea926041bedfe98) >> 128;
if (absTick & 0x80000 != 0) ratio = (ratio * 0x48a170391f7dc42444e8fa2) >> 128;
if (tick > 0) ratio = type(uint256).max / ratio;
// Shift from Q128.128 to Q64.96 and round up
sqrtPriceX96 = uint160((ratio >> 32) + (ratio % (1 << 32) == 0 ? 0 : 1));
}
}
3.6 Python: Tick Math Verification
import math
Q96 = 2**96
LOG_BASE = math.log(1.0001)
def tick_to_sqrt_price(tick: int) -> float:
"""Compute sqrt(1.0001^tick) directly."""
return math.sqrt(1.0001 ** tick)
def sqrt_price_to_sqrtPriceX96(sqrt_price: float) -> int:
"""Convert to Q64.96 fixed-point."""
return int(sqrt_price * Q96)
def tick_to_sqrtPriceX96(tick: int) -> int:
return sqrt_price_to_sqrtPriceX96(tick_to_sqrt_price(tick))
def sqrtPriceX96_to_price(sqrtPriceX96: int) -> float:
sqrt_price = sqrtPriceX96 / Q96
return sqrt_price ** 2
def tick_to_price(tick: int) -> float:
return 1.0001 ** tick
# Verification
for tick in [-887272, -100, 0, 100, 887272]:
price = tick_to_price(tick)
sqrt = tick_to_sqrt_price(tick)
spX96 = tick_to_sqrtPriceX96(tick)
print(f"tick={tick:8d} price={price:.6e} sqrtP={sqrt:.6f} sqrtPX96={spX96}")
4. Curve StableSwap
4.1 Motivation
The constant product (x*y=k) formula has high price impact for stablecoins because it prices assets as if they can diverge arbitrarily. StableSwap combines a constant sum invariant (x+y=C) and a constant product, weighting them to stay near the constant sum when assets are balanced.
4.2 The StableSwap Invariant
For n assets with balances x_i and amplification coefficient A:
A * n^n * Σ(x_i) + D = A * D * n^n + D^(n+1) / (n^n * Π(x_i))
Where D is the invariant (total value when all assets are equal).
When all balances are equal: x_i = D/n for all i, and D is the total pool value.
Simplified for n=2 (two assets x, y):
4*A*(x+y) + D = 4*A*D + D³/(4*x*y)
Understanding the A parameter:
- A=0: pure constant product (x*y = (D/2)²)
- A→∞: pure constant sum (x+y = D)
- Typical values: A=100 for stablecoins (e.g. USDC/DAI), A=2000 for highly correlated assets
4.3 Deriving D — Newton's Method
Given balances (x_0, x_1, ..., x_{n-1}), find D such that the invariant holds. There is no closed-form solution; we use Newton's method.
Rewrite the invariant as f(D) = 0:
f(D) = A*n^n*Σ(x_i)*D + n^n*Π(x_i)*D - A*n^n*D² - D^(n+1)/...
More cleanly, define:
f(D) = D^(n+1)/(n^n * Π(x_i)) + (A*n^n - 1)*D - A*n^n*Σ(x_i)
Newton step:
D_{k+1} = D_k - f(D_k) / f'(D_k)
Curve's Newton iteration (as in StableSwap code):
def get_D(xp: list, A: int) -> int:
n = len(xp)
S = sum(xp)
if S == 0:
return 0
D = S
Ann = A * n
for _ in range(255):
D_P = D
for x in xp:
D_P = D_P * D // (n * x) # D_P = D^(n+1) / (n^n * prod(x_i))
D_prev = D
D = (Ann * S + D_P * n) * D // ((Ann - 1) * D + (n + 1) * D_P)
if abs(D - D_prev) <= 1:
return D
raise Exception("D did not converge")
The Newton update formula is derived from the invariant. Let D_P = D^(n+1)/(n^n * Π(x_i)):
D_new = (Ann*S + n*D_P) * D / ((Ann-1)*D + (n+1)*D_P)
This usually converges in 4-8 iterations.
4.4 Exchange Calculation
To swap dx of token i for token j, find new balance x_j_new given x_i_new = x_i + dx:
Fix all balances except x_j. The invariant defines a constraint on x_j:
get_y(i, j, x_i_new, xp) -> x_j_new
Derivation: With known balances except x_j, let S' = Σ_{k≠j} x_k and P' = Π_{k≠j} x_k. The invariant becomes:
A*n^n*(S'+x_j) + D = A*n^n*D + D^(n+1)/(n^n * P' * x_j)
Let b = S' + D/Ann and c = D^(n+2)/(Ann * n^n * P'). Then:
x_j^2 + b*x_j - c = 0
x_j = (-b + sqrt(b² + 4c)) / 2
This is solved again via Newton's method:
def get_y(i: int, j: int, x: int, xp_: list, A: int, D: int) -> int:
n = len(xp_)
Ann = A * n
c = D
S_ = 0
_x = 0
for k in range(n):
if k == i:
_x = x
elif k != j:
_x = xp_[k]
else:
continue
S_ += _x
c = c * D // (n * _x)
c = c * D // (n * Ann)
b = S_ + D // Ann
y = D
for _ in range(255):
y_prev = y
y = (y*y + c) // (2*y + b - D)
if abs(y - y_prev) <= 1:
return y
raise Exception("y did not converge")
def exchange(i: int, j: int, dx: int, xp: list, A: int, fee: int) -> int:
"""fee in units of 1e10 (e.g., 4000000 = 0.04%)"""
D = get_D(xp, A)
xp_new = xp.copy()
xp_new[i] += dx
y = get_y(i, j, xp_new[i], xp_new, A, D)
dy = xp[j] - y - 1 # -1 for rounding
fee_amount = dy * fee // 10**10
return dy - fee_amount
4.5 Amplification Coefficient Dynamics
In production Curve pools, A is not static. It ramps linearly over time between A_initial and A_final:
A(t) = A_initial + (A_final - A_initial) * (t - t_initial) / (t_final - t_initial)
This allows governance to adjust pool behavior (e.g., lower A to reduce gas subsidization of arbitrageurs during depegs). The ramp is bounded to change by at most factor 10x per day to prevent sudden manipulation.
function _A() internal view returns (uint256) {
uint256 t1 = future_A_time;
uint256 A1 = future_A;
if (block.timestamp < t1) {
uint256 A0 = initial_A;
uint256 t0 = initial_A_time;
if (A1 > A0) {
return A0 + (A1 - A0) * (block.timestamp - t0) / (t1 - t0);
} else {
return A0 - (A0 - A1) * (block.timestamp - t0) / (t1 - t0);
}
} else {
return A1;
}
}
4.6 Numerical Example
Pool: USDC/DAI, each 1,000,000 units, A=100, fee=0.04%
S = 2,000,000
Ann = 200
Initial D ≈ 2,000,000
Newton iteration:
D_P = D = 2,000,000
D_P = 2e6 * 2e6 / (2 * 1e6) = 2e6 (both x_i = 1e6)
D = (200*2e6 + 2e6*2) * 2e6 / ((200-1)*2e6 + (2+1)*2e6)
= (400e6 + 4e6) * 2e6 / (398e6 + 6e6)
= 404e6 * 2e6 / 404e6 = 2e6 ✓
Swap 1000 USDC for DAI:
x_0_new = 1,001,000
Solve for y (DAI): y ≈ 999,000.04 (very small slippage due to high A)
dy = 1,000,000 - 999,000.04 = 999.96
fee = 999.96 * 0.0004 = 0.40
output = 999.96 - 0.40 = 999.56 DAI
Compare to V2: would be ~999.00 (more slippage)
5. Curve V2 (Tricrypto)
5.1 Generalized Invariant for Volatile Pairs
Curve V2 extends StableSwap to handle volatile, non-pegged assets (ETH, BTC, USDT). The invariant generalizes the concept using a price scale p_i for each asset to normalize balances:
Transformed balances:
x_i' = x_i * p_i (normalize all assets to same unit)
The invariant uses these normalized balances with a dynamic A and gamma parameter:
K_0 = Π(x_i')^n / (D/n)^n [constant product term]
K = A * gamma^2 * K_0 / (gamma + 1 - K_0)^2
Invariant: K * Σ(x_i') + Π(x_i') = K * D + (D/n)^n
This reduces to constant product when gamma -> 0 and to a weighted sum near the equilibrium when K_0 ≈ 1.
5.2 Price Oracle (EMA)
Curve V2 maintains an exponential moving average (EMA) price oracle to detect when the pool is far from equilibrium and update internal price scales.
EMA update per block:
p_oracle = (p_oracle_prev * (1 - alpha) + p_last * alpha)
alpha = 1 - exp(-dt / half_time)
In integer arithmetic (approximately):
alpha = dt * 10^18 / (dt + half_time) [linear approximation for small dt]
p_oracle = (p_oracle_prev * (10^18 - alpha) + p_last * alpha) / 10^18
The half_time parameter (e.g. 600 seconds) controls oracle lag.
5.3 Dynamic Fees
Curve V2 adjusts fees based on how far the pool is from equilibrium:
f = fee_gamma / (fee_gamma + 1 - K_0)
fee = (mid_fee * f + out_fee * (1 - f))
Where:
mid_fee= fee at perfect balance (minimum fee, e.g. 0.01%)out_fee= fee at extreme imbalance (maximum fee, e.g. 0.3%)fee_gamma= sensitivity parameter
When K_0 = 1 (pool perfectly balanced): f ≈ 1, fee ≈ mid_fee
When K_0 << 1 (pool very imbalanced): f ≈ 0, fee ≈ out_fee
5.4 Price Scale Rebalancing
The price scale p_i (internal price assumptions) is updated periodically to match the oracle price. This process:
- Computes the "profit" from the current deviation
- If profit exceeds a threshold, updates
p_itoward the oracle price - This makes the pool more efficient around current market prices
def adjust_price_scale(xp, p_oracle, p_scale, A, gamma, D):
# Compute what D would be with oracle prices
xp_oracle = [xp[0]] + [xp[i] * p_oracle[i] / p_scale[i] for i in range(1, n)]
D_oracle = get_D_crypto(xp_oracle, A, gamma)
# If D_oracle > D (we'd have more value at oracle prices), update
if D_oracle > D * (1 + adjustment_threshold):
p_scale_new = [p * p_oracle[i] / p_scale[i] for i, p in enumerate(p_scale)]
return p_scale_new
return p_scale
6. Impermanent Loss
6.1 Definition
Impermanent loss (IL) is the difference in value between:
- Holding: keeping the initial token amounts
- LPing: providing liquidity and holding LP tokens
It is "impermanent" because it reverses if prices return to the initial ratio.
6.2 Mathematical Derivation (V2)
Initial state: Pool with x_0 of X and y_0 of Y. Price P_0 = y_0/x_0. Total value = x_0 * P_0 + y_0 = 2 * y_0.
After price change to P_1: The pool rebalances along the constant product curve. New reserves:
k = x_0 * y_0
x_1 = sqrt(k / P_1) = x_0 * sqrt(P_0 / P_1)
y_1 = sqrt(k * P_1) = y_0 * sqrt(P_1 / P_0)
Value of LP position at P_1:
V_LP = x_1 * P_1 + y_1
= x_0 * sqrt(P_0/P_1) * P_1 + y_0 * sqrt(P_1/P_0)
= x_0 * P_0 * sqrt(P_1/P_0) + y_0 * sqrt(P_1/P_0)
= 2 * y_0 * sqrt(P_1/P_0)
= 2 * y_0 * sqrt(r) where r = P_1/P_0
Value of HODL at P_1:
V_HODL = x_0 * P_1 + y_0
= x_0 * r * P_0 + y_0
= y_0 * r + y_0
= y_0 * (1 + r)
Impermanent Loss:
IL = (V_LP - V_HODL) / V_HODL
= (2*sqrt(r) - (1+r)) / (1+r)
= 2*sqrt(r)/(1+r) - 1
Key formula:
IL(r) = 2*sqrt(r)/(1+r) - 1
Note: IL is always ≤ 0 (always a loss relative to HODL), since 2*sqrt(r) ≤ 1+r by AM-GM inequality.
6.3 Numerical IL Table
import numpy as np
def il(r: float) -> float:
"""Impermanent loss as a fraction (negative = loss)."""
return 2 * np.sqrt(r) / (1 + r) - 1
price_ratios = [0.25, 0.5, 0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 2.0, 4.0, 9.0]
print(f"{'Price ratio':>15} {'IL (%)':>10}")
print("-" * 28)
for r in price_ratios:
print(f"{r:>15.2f} {il(r)*100:>10.4f}%")
Output:
Price ratio IL (%)
----------------------------
0.25 -5.7191%
0.50 -2.0203%
0.75 -0.5132%
0.90 -0.1129%
1.00 0.0000%
1.10 -0.1130%
1.25 -0.5132%
1.50 -2.0203%
2.00 -5.7191%
4.00 -20.0000%
9.00 -40.0000%
Note the symmetry: r and 1/r give the same IL (since IL(r) = IL(1/r)). Also IL(-50% price) = IL(+100% price).
6.4 V3 Concentrated Liquidity IL
For a V3 position in range [P_a, P_b], IL is more complex because the position holds different amounts depending on the price. The formula:
When P_a ≤ P ≤ P_b:
V_LP = 2*L*(sqrt(P) - sqrt(Pa)) + 2*L*(1/sqrt(P) - 1/sqrt(Pb))*P
= 2*L*(sqrt(P) - sqrt(Pa) + P/sqrt(P) - P/sqrt(Pb))
= 2*L*(2*sqrt(P) - sqrt(Pa) - P/sqrt(Pb)) [wait, let me redo]
More precisely, V_LP = y_real + x_real * P:
V_LP(P) = L*(sqrt(P) - sqrt(Pa)) + L*(1/sqrt(P) - 1/sqrt(Pb))*P
= L*(sqrt(P) - sqrt(Pa) + sqrt(P) - P/sqrt(Pb))
= L*(2*sqrt(P) - sqrt(Pa) - P/sqrt(Pb))
V_HODL at price P (with initial deposit at P_0):
x_0 = L*(1/sqrt(P_0) - 1/sqrt(Pb))
y_0 = L*(sqrt(P_0) - sqrt(Pa))
V_HODL(P) = x_0 * P + y_0
= L*(P/sqrt(P_0) - P/sqrt(Pb) + sqrt(P_0) - sqrt(Pa))
IL_V3 = V_LP(P)/V_HODL(P) - 1 (computed numerically for a given range)
Key insight: A narrower range has higher capital efficiency but higher IL for a given price move outside the range.
6.5 IL Hedging Strategies
1. Options Hedging: A strangle (buy OTM put + OTM call) at range boundaries can offset IL. The breakeven fee income needed is approximately the option premium.
Premium_breakeven ≈ IL_expected_vol * V_position / time_period
2. Delta Hedging: An LP's delta (sensitivity to price changes) is:
Δ_LP = ∂V_LP/∂P = L/(2*sqrt(P)) (within range)
A HODL position has delta = x_0. The net delta of HODL - LP position:
Δ_net = x_0 - L/(2*sqrt(P))
This can be hedged with a perpetual short position of size Δ_net. Dynamic rehedging as P changes is needed (gamma risk).
3. Correlation-based strategies: Pair tokens with mean-reverting price relationships (e.g. ETH/stETH) to minimize IL.
6.6 IL vs Fee Revenue Analysis
Break-even holding period where fee income equals IL:
T_breakeven = IL / (daily_fee_yield)
daily_fee_yield = (volume_daily * fee_rate) / TVL
Example:
Pool: ETH/USDC, 0.3% fee tier
Assumptions:
- 1x price move (price doubles) → IL = 5.72%
- Daily volume/TVL ratio = 0.1 (10% daily turnover)
- Fee rate = 0.003
Daily fee yield = 0.1 * 0.003 = 0.03% per day
Days to break even on 5.72% IL = 5.72 / 0.03 ≈ 191 days
If volume/TVL is higher (e.g., 50%), breakeven = 38 days. This framework guides fee tier and pair selection.
7. Balancer Weighted Pools
7.1 Generalized Constant Product (Weighted)
Balancer generalizes constant product to arbitrary weights. For tokens with balances B_i and weights w_i (summing to 1):
Π(B_i^w_i) = k (invariant)
For equal weights (w_i = 1/n), this reduces to the geometric mean, which is equivalent to the standard constant product for n=2.
7.2 Spot Price Formula
The spot price of token i in terms of token j:
SP_ij = (B_i / w_i) / (B_j / w_j)
This is the marginal exchange rate without fees. Intuition: a token with higher weight contributes more to the invariant, so its price is "amplified" by its weight ratio.
With fee (swap fee f):
EP_ij = SP_ij / (1 - f)
7.3 Out-Given-In Swap Formula
Swapping A_i of token i for token j:
A_o = B_j * (1 - (B_i / (B_i + A_i * (1-f)))^(w_i/w_j))
Derivation: Starting from the invariant:
B_i^w_i * B_j^w_j * Π_{k≠i,j}(B_k^w_k) = k
(B_i + A_i_eff)^w_i * (B_j - A_o)^w_j * ... = k
Dividing:
((B_i + A_i_eff) / B_i)^w_i = (B_j / (B_j - A_o))^w_j
Solving for A_o:
(B_j - A_o) = B_j * (B_i / (B_i + A_i_eff))^(w_i/w_j)
A_o = B_j * (1 - (B_i/(B_i + A_i_eff))^(w_i/w_j))
where A_i_eff = A_i * (1 - f).
7.4 In-Given-Out Swap Formula
Given desired output A_o, compute required input A_i:
A_i = B_i * ((B_j / (B_j - A_o))^(w_j/w_i) - 1) / (1 - f)
7.5 Multi-Asset Proportional Join/Exit
Proportional join (deposit all tokens in ratio): straightforward, no swap needed.
BPT_out = BPT_supply * (ratio - 1) where ratio = B_i_new / B_i_old (same for all i)
Single-token join (deposit token i only): treated as depositing proportionally then swapping excess.
BPT_out = BPT_supply * ((B_i + A_i*(1-f))/ B_i)^w_i - 1)
7.6 Python: Balancer Math
class BalancerPool:
def __init__(self, balances: list, weights: list, fee: float = 0.003):
assert abs(sum(weights) - 1.0) < 1e-10, "Weights must sum to 1"
self.balances = list(balances)
self.weights = list(weights)
self.fee = fee
def spot_price(self, i: int, j: int) -> float:
return (self.balances[i] / self.weights[i]) / (self.balances[j] / self.weights[j])
def swap_out_given_in(self, i: int, j: int, amount_in: float) -> float:
Bi, Bj = self.balances[i], self.balances[j]
wi, wj = self.weights[i], self.weights[j]
amount_in_eff = amount_in * (1 - self.fee)
ratio = Bi / (Bi + amount_in_eff)
return Bj * (1 - ratio ** (wi / wj))
def swap_in_given_out(self, i: int, j: int, amount_out: float) -> float:
Bi, Bj = self.balances[i], self.balances[j]
wi, wj = self.weights[i], self.weights[j]
ratio = Bj / (Bj - amount_out)
return Bi * (ratio ** (wj / wi) - 1) / (1 - self.fee)
# Example: 80/20 WBTC/WETH pool
pool = BalancerPool(
balances=[10.0, 100.0], # 10 BTC, 100 ETH
weights=[0.8, 0.2],
fee=0.003
)
print(f"Spot price BTC/ETH: {pool.spot_price(0,1):.4f} ETH per BTC")
# = (10/0.8)/(100/0.2) = 12.5/500 = 0.025 BTC per ETH → 40 ETH per BTC
out = pool.swap_out_given_in(1, 0, 1.0) # sell 1 ETH for BTC
print(f"1 ETH → {out:.6f} BTC")
8. TWAP Oracles
8.1 Geometric Mean TWAP in V3
Uniswap V3 uses a log-price accumulator to compute a geometric mean TWAP. Each block, the tick (log-price) is accumulated:
tickCumulative[t] = tickCumulative[t-1] + current_tick * (t - t_prev)
TWAP calculation over interval [t0, t1]:
tick_avg = (tickCumulative[t1] - tickCumulative[t0]) / (t1 - t0)
price_TWAP = 1.0001^tick_avg
Note: this gives the geometric mean price, not arithmetic mean, because:
geometric_mean(P_t) = exp(mean(log(P_t)))
And since log_{1.0001}(P_t) = tick_t, the average tick corresponds to the geometric mean price.
8.2 Arithmetic Mean vs Geometric Mean TWAP
V2 TWAP (arithmetic mean price): V2 accumulated price0CumulativeLast = price * time, so the TWAP is arithmetic. Vulnerable to manipulation by extreme prices since outliers dominate arithmetic means.
V3 TWAP (geometric mean price): Far more resistant because a 1000x price spike only contributes log(1000) ≈ 3x more than a 2x spike. Attackers would need to sustain extreme prices for long periods.
8.3 Manipulation Resistance Analysis
Cost to manipulate a TWAP oracle up by factor r over time window T:
For geometric mean TWAP (V3):
- Attacker must push price to some level
P_attackfor durationT_attack - Required:
tick_attack * T_attack / T = target_tick_offset - Cost is the arbitrage loss from holding pool at manipulated price
Manipulation cost estimation:
cost ≈ L * (1/sqrt(P_0) - 1/sqrt(P_attack)) * P_attack * (1 - 1/r) [approximately]
For a 10% manipulation (r=1.1) over 30 minutes (T=1800s) in a 1-block window, the cost is typically in the millions of dollars for large pools, making manipulation economically infeasible.
8.4 Observation Ring Buffer
V3 stores observations in a fixed-size ring buffer:
struct Observation {
uint32 blockTimestamp;
int56 tickCumulative;
uint160 secondsPerLiquidityCumulativeX128;
bool initialized;
}
Observation[65535] public observations;
uint16 public observationIndex;
uint16 public observationCardinality; // current ring size
uint16 public observationCardinalityNext; // expanding
Operators can pre-warm the buffer (increasing cardinality) to store more historical observations, enabling longer TWAP windows.
8.5 Solidity: TWAP Computation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IUniswapV3Pool {
function observe(uint32[] calldata secondsAgos)
external view returns (
int56[] memory tickCumulatives,
uint160[] memory secondsPerLiquidityCumulativeX128s
);
}
library OracleLib {
function getTwap(
address pool,
uint32 twapInterval
) internal view returns (int24 arithmeticMeanTick) {
uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = twapInterval;
secondsAgos[1] = 0;
(int56[] memory tickCumulatives, ) = IUniswapV3Pool(pool).observe(secondsAgos);
int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0];
arithmeticMeanTick = int24(tickCumulativesDelta / int56(uint56(twapInterval)));
// Round toward negative infinity if remainder is negative
if (tickCumulativesDelta < 0 && (tickCumulativesDelta % int56(uint56(twapInterval)) != 0)) {
arithmeticMeanTick--;
}
}
function getSqrtTwapX96(
address pool,
uint32 twapInterval
) internal view returns (uint160 sqrtPriceX96) {
int24 tick = getTwap(pool, twapInterval);
// Convert tick to sqrtPriceX96 using TickMath
// sqrtPriceX96 = getSqrtRatioAtTick(tick)
// (implementation using TickMath library)
sqrtPriceX96 = TickMathSimplified.getSqrtRatioAtTick(tick);
}
}
9. Liquidity Bootstrapping Pools (LBP)
9.1 Balancer LBP Concept
An LBP uses Balancer's weighted pool with time-varying weights to enable price discovery for new token launches. The project starts with high weight for the new token (e.g., 95%) and low weight for the collateral (e.g., 5%), then gradually shifts to equal weights over the sale period.
9.2 Weight Shifting Math
Weights change linearly over time:
w_token(t) = w_start + (w_end - w_start) * (t - t_start) / (t_end - t_start)
w_collateral(t) = 1 - w_token(t)
Effect on price: The spot price of the new token in terms of colla
…(truncated)