Section 6 of 9
Tick System and Position Management
Tick System and Position Management
What You Are Building
Two libraries that form the data layer of concentrated liquidity: Tick and Position. Every other part of the protocol depends on these. The Tick library manages what happens at each price boundary. The Position library tracks individual LP stakes.
In Uniswap V3's codebase, these are Tick.sol and Position.sol in the v3-core repository. Your implementation follows the same architecture with simplified math.
The Tick Library
Each initialized tick stores a Tick.Info struct with four essential fields:
liquidityGross (uint128): The total liquidity referencing this tick, counting both lower and upper bound references. When liquidityGross drops to zero, the tick is de-initialized and no longer needs to be checked during swaps. When it goes from zero to non-zero, the tick becomes initialized. This transition is called "flipping."
liquidityNet (int128): The net liquidity change that occurs when the price crosses this tick moving upward. This is the critical field for swap execution. The sign convention is: when an LP adds liquidity with tickLower=100 and tickUpper=200, tick 100 gets +liquidityDelta added to its liquidityNet, and tick 200 gets -liquidityDelta. When a swap crosses tick 100 going up, the pool gains that liquidity. When it crosses tick 200 going up, the pool loses it. This is what makes concentrated ranges work.
feeGrowthOutside0X128, feeGrowthOutside1X128 (uint256): Fee growth accumulated on the "other side" of this tick. The "outside" convention requires careful initialization. When a tick is first initialized and the current price is at or above that tick, feeGrowthOutside is set to the current global fee growth (because all historical fees happened "below"). When the current price is below the tick being initialized, feeGrowthOutside starts at zero.
initialized (bool): Whether any position references this tick.
Tick.update(): Adding and Removing Liquidity
When an LP creates or modifies a position, Tick.update() is called for both the lower and upper tick of their range. The function:
- Reads the current liquidityGross
- Adds or subtracts the liquidity delta
- Detects if the tick was "flipped" (transitioned between zero and non-zero)
- On first initialization, sets feeGrowthOutside based on the current tick position
- Updates liquidityNet with the correct sign (add for lower tick, subtract for upper tick)
The upper boolean parameter controls the sign. This is easy to get wrong. If you mix up the signs, swaps will add liquidity where they should remove it, breaking the pool entirely.
Tick.cross(): Price Crossing a Boundary
During a swap, when the computed price reaches an initialized tick, Tick.cross() is called. This function flips the feeGrowthOutside values:
feeGrowthOutside = feeGrowthGlobal - feeGrowthOutside
This works because of how "outside" is defined. Before crossing, "outside" means one direction. After crossing, the price is on the other side, so "outside" means the opposite direction. The subtraction from the global value correctly flips the perspective.
The function returns the tick's liquidityNet, which the swap loop uses to update the pool's active liquidity.
The Position Library
A position is uniquely identified by three values: the owner's address, the lower tick, and the upper tick. The storage key is computed as:
bytes32 key = keccak256(abi.encodePacked(owner, tickLower, tickUpper));
This means if the same address adds liquidity to the same range twice, the liquidity is merged into one position. There is no separate tracking of "add #1" and "add #2."
Each Position.Info struct stores:
- liquidity: How much liquidity this position holds
- feeGrowthInside0LastX128, feeGrowthInside1LastX128: Snapshots of fee growth inside the position's range at the time of last update
- tokensOwed0, tokensOwed1: Uncollected fees that the position owner can withdraw
Position.update(): Fee Accounting
When a position is modified (liquidity added or removed), Position.update() calculates the fees earned since the last update:
feesEarned = (currentFeeGrowthInside - lastFeeGrowthInside) * liquidity
The difference between the current fee growth inside the range and the stored snapshot, multiplied by the position's liquidity, gives the exact fees earned. These are added to tokensOwed. Then the snapshot is updated to the current value, resetting the accumulator for the next period.
Your Task
Implement both libraries in the starter code. The TODO comments guide you through each step. Pay special attention to:
- The sign convention in
Tick.update()for liquidityNet - The initialization logic for feeGrowthOutside (depends on whether current tick is above or below)
- The fee calculation in
Position.update()using the snapshot pattern - The keccak256 key computation in
Position.computeKey()