Section 8 of 9
Position Manager + Oracle
Position Manager + Oracle
What You Are Building
Two systems that sit on top of the core pool: a Position Manager that handles the mint/burn/collect lifecycle for LP positions, and an Oracle that stores historical price observations for TWAP (time-weighted average price) queries. Together, these make the protocol usable by both LPs and external contracts that need reliable price feeds.
Position Manager: The LP Interface
The raw pool contract handles math and state, but it expects callers to manage token transfers through callbacks. The Position Manager wraps this into a clean interface. In Uniswap V3, this is the NonfungiblePositionManager contract, which mints an ERC-721 NFT for each position.
Your simplified version tracks positions with an ID and a mapping instead of full ERC-721 implementation, but the lifecycle is the same.
mint(): Creating a Position
When an LP calls mint(tickLower, tickUpper, amount), several things happen:
-
Validate the range. tickLower must be less than tickUpper. Both must be divisible by the pool's tick spacing (enforced in production, simplified here).
-
Compute the position key.
keccak256(abi.encodePacked(msg.sender, tickLower, tickUpper))determines whether this is a new position or an addition to an existing one. Same owner, same range means same position. -
Calculate required token amounts. This depends on the relationship between the current price and the position's range:
- If the current tick is below tickLower, the position is entirely in token0 (waiting for the price to rise into range). Only token0 is required.
- If the current tick is at or above tickUpper, the position is entirely in token1 (the price has moved past the range). Only token1 is required.
- If the current tick is within the range, the position needs both tokens in proportions determined by where the current price sits within the range.
-
Update tick state. Call
Tick.update()on both tickLower (withupper = false) and tickUpper (withupper = true). This registers the liquidity at both boundaries. -
Update pool liquidity. If the current tick falls within the new position's range, add the liquidity amount to the pool's active liquidity.
burn(): Removing Liquidity
Burning is the reverse of minting. The LP specifies a position ID and an amount of liquidity to remove. The function:
- Verifies the caller owns the position
- Calculates the token amounts owed based on the same price/range logic as mint
- Decreases the position's liquidity
- Adds the calculated amounts to
tokensOwed0andtokensOwed1
Importantly, burn does not transfer tokens. It only updates accounting. The actual transfer happens in collect(). This two-step pattern (burn then collect) prevents reentrancy issues and lets LPs batch operations.
collect(): Withdrawing Tokens and Fees
collect() transfers accumulated tokens to the position owner. This includes both tokens from burned liquidity and earned fees. The function:
- Reads
tokensOwed0andtokensOwed1from the position - Transfers those amounts to the owner
- Resets both tokensOwed fields to zero
In production, the amounts are capped to the caller's request (partial collection is allowed). The starter code simplifies this to full withdrawal.
Oracle: On-Chain Price History
The Oracle stores a ring buffer of price observations. Each Observation contains:
- blockTimestamp (uint32): When this observation was recorded
- tickCumulative (int56): The running sum of
tick * timeElapsed - initialized (bool): Whether this slot has been written to
How the Ring Buffer Works
The observations array has a fixed maximum size (65,535 slots in V3). A cardinality value tracks how many slots are currently active. An index tracks the most recent write position. New observations are written to (index + 1) % cardinality, overwriting the oldest data. This gives the oracle a finite history window.
Any account can increase the cardinality by calling a grow function (not in the starter code), paying gas to initialize new storage slots. More cardinality means longer price history at the cost of one-time storage gas.
Oracle.write(): Recording Observations
Called during every swap (and optionally during liquidity changes), write():
- Checks if the block timestamp has changed since the last observation. If not, returns early (only one observation per block).
- Computes the new tickCumulative:
lastTickCumulative + currentTick * timeDelta - Writes the observation to the next ring buffer slot
Oracle.observe(): Querying TWAP
External contracts call observe(secondsAgo) to get the time-weighted average tick over a past period. The math:
twapTick = (tickCumulative_now - tickCumulative_past) / timeElapsed
Production V3 uses binary search to find the closest observations to the requested timestamp. The starter code simplifies this to a linear scan. The returned tick can be converted to a price using price = 1.0001^tick.
TWAP oracles are manipulation resistant because an attacker would need to sustain an artificial price for the entire averaging window. A 30-minute TWAP requires 30 minutes of continuous manipulation, which is extremely expensive compared to a spot price that can be moved in a single transaction.
Your Task
Implement the Position Manager (mint, burn, collect) and the Oracle library (Observation struct, write, observe). The starter code has 19 TODO blocks. Start with the simpler Oracle functions, then build the Position Manager lifecycle. Pay attention to:
- The position key deduplication (same owner + same range = same position)
- Token amount calculation based on current tick vs. position range
- The two-step burn then collect pattern
- Ring buffer indexing with modular arithmetic in the Oracle