Back to Blog 

UniswapDeFiSolidityWeb3 Security
Fee Growth Outside: The Uniswap V3 Trick Nobody Explains Well
12 min
TL;DR — Quick Summary
- Each initialized tick stores two liquidity numbers that are constantly confused:
liquidityGrossdecides when the tick can be deleted,liquidityNetis the signed change applied when the price crosses it. feeGrowthOutsidestores the fee growth on the side of the tick away from the current price. Crossing the tick flips which side that is, and the stored value stays correct with a single assignment:outside = global − outside.- Fee growth inside any range, at any time, is
global − below − above. Two subtractions per token, in constant time, regardless of how many crossings happened. - The value written when a tick is first initialized is a convention, not a fact. It works because every formula consumes only differences between inside values, so a constant offset cancels.
global − below − abovecan legitimately go negative, so it must beunchecked. Under Solidity 0.8 it panics instead of wrapping, and the pool bricks under entirely normal use.
The full walkthrough, with the code written on screen. The article below covers
the same ground if you would rather read it.
Introduction
Here is a question that sounds impossible.
A liquidity position earns fees only while the price is inside its range. The price moves in and out, thousands of times, over months. Now tell me how many fees that position earned — without looping over any history, and without storing anything per position except one checkpoint.
Uniswap V3 answers it in one subtraction.
That is the fee-growth-outside trick, and it is the single most confusing thing in the protocol. It is also, once it clicks, the most elegant. This is section 4 of Building Uniswap V3.
First, the two liquidity numbers
Before the fees, the simpler half. Every initialized tick stores two liquidity values, and people mix them up constantly.
liquidityGross counts how many positions reference this tick at all. It only goes up when you add and down when you remove, and it answers exactly one question: can this tick be deleted yet? When gross reaches zero, nobody references the tick anymore and it gets cleared.liquidityNet is signed. It is the change applied to the pool's active liquidity when the price crosses this tick — added at a position's lower tick, subtracted at its upper.So the same position touches both ticks, in opposite directions. Cross into the range and liquidity goes up. Cross out and it comes back down. That is the whole mechanism for tracking active liquidity as the price moves, and it is why the swap loop never has to know which positions exist.
The problem the fee accumulator has to solve
The pool keeps one global accumulator per token: all-time fees, per unit of liquidity. Simple, cheap, one number.
But that global counts fees earned across every price. Your position only earned the ones that accrued while the price was inside your range.
So how do you subtract out everything else, without walking through history?
Storing the wrong side on purpose
Here is the trick. Every initialized tick stores
feeGrowthOutside: the fee growth accumulated on the side of the tick away from the current price.That definition has a beautiful maintenance property. When the price crosses the tick, "away from the price" flips to the other side — and the stored value stays correct if you simply assign:
1outside = global − outside
That is the entire
cross function, as far as fees are concerned. One subtraction per crossing. No history, no loops, no iteration over anything.And then inside falls out
Once both boundary ticks hold correct outside values, the growth inside the range is just what is left after removing everything below and everything above:
1inside = global − below − above
The two conditionals in the code exist only to read from the correct side. If the current price is at or above the lower tick, that tick's
outside is the below-side growth. If the price is under it, the stored value is describing the other side, so the below-side growth is the complement — global − outside.Walking it with real numbers
This is where it clicks or it does not, so it is worth doing slowly. Drop the fixed-point scaling and think in fee units per unit of liquidity.
Setup. A position spanning ticks −100 to 200. The current tick is 50, so we are in range. Global fee growth is 100.
Tick −100 was initialized when global was 20, and the price has been above it ever since, so its
outside is 20. Tick 200 has an outside of 10, left from an old excursion above it that flipped back on the way down.Read it.
below= 20, read directly, because 50 is at or above −100.above= 10, read directly, because 50 is under 200.inside= 100 − 20 − 10 = 70.
Now 20 more fee units accrue, price still in range. Global becomes 120. The outside values do not move at all, because nothing was crossed.
inside= 120 − 20 − 10 = 90.
Your position checkpointed at 70.
90 − 70 = 20 — exactly the fees that accrued while you were in range. It just works.Now let the price rise through tick 200.
cross fires and sets that tick's outside to 120 − 10 = 110.The current tick is now above 200, so the conditional takes the other branch:
above = global − outside = 120 − 110 = 10.The same 10, read from the flipped side. The books stay balanced straight through the crossing, and nothing had to be recomputed.
Building something that holds user funds? The Founder Security Sprint is a focused engagement for teams shipping their first protocol. And if you want someone walking you through protocol code like this every week, alongside other security engineers, that is Zealynx Insiders.
The made-up number
There is a part of this that bothers people the first time, and they are right to be bothered.
When a tick is initialized for the very first time, what should its
outside value be? Nobody was tracking it before now. There is no true answer.So V3 just decides. If the tick is at or below the current price, all historical growth is deemed to have happened below it, so
outside = global. If it is above the current price, outside is zero — and notice the code has no else branch at all, because zero is the struct default.That is a convention, not a fact. It is a made-up number.
Working auditors in your corner, all year
Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from the firm behind 42 audits. Founders get a two-day audit session on the $500/year plan.
No spam. Unsubscribe anytime.
And it works, because every formula here only ever consumes differences between inside values taken at two moments. A constant offset appears in both and cancels. The absolute value is meaningless. Only the difference is real.
Once you have that, a lot of V3 stops looking arbitrary. The protocol is full of accumulators whose absolute values mean nothing and whose differences mean everything.
Where a careless port destroys the protocol
Because those outside values are relative fictions,
global − below − above can legitimately go below zero. A tick initialized late, sitting next to one initialized early, is enough to do it. This is not an error state — it is normal.Under Solidity 0.7 that subtraction wrapped silently, and correctly. Under 0.8 it panics.
So the expression must be wrapped in
unchecked, or the pool reverts on perfectly valid state. Skip that one keyword when porting and the pool bricks under completely normal use — not under attack, not at an edge case, just under a tick initialization order that happens all the time.And the wrap is harmless for exactly the same reason the offset is. The next subtraction — current inside minus your checkpointed inside — wraps back. The difference is exact in modular arithmetic.
This is the canonical example of underflow being intentional and safe, and it is the third time in four sections that
unchecked has turned out to be load-bearing rather than decorative.The two functions that matter
The inside formula is the payoff, and its size is the point:
Everything not below and not above is inside.
global − below − above, for both tokens, inside an unchecked block.Two subtractions per token. That is the answer to "how many fees did this position earn," for any range, at any time, in constant time. No loop over crossings. No per-position history.
cross is the function that keeps all of it true. Every accumulator on the tick gets the same treatment: fee growth outside for both tokens, flipped — and then the three oracle fields, the tick cumulative, the seconds per liquidity, and the seconds outside, flipped identically.You write those oracle fields here and fully understand them later, but notice they follow the identical pattern. That is not a coincidence. It is the same idea applied to a different accumulator, which is a strong hint that the idea generalises well beyond fees.
All of it
unchecked, including the timestamp math, where a uint32 subtraction wrapping is by design.And then
cross returns liquidityNet, which is what the swap loop applies to active liquidity as it steps across the boundary.What you have after this
Two liquidity numbers per tick — one for lifetime, one for crossings. And an accumulator that stores the wrong side on purpose, flips on every crossing, and turns an impossible-looking history question into one subtraction.
Next in this series:
TickBitmap, which answers a different question. Given where the price is now, which is the next initialized tick? The swap loop cannot afford to check them one at a time, and the answer is a single word of storage and some bit twiddling.FAQ
1. What is fee growth outside in Uniswap V3?
feeGrowthOutside is the fee growth accumulated on the side of a tick away from the current price. It is not maintained continuously — it is only flipped, via outside = global − outside, each time the price crosses that tick. Subtracting the outside values of a range's two boundary ticks from the global accumulator yields the growth strictly inside the range, in constant time.2. What is the difference between liquidityGross and liquidityNet?
liquidityGross is unsigned and counts how many positions reference the tick at all; when it reaches zero the tick can be deleted. liquidityNet is signed and is the change applied to the pool's active liquidity when the price crosses the tick — added at a position's lower tick, subtracted at its upper. Gross governs lifetime; net governs crossings.3. Why can the fee growth inside calculation underflow, and why is that safe?
Because the outside values written at tick initialization are a convention rather than a measurement,
global − below − above can legitimately be negative when ticks were initialized at different times. It is safe because every consumer takes a difference of two inside values, and in modular arithmetic the wrap cancels between them. The subtraction must therefore sit inside unchecked; under Solidity 0.8 a checked subtraction panics and bricks the pool on valid state.4. Why is the initial feeGrowthOutside value arbitrary?
Because nothing was tracked for that tick before it existed, so there is no correct historical value. V3 assigns
global if the tick is at or below the current price and zero otherwise. The choice is safe because all downstream math consumes only differences between inside snapshots, and a constant offset present in both terms cancels exactly.5. Why does `cross` update oracle fields as well as fee fields?
Because the oracle's per-tick values — tick cumulative, seconds per liquidity outside, and seconds outside — use the same "store the far side, flip on crossing" scheme as fee growth. They are different accumulators sharing one mechanism, which is why they are flipped with identical code in the same function.
Glossary
| Term | Definition |
|---|---|
| Fee Growth | A global accumulator of fees per unit of liquidity, letting a pool settle any position by differencing two snapshots. |
| Liquidity Net | The signed liquidity a pool applies to its active total when the price crosses a tick. |
| Tick | A discrete price point where tick i means a price of 1.0001^i. |
| Tick Bitmap | A sparse bitmap recording which ticks are initialized, so the swap loop can find the next one in a single storage read. |
| Active Liquidity | The liquidity currently in range and therefore backing trades at the pool's present price. |
| Concentrated Liquidity | Allocating liquidity to a chosen price range rather than the whole curve. |
Write it yourself
This article is section 4 of Building Uniswap V3, a free 18-section module on the Zealynx Academy. In this one you write the
Tick library — the inside formula, cross, the gross update with its cap check, the initialization convention and clear — and a 17-test suite checks each of them.If you would rather learn it alongside other engineers, with a live build session every week and office hours where you bring your own code, that is Zealynx Insiders.
Working auditors in your corner, all year
Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from the firm behind 42 audits. Founders get a two-day audit session on the $500/year plan.
No spam. Unsubscribe anytime.
