Section 9 of 10

Build
+15 Lynx

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:

  1. Validate the range. tickLower must be less than tickUpper. Both must be divisible by the pool's tick spacing (enforced in production, simplified here).

  2. 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.

  3. 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.
  4. Update tick state. Call Tick.update() on both tickLower (with upper = false) and tickUpper (with upper = true). This registers the liquidity at both boundaries.

  5. 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:

  1. Verifies the caller owns the position
  2. Calculates the token amounts owed based on the same price/range logic as mint
  3. Decreases the position's liquidity
  4. Adds the calculated amounts to tokensOwed0 and tokensOwed1

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:

  1. Reads tokensOwed0 and tokensOwed1 from the position
  2. Transfers those amounts to the owner
  3. 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():

  1. Checks if the block timestamp has changed since the last observation. If not, returns early (only one observation per block).
  2. Computes the new tickCumulative: lastTickCumulative + currentTick * timeDelta
  3. 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:

  1. The position key deduplication (same owner + same range = same position)
  2. Token amount calculation based on current tick vs. position range
  3. The two-step burn then collect pattern
  4. Ring buffer indexing with modular arithmetic in the Oracle

Your Code

Solution.sol
Solidity
Loading editor...

Requirements

Observation struct defined
Observation has tickCumulative
Oracle.write computes new tickCumulative
Oracle uses ring buffer indexing
observe function calculates TWAP
mint function exists
mint validates tick range
Position key computed from owner and ticks
burn function exists
burn validates ownership
burn updates tokensOwed
collect function exists
collect resets tokensOwed to zero
Position events emitted