Section 6 of 9

Build
+15 Shields

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:

  1. Reads the current liquidityGross
  2. Adds or subtracts the liquidity delta
  3. Detects if the tick was "flipped" (transitioned between zero and non-zero)
  4. On first initialization, sets feeGrowthOutside based on the current tick position
  5. 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:

  1. The sign convention in Tick.update() for liquidityNet
  2. The initialization logic for feeGrowthOutside (depends on whether current tick is above or below)
  3. The fee calculation in Position.update() using the snapshot pattern
  4. The keccak256 key computation in Position.computeKey()

Your Code

Solution.sol
Solidity
Loading editor...

Requirements

Tick.Info struct has liquidityGross field
Tick.Info struct has liquidityNet field
Tick.Info struct has feeGrowthOutside fields
Tick.Info struct has initialized flag
Tick.update modifies liquidityGross
Tick flipped detection logic
Tick.update handles liquidityNet sign convention
Tick.cross flips feeGrowthOutside
Position.Info struct has liquidity field
Position.Info struct has fee growth snapshot fields
Position.Info struct has tokensOwed fields
Position key uses keccak256 with owner and ticks
Position.update calculates owed fees from fee growth delta