MODULE 00 Mental model · V2 → V3
Why full-range liquidity wastes capital, what "concentrated" really changes, and the structural consequences: ticks, fee tiers, NFT positions.
The analogy
Imagine a market stall that must stock every possible price for a good — from $0.01 to infinity — even though it only ever sells around $3,000. Most of the inventory just sits there, never touched, earning nothing. That's a Uniswap V2 liquidity provider. V3 lets the stall owner stock only the prices that actually trade, so the same capital does far more work.
V2 baseline — the constant product
x— reserve of token0 (lower address),y— reserve of token1k— the invariant; the pool keepsx·yconstant across swaps (before fees)- Every V2 LP spreads liquidity across
(0, ∞)— they cannot choose a range
The canonical enforcement, from UniswapV2Pair.sol (swap), checks the
invariant on the post-swap balances (adjusted for the 0.3% fee):
uint balance0Adjusted = balance0 * 1000 - amount0In * 3;
uint balance1Adjusted = balance1 * 1000 - amount1In * 3;
require(
balance0Adjusted * balance1Adjusted >= uint(_reserve0) * _reserve1 * (1000**2),
"UniswapV2: K"
);
The capital-efficiency problem (quantified)
If an LP concentrates the same capital into a price range [P_a, P_b] instead of
(0, ∞), the effective depth multiplies. The general efficiency multiplier
E relative to full-range V2 is:
and for a symmetric range centered at the current price, the clean closed form:
A USDC/USDT pair concentrated to ±1% (P_a/P_b = 0.99/1.01 ≈ 0.9802):
The same liquidity provides ~200× the depth of full-range V2. A wider ETH/USDC range
from $2,500 to $3,600 (P_a/P_b ≈ 0.694):
Interactive · capital efficiency vs range width
Drag the lower/upper bounds
around a center price. The Desmos graph plots the efficiency multiplier E
as the range tightens — watch it blow up as the band narrows (the leverage of
concentration). The curve uses the symmetric closed form
E = 1/(1−(P_a/P_b)^¼); the readout below applies it to your exact bounds.
(For a price sitting off-center in the range, use the general form
E = 2√P/(2√P − √P_a − P/√P_b) from the derivation above — e.g. P=3000 in
[2500,3600] gives ≈5.45×.)
The structural consequences
| Feature | Why concentration forces it |
|---|---|
| Ticks | Ranges need discrete, well-defined boundaries; ticks are the price grid (each = 1 basis point) positions snap to |
| Multiple fee tiers | Tight ranges suit stable pairs (low fee); volatile pairs need wider ranges and higher fees to compensate for impermanent loss |
| NFT positions | A position is now (owner, tickLower, tickUpper) — no longer fungible, so
it's an ERC-721, not an ERC-20 LP token |
Architecture: core vs periphery
- Core (
v3-core):UniswapV3Factory,UniswapV3Pool— minimal, unopinionated, immutable. Holds funds and the math; uses callbacks (uniswapV3SwapCallback,uniswapV3MintCallback) so it never trusts the caller's accounting. - Periphery (
v3-periphery):NonfungiblePositionManager(wraps positions as NFTs),SwapRouter(multi-hop, slippage, deadlines). Replaceable, user-friendly, where opinions live.
- V2 spreads every LP across
(0, ∞), so most capital sits at untraded prices earning nothing. - Concentrating into
[P_a, P_b]multiplies effective depth — ~200× for a ±1% stable range — at the cost of LP'ing only while price is in range. - Concentration forces ticks (range boundaries), fee tiers, and non-fungible NFT positions; core stays minimal/immutable, periphery is replaceable.
- In V2, why does liquidity at a price the pair never reaches earn zero fees?
- What's the tradeoff an LP accepts to get the ~200× capital efficiency?
- Why is the swap-invariant check in core and slippage/deadline logic in periphery?
Show answers
1) Fees accrue only on swaps that move through a price; liquidity parked at never-traded
prices is never the marginal liquidity, so no swap ever touches it. 2) The position only
earns and only stays "active" while price is inside [P_a, P_b]; outside the
range it earns nothing and becomes 100% one token. 3) Core must be minimal and immutable
to be trustless and gas-cheap (it just enforces the math via callbacks); UX concerns
like slippage and deadlines vary and belong in replaceable periphery.
MODULE 01 Mathematical foundations
Q64.96 fixed-point, why store √P, square-and-multiply exponentiation, and the local constant product.
1.1 · Q number format
The EVM has no floating point. V3 uses fixed-point: a real number times a power of two,
stored as an integer. Qm.n means m integer bits and n
fractional bits. Q64.96 = 64 integer + 96 fractional = 160 bits total (fits a
uint160); Q128.128 = 256 bits.
And the per-tick base 1.0001: 1.0001 · 2^96 ≈ 7.923×10^28.
1.2 · Why store the square root of price
V3 stores sqrtPriceX96 = √P · 2^96, not P. Reason: swap math
relates amounts to differences in √P (Module 3), so storing √P means the
hot path never computes a square root on-chain — only multiply/divide. Square roots in the EVM
are expensive; differences of a stored √P are cheap.
d_0, d_1— decimals of token0, token1. For ETH(18)/USDC(6) as token1/token0 the rawPis in 1e-12 units, so multiply by10^{12}to read USDC-per-ETH.
Suppose token0 = USDC (6), token1 = WETH (18), and sqrtPriceX96 encodes raw
P = token1/token0. If raw P = 3.33×10^{-16} WETH per USDC-unit,
the human ETH price is its inverse with decimal shift:
1 / (P · 10^{6−18}) = 1/(3.33×10^{-16}·10^{-12}) → ≈ $3,000 per ETH. The
decimal shift is the step re-implementations most often get wrong — always track which token
is token0.
1.3 · Square-and-multiply
Computing 1.0001^tick for tick up to ~887,272 by repeated
multiplication would cost hundreds of thousands of multiplications. Square-and-multiply does it
in O(log tick): write the exponent in binary, square the base each bit, and
multiply the running product whenever a bit is set.
13 = 1101 in binary (bits: 8 + 4 + 1)
square chain: b^1 → b^2 → b^4 → b^8
bit 0 (1): set → multiply in b^1 result = b^1
bit 1 (0): skip
bit 2 (1): set → multiply in b^4 result = b^1 · b^4 = b^5
bit 3 (1): set → multiply in b^8 result = b^5 · b^8 = b^13 ✓
4 squarings + 3 multiplies instead of 12 multiplies
TickMath.getSqrtRatioAtTick (Module 2.2) is a hard-coded
square-and-multiply: each precomputed hex constant is 1.0001^(−2^i) in Q128.128,
multiplied in only when the matching bit of tick is set.
1.4 · The constant product, locally
V3 still uses x·y = k, but only within a tick range and with
virtual reserves. From L = √(x·y) and √P = √(y/x) we derive
the two relations the whole protocol leans on:
Derivation: from
L²=xy and P=y/x, substitute y=Px →
L²=x²P → x=L/√P; then y=Px=PL/√P=L√P. ∎
L is the position's liquidity — the depth of the curve. It stays constant
during any swap that does not cross a tick; only √P, x and
y move.
- Q64.96 stores reals as integers (×2^96);
sqrtPriceX96packs√Pinto auint160. - Storing
√Pkeeps the swap hot-path to multiply/divide; the price↔sqrtPrice conversion must account for token decimals. x = L/√P,y = L√Pare the core identities;Lis constant within a tick range, and1.0001^tickis computed by hard-coded square-and-multiply.
- How many bits does
Q64.96use, and what type holds it? - Why does storing
√Pinstead ofPsave gas at swap time? - Derive
y = L√PfromL² = xyandP = y/x.
Show answers
1) 64 + 96 = 160 bits, held in a uint160. 2) Swap amounts depend on
differences of √P; with √P stored, the path never computes an
on-chain square root (expensive) — only multiply/divide. 3)
P = y/x → y = Px; L² = xy = x·Px = x²P → x = L/√P; then
y = Px = P·L/√P = L√P. ∎
MODULE 02 Ticks & the tick bitmap
tick↔price, the hard-coded TickMath, tick spacing, the ±887272 limits, and the bitmap that makes swaps cheap.
2.1 · What a tick is
Base 1.0001 means each tick is one basis point (0.01%) of price change. Ticks
are integers; "the current tick" is the greatest i whose price is ≤ the current
price.
2.2 · TickMath (the real code)
getSqrtRatioAtTick(int24 tick) is square-and-multiply over the bits of
|tick|. Each constant is 1.0001^(−2^k) in Q128.128; a constant is
multiplied in only if bit k is set, then the result is inverted if
tick > 0 and shifted to Q64.96:
uint256 absTick = tick < 0 ? uint256(-int256(tick)) : uint256(int256(tick));
uint256 ratio = absTick & 0x1 != 0
? 0xfffcb933bd6fad37aa2d162d1a594001 // 1.0001^(-1) in Q128.128
: 0x100000000000000000000000000000000; // 1.0 in Q128.128
if (absTick & 0x2 != 0) ratio = (ratio * 0xfff97272373d413259a46990580e213a) >> 128;
if (absTick & 0x4 != 0) ratio = (ratio * 0xfff2e50f5f656932ef12357cf3c7fdcc) >> 128;
// … one line per bit, up to 0x80000 …
if (tick > 0) ratio = type(uint256).max / ratio; // invert for positive ticks
// round up, then shift Q128.128 → Q64.96
sqrtPriceX96 = uint160((ratio >> 32) + (ratio % (1 << 32) == 0 ? 0 : 1));
getTickAtSqrtRatio goes the other way: a most-significant-bit log computation
(binary search) returning the greatest tick with sqrtRatio ≤ input — the off-by-one
direction matters for the bitmap.
2.3–2.5 · Spacing, fee tiers, limits
| Fee | tickSpacing | Use |
|---|---|---|
| 0.05% | 10 | Stable pairs (tight, fine-grained) |
| 0.30% | 60 | Standard pairs |
| 1.00% | 200 | Volatile/exotic (coarse, cheaper crossing) |
Position boundaries must be divisible by tickSpacing. Wider spacing = fewer
initializable ticks = cheaper swaps (fewer crossings) but coarser liquidity. The limits come
from fitting √P into a uint160:
So the price range is ~2^{-128} to 2^{128}; swaps clamp against
MIN_SQRT_RATIO / MAX_SQRT_RATIO.
2.6 · The tick bitmap (in depth)
The pool tracks which ticks are initialized (referenced by some position) in a
mapping(int16 => uint256) — one 256-bit word per 256 (compressed) ticks. A
tick's place decomposes into a word position and a bit position:
function flipTick(mapping(int16 => uint256) storage self, int24 tick, int24 tickSpacing) internal {
int24 compressed = tick / tickSpacing;
(int16 wordPos, uint8 bitPos) = position(compressed);
uint256 mask = 1 << bitPos;
self[wordPos] ^= mask; // toggle the bit
}
function nextInitializedTickWithinOneWord(
mapping(int16 => uint256) storage self, int24 tick, int24 tickSpacing, bool lte
) internal view returns (int24 next, bool initialized) {
int24 compressed = tick / tickSpacing;
if (lte) { // searching left (price down)
(int16 wordPos, uint8 bitPos) = position(compressed);
uint256 mask = (1 << bitPos) - 1 + (1 << bitPos); // all bits ≤ current
uint256 masked = self[wordPos] & mask;
initialized = masked != 0;
next = initialized
? (compressed - int24(bitPos - BitMath.mostSignificantBit(masked))) * tickSpacing
: (compressed - int24(bitPos)) * tickSpacing;
} else { /* search right (price up): least-significant-bit of the upper mask */ }
}
The swap loop calls nextInitializedTickWithinOneWord each step to jump straight to
the next tick that actually has liquidity, instead of scanning all 887k ticks — the single
biggest gas win in the swap path.
P(i) = 1.0001^i(each tick = 1 bp);TickMathis hard-coded square-and-multiply for tick→√P and an MSB-log for √P→tick.- Fee tier fixes
tickSpacing; boundaries must be divisible by it; limits ±887272 come from fitting√Pin auint160. - The bitmap stores initialized ticks 256-per-word;
nextInitializedTickWithinOneWordlets swaps skip empty ticks.
- What price change does one tick represent, and why base 1.0001?
- For
tickSpacing = 60, is tick 75 a legal position boundary? What about 120? - Why does the bitmap make swaps dramatically cheaper than scanning ticks?
Show answers
1) 0.01% (one basis point); base 1.0001 makes each integer tick exactly 1 bp so the grid
is uniform in log-price. 2) 75 is illegal (75/60 not integer); 120 is legal (120/60 =
2). 3) It packs 256 ticks per word, so one SLOAD + bit-mask finds the next
initialized tick, versus iterating potentially thousands of empty ticks.
MODULE 03 Liquidity & reserves
Virtual vs real reserves, the three price regions, and the amount-delta formulas with their rounding directions.
3.1–3.2 · L, virtual vs real reserves
A concentrated position on [P_a, P_b] behaves like a much larger full-range V2
position while price is in range. The trick: add offset terms so the local curve looks
like a shifted constant product. Virtual reserves are what the curve "pretends" exist; real
reserves are what's actually deposited.
At the boundaries one real reserve hits zero: at P = P_b, x_real = 0
(all token1); at P = P_a, y_real = 0 (all token0).
3.3 · The three regions
| Price vs range | Composition | Amounts |
|---|---|---|
P ≤ P_a (below) |
100% token0 | x = L(1/√P_a − 1/√P_b), y = 0 |
P_a < P < P_b (in range) |
mix | x = L(1/√P − 1/√P_b), y = L(√P − √P_a) |
P ≥ P_b (above) |
100% token1 | x = 0, y = L(√P_b − √P_a) |
3.4 · Amount deltas (the code)
SqrtPriceMath.getAmount0Delta / getAmount1Delta compute how much token
moves between two √P for a given L. Side by side — note token0 has the
product of both √P in the denominator, token1 is just the difference:
Derivation: Δx = L/√P_a − L/√P_b = L(√P_b − √P_a)/(√P_a√P_b) from
x = L/√P; Δy = L√P_b − L√P_a from y = L√P. They differ
because token0 lives in the 1/√P world and token1 in the √P world.
getAmount0Delta / getAmount1Delta take a roundUp flag.
The protocol rounds up when computing what a user must pay in (so the pool
never receives too little) and down when computing what a user receives out
(so the pool never pays too much). Get this backwards and the pool slowly bleeds value to
traders.
L = 2,000,000, range tick 0 → tick 60 (√P_a = 1.0,
√P_b ≈ 1.00299551 in Q64.96):
Interactive · position reserves across the three regions
Drag the current price across a fixed range. The Desmos graph plots token0 (orchid) and token1 (mint) reserves vs price — watch token0→0 at the upper bound and token1→0 at the lower bound, with the mix in between.
- Virtual reserves make a concentrated position behave like a big V2 position in range; real reserves are the deposited amounts, hitting zero at the bounds.
- Three regions: all token0 below the range, a mix inside, all token1 above.
Δxcarries1/(√P_a√P_b),Δyis the bare √P difference; round up on pay-in, down on pay-out.
- At
P = P_b, which real reserve is zero and which token does the position fully hold? - Why does
Δxhave√P_a√P_bin the denominator butΔydoesn't? - Which rounding direction applies to the amount a trader must pay in, and why?
Show answers
1) x_real = 0; the position is 100% token1 (it sold all its token0 as price
rose to the top). 2) token0 reserves are L/√P, so a difference gives
L(√P_b−√P_a)/(√P_a√P_b); token1 is L√P, whose difference is
just L(√P_b−√P_a). 3) Round up, so the pool never collects less
than the exact amount owed (protects LPs/the invariant).
MODULE 04 Positions & fee growth
The Position struct, mint/burn/collect's two-step design, and the feeGrowthInside accounting that isolates a range's fees.
4.1 · The Position struct
struct Info {
uint128 liquidity; // L contributed by this position
uint256 feeGrowthInside0LastX128; // snapshot of in-range fee growth, token0
uint256 feeGrowthInside1LastX128; // snapshot, token1
uint128 tokensOwed0; // fees credited, not yet collected
uint128 tokensOwed1;
}
Keyed by keccak256(owner, tickLower, tickUpper) — that's why positions are
non-fungible: two LPs with different ranges hold fundamentally different things, so the
periphery wraps each as an ERC-721.
4.2 · mint / burn / collect (two-step)
mint updates both ticks via Tick.update (flipping the bitmap if a tick
becomes initialized), writes the position, then calls uniswapV3MintCallback so the
caller pays — the pool verifies tokens arrived rather than trusting the caller.
burn does not transfer tokens; it reduces liquidity and credits
tokensOwed. collect is the actual withdrawal. The split lets an LP
burn and re-mint without forced transfers, and batches fee collection.
4.3 · Fee growth accounting (the clever part)
feeGrowthGlobal0X128 is a monotonically increasing accumulator: total fees per unit
of liquidity, ever. To find fees earned inside a range, subtract the "outside" growth
of the two boundary ticks:
where
f_below/f_above are derived from each tick's
feeGrowthOutside, chosen by where the current tick sits relative to the
boundary.
The subtraction isolates exactly the growth that happened while price was inside the range — the mechanism behind every fee question in Module 6.
- A position is
(owner, tickLower, tickUpper)with liquidity, two fee-growth snapshots, and owed balances — non-fungible, hence NFTs. - mint pays via callback; burn only credits
tokensOwed; collect withdraws — a deliberate two-step. feeGrowthInside = global − below − above; owed =L · Δ feeGrowthInside.
- Why doesn't
burnsend tokens, and what does? - How does subtracting two ticks' outside-growth isolate in-range fees?
- Why are V3 positions ERC-721 rather than ERC-20?
Show answers
1) burn credits tokensOwed; collect performs the
transfer — separating accounting from withdrawal. 2) The global accumulator minus
growth-below-lower minus growth-above-upper leaves only growth that occurred while the
price was between the bounds. 3) Positions differ by range
(tickLower, tickUpper), so they aren't interchangeable — non-fungible.
MODULE 05 Swaps · the state machine
The swap loop, computeSwapStep, crossing a tick, the edge-of-tick case, and exact-in vs exact-out.
5.1 · Entry & state
swap(recipient, zeroForOne, amountSpecified, sqrtPriceLimitX96, data).
zeroForOne = selling token0 for token1 (price falls).
amountSpecified > 0 = exact input, < 0 = exact output.
sqrtPriceLimitX96 caps how far price may move. The loop carries a
SwapState (remaining amount, current √P, current tick, accumulated
fees, active liquidity).
5.2–5.3 · The loop & computeSwapStep
while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) {
StepComputations memory step;
step.sqrtPriceStartX96 = state.sqrtPriceX96;
// 1) find the next initialized tick within one word (bitmap)
(step.tickNext, step.initialized) =
tickBitmap.nextInitializedTickWithinOneWord(state.tick, tickSpacing, zeroForOne);
step.sqrtPriceNextX96 = TickMath.getSqrtRatioAtTick(step.tickNext);
// 2) swap within this tick range up to the next tick or the limit
(state.sqrtPriceX96, step.amountIn, step.amountOut, step.feeAmount) =
SwapMath.computeSwapStep(
state.sqrtPriceX96,
(zeroForOne ? step.sqrtPriceNextX96 < sqrtPriceLimitX96 : step.sqrtPriceNextX96 > sqrtPriceLimitX96)
? sqrtPriceLimitX96 : step.sqrtPriceNextX96,
state.liquidity, state.amountSpecifiedRemaining, fee);
// 3) update remaining amounts & global fee growth …
// 4) if we reached the next tick exactly, cross it
if (state.sqrtPriceX96 == step.sqrtPriceNextX96 && step.initialized) {
int128 liquidityNet = ticks.cross(step.tickNext, feeGrowthGlobal0X128, feeGrowthGlobal1X128);
if (zeroForOne) liquidityNet = -liquidityNet;
state.liquidity = LiquidityMath.addDelta(state.liquidity, liquidityNet);
state.tick = zeroForOne ? step.tickNext - 1 : step.tickNext;
}
}
computeSwapStep moves √P as far as the remaining amount allows, bounded
by the next tick or the limit. The fee is taken from the input. Exact-in consumes input and
produces output; exact-out targets an output and back-computes input — same code path, routed by
the sign of amountSpecified.
5.4 · Crossing a tick (liquidityNet vs liquidityGross)
Each tick stores liquidityNet — the signed change to active liquidity when crossed
(a position adds +L at its lower tick, −L at its upper).
Tick.cross applies it and flips feeGrowthOutside.
liquidityGross tracks total referencing liquidity for bookkeeping (when it hits
zero the tick uninitializes). When crossing left (zeroForOne), the sign is negated.
5.5 · The edge-of-tick case (your specific question)
As a swap pushes price to a range boundary, one real reserve of in-range positions goes to zero. The crucial distinction: price (√P) moves continuously across the boundary, but active liquidity L jumps discretely at the crossing.
single position L=1,000,000 on [tickLo, tickHi]; price rising (oneForZero)
... swap consumes token1 in, pays token0 out, √P climbs toward √P(tickHi)
at √P = √P(tickHi): x_real = L(1/√P_hi − 1/√P_hi) = 0 → position now 100% token1
computeSwapStep returns sqrtPriceX96 == sqrtPriceNextX96 → reached the tick exactly
Tick.cross(tickHi): liquidityNet here is −L (it's this position's UPPER bound)
state.liquidity += (−L) → active L drops to whatever the NEXT range holds (maybe 0)
√P keeps moving continuously into the next range; L is now the next range's depth
The symmetric case (token1 → token0, price falling) hits P_lower where
y_real → 0 (position becomes 100% token0), crosses tickLo whose
liquidityNet is +L as a lower bound — but negated on a downward
cross, so active L still steps to the adjacent range's value.
Interactive · active liquidity steps as price crosses ticks
Three overlapping positions on different ranges. Drag the price; the canvas shows each position's range (bars) and the resulting active liquidity (the stepped orchid line) — continuous price, discrete L jumps at every tick boundary.
5.6 · Exact-in vs exact-out side by side
| Exact in (amountSpecified > 0) | Exact out (amountSpecified < 0) | |
|---|---|---|
| Loop target | consume all input | produce the requested output |
| Fee | taken from input each step | added on top of required input |
| computeSwapStep | moves √P by available input | moves √P to deliver needed output |
| Return | output received | input required |
- The swap loop repeatedly: finds the next initialized tick (bitmap), runs
computeSwapStepto that tick or the limit, updates amounts/fees, and crosses the tick if reached exactly. - Crossing applies the tick's signed
liquidityNet(negated when going down); price is continuous, active L is discrete. - Exact-in and exact-out share the path, routed by the sign of
amountSpecified.
- At a range's upper boundary, which reserve is zero and what is the position made of?
- What's continuous and what's discrete as a swap crosses a tick?
- Why is a tick's
liquidityNetnegated when crossing in thezeroForOnedirection?
Show answers
1) x_real = 0 (token0 gone); the position is 100% token1. 2) √P
moves continuously; active liquidity L jumps discretely at the crossing. 3)
liquidityNet is stored for upward crossings (price rising); going down
reverses the direction of liquidity entering/leaving, so the sign flips.
MODULE 06 Fees
Where the fee is taken, the rigorous proof that only in-range LPs earn, protocol fees — and why there are no liquidators in an AMM.
6.1 · Accrual
In computeSwapStep the fee is skimmed from the input each step and added to
feeGrowthGlobalX128 as feeAmount · 2^128 / liquidity — i.e. fee
per unit of active liquidity. So only the liquidity that was active during that step
shares in it.
6.2 · In-range vs out-of-range (proved, not asserted)
From Module 4.3, a position's earned fee is L · (f_inside,now − f_inside,last).
While price is outside the range, f_inside does not change — the
feeGrowthOutside subtraction holds the inside-accumulator flat — so the difference
contributes zero. Therefore:
- Liquidity parked in a range where swaps happen outside earns nothing — provably, from the accumulator, not by assertion.
- When a swap occurs at a price inside a range, only positions whose ranges include that price are "active," and they split that step's fee pro-rata by liquidity (because the global accrual is per-unit-L and only their L is active).
There are no liquidators in Uniswap V3. Liquidation is a lending-protocol concept (Aave/Compound) — there's no borrowing in a spot AMM, so nothing to liquidate. The closest analogue is arbitrageurs: they trade against the pool to realign its price with the broader market. They earn no protocol fee — their profit is the price gap they close. Arbitrage (not liquidation) is what keeps a V3 pool's price correct.
6.3 · Protocol fees
setFeeProtocol sets a fraction (e.g. 1/4 to 1/10) of the swap fee to divert to the
protocol; it's skimmed in computeSwapStep before the rest goes to LPs, accumulated
in protocolFees, and withdrawn by the owner via collectProtocol.
- Fees accrue per unit of active liquidity into the global accumulator, so only in-range liquidity shares each swap.
- Out-of-range positions provably earn zero (their
f_insideis frozen); in-range positions split fees pro-rata by L. - No liquidators exist in an AMM — arbitrageurs (unpaid by the protocol) keep the price aligned; protocol fees are an optional skim collected by the owner.
- If swaps happen only at prices outside my range, how much do I earn — and why, from the accumulator?
- Two in-range positions have L = 1M and 3M. How is a swap's fee split?
- Why is "liquidator" the wrong word for Uniswap V3, and what's the right analogue?
Show answers
1) Zero — f_inside doesn't advance while price is outside the range, so
L·Δf_inside = 0. 2) 1:3 — pro-rata by liquidity (25% / 75%). 3) There's no
borrowing in an AMM, so nothing to liquidate; the analogue is arbitrageurs, who realign
price and profit from the gap rather than a protocol fee.
MODULE 07 Pool lifecycle & admin
Factory CREATE2 pool creation, initialization, owner-gated functions, and why core immutability is a feature.
7.1 · Factory & pool creation
UniswapV3Factory.createPool(tokenA, tokenB, fee) orders the tokens
(token0 = lower address), looks up the tickSpacing for that fee, and
deploys via CREATE2 with a deterministic salt:
pool = address(new UniswapV3Pool{salt: keccak256(abi.encode(token0, token1, fee))}());
Deterministic addresses let anyone compute a pool's address off-chain from
(token0, token1, fee) without a registry lookup.
enableFeeAmount(fee, tickSpacing) adds new fee/spacing tiers (owner-only).
7.2 · Initialization
initialize(sqrtPriceX96) sets the starting price and tick once; a pool must be
initialized before any mint or swap. This bootstraps slot0 (the packed current
price, tick, and oracle state).
7.3 · Admin / immutability
Owner-gated: setFeeProtocol, collectProtocol, and factory
setOwner. Critically, core is immutable — there is no admin who can move
user funds, pause the pool, or change the math. The only owner powers are the protocol-fee
switch and collecting already-accrued protocol fees. Immutability is the feature: users get a
permanent guarantee the rules can't change under them.
- Pools are CREATE2-deployed with salt
(token0, token1, fee), giving deterministic, registry-free addresses. initializesets the startingsqrtPriceX96once before any liquidity or swaps.- Core is immutable; the owner can only toggle/collect protocol fees — never touch user funds or the math.
- What goes into the CREATE2 salt, and why does determinism help?
- What must happen before a pool accepts liquidity?
- Name the only things a pool owner can do — and what they cannot.
Show answers
1) (token0, token1, fee); addresses are computable off-chain without a
lookup, simplifying integrations. 2) initialize(sqrtPriceX96) must set the
starting price/tick. 3) Owner can set/collect protocol fees and (factory) transfer
ownership; it cannot move user funds, pause, or alter the math.
MODULE 08 The price oracle (TWAP)
The observation ring buffer, the tick accumulator, computing a TWAP, and why it uses the log-price.
8.1 · Observations
Each pool keeps a ring buffer of Observations: a timestamp, a tick
accumulator (cumulative sum of the current tick × seconds), a cumulative
1/liquidity, and an init flag. observe(secondsAgos[]) interpolates
between stored observations to return accumulators at past times.
8.2 · The tick accumulator & TWAP
Two observations give the average tick over the window by a simple difference-over-time. Convert
back to a price with P = 1.0001^{\bar i}.
If tickCumulative rose by 3,600,000 over 1,800
seconds: avg tick = 3,600,000 / 1,800 = 2,000 →
P = 1.0001^{2000} ≈ 1.2214 (token1 per token0).
8.3 · Why log-price, why it exists
Averaging the tick (log price) rather than the price means the TWAP is a geometric mean
— robust to asymmetric spikes and cheap to accumulate (just add tick × Δt).
On-chain TWAP exists so other protocols can read a manipulation-resistant price: moving a
TWAP requires sustaining a manipulated price across time (and paying arbitrage), not just a
single-block push. Its limitation: it lags, and very short windows are cheaper to manipulate.
- A ring buffer of observations stores a cumulative tick accumulator;
observeinterpolates past values. - TWAP tick = Δ accumulator / Δ time; convert via
P = 1.0001^tick. - Using log-price gives a geometric mean and cheap accumulation; the TWAP resists single-block manipulation but lags.
- How do you get an average tick over a window from two observations?
- Why average the tick rather than the price directly?
- Why is a TWAP harder to manipulate than a spot price, and what's the cost of that robustness?
Show answers
1) (tickCumulative(t2) − tickCumulative(t1)) / (t2 − t1). 2) It yields a
geometric mean (robust to asymmetric spikes) and accumulates cheaply as
tick·Δt. 3) Moving it requires holding a manipulated price across many
blocks against arbitrage, not one push; the cost is that the TWAP lags the true price
and short windows are weaker.
MODULE 09 Building your own DEX
A build order, the correctness pitfalls and how to test them, what to reuse vs re-derive, non-EVM porting, and invariant tests.
9.1 · Recommended build order
- Fixed-point + math libs (Modules 1, 3.4):
FullMath.mulDiv,TickMath,SqrtPriceMath. Get these bit-exact first. - Ticks + bitmap (Module 2):
Tick.update/cross,TickBitmap.flipTick/nextInitializedTickWithinOneWord. - Positions + fee growth (Module 4): the accumulator accounting.
- mint/burn/collect with callbacks.
- The swap loop (Module 5) — last and slowest; it composes everything above.
- Oracle (Module 8) and factory/init (Module 7).
9.2 · Top correctness pitfalls & how to test
| Pitfall | Test |
|---|---|
| Rounding direction (pay-in up, pay-out down) | Fuzz swaps; assert pool balance never decreases below the invariant after a round trip |
| token0/token1 ordering | Property test: token0 < token1 always; price interpreted as
token1/token0 |
| Bitmap edge at word boundaries | Initialize ticks straddling a 256-multiple; assert next-tick search crosses words correctly |
| Price-limit clamp | Swap with a limit mid-range; assert √P stops exactly at the limit |
liquidityNet sign |
Mint a position, swap across both bounds, assert active L returns to baseline |
| Fixed-point overflow | Use FullMath.mulDiv (512-bit intermediate); fuzz with max uint
inputs |
9.3 · Reuse vs re-derive
Reuse the battle-tested libs: TickMath, SqrtPriceMath,
FullMath. FullMath.mulDiv(a, b, denominator) computes
a·b/denominator at full 512-bit precision — it splits the 512-bit product
across two words and does long division, so a·b can exceed 2^256
without overflow. Re-deriving this from scratch is the most common source of value-leaking bugs,
so reuse it.
9.4 · Non-EVM porting (conceptual)
- Account model: on Solana there's no contract storage of
mappings — ticks/positions become program-derived accounts (PDAs); the bitmap becomes a set of fixed-size accounts. - Compute budget: the swap loop must bound tick crossings per transaction; a long crossing run can exceed the compute limit.
- Fixed-point libs: no native 256-bit mulDiv — you implement the 512-bit trick or use
u128with care. - No callbacks: the EVM mint/swap callback pattern is replaced by pre-funded transfers/CPI ordering.
9.5 · Minimal invariant tests
- Reserve conservation: sum of all positions' real reserves at the current price ≈ pool token balances (minus fees/protocol fees).
- Liquidity conservation: a swap that doesn't cross a tick leaves active
Lunchanged. - Fee-growth monotonicity:
feeGrowthGlobalnever decreases. - No-arb round trip: swap
A→B→Areturns ≤ the input (fees make it strictly less) — never more. - Tick consistency:
getTickAtSqrtRatio(getSqrtRatioAtTick(i)) == ifor all validi.
- Build bottom-up: math libs → ticks/bitmap → positions/fees → mint/burn → swap loop → oracle/factory.
- Reuse
TickMath/SqrtPriceMath/FullMath(the 512-bitmulDiv); re-derive only what you must, and fuzz the rounding/ordering/bitmap edges. - Porting off-EVM mainly reshapes storage (accounts/PDAs), bounds the swap loop to the compute budget, and replaces callbacks.
- Why build the swap loop last?
- What does
FullMath.mulDivprotect against, and how? - Give one invariant test that would immediately catch a
liquidityNetsign error.
Show answers
1) It composes every other component (ticks, bitmap, deltas, fees), so they must be
correct first. 2) Intermediate overflow of a·b beyond 2^256 — it carries a
512-bit product across two words and long-divides. 3) Mint a position, swap across both
its bounds and back; active liquidity must return to its starting value — a sign error
leaves it wrong.
References
- Uniswap/v3-core —
UniswapV3Pool.sol,UniswapV3Factory.sol; librariesTickMath,SqrtPriceMath,SwapMath,Tick,TickBitmap,Position,Oracle,FullMath. - Uniswap/v3-periphery —
NonfungiblePositionManager.sol,SwapRouter.sol. - Uniswap V3 Core whitepaper — the canonical derivations.
- RareSkills V3 series — concentrated liquidity, ticks, Q number format, sqrtPriceX96, tick limits, tick spacing, getSqrtRatioAtTick, virtual/real reserves, getAmountDelta, positions.
Code excerpts are abbreviated
for teaching; always cross-check against the canonical v3-core source before
relying on exact lines in your own implementation.