Found Academy useful? A $5 donation by May 14 helps us ship more, faster. Every donor counts (QF matching).

Donate

Section 4 of 16

Build
+15 Lynx

Pair: Reserve Updates and TWAP Oracle

Key takeaway: Uniswap V2's TWAP oracle accumulates price0CumulativeLast and price1CumulativeLast on every reserve update, encoded in a custom UQ112x112 fixed-point format (224 bits, 112 integer + 112 fractional). The _update() function holds two opposite arithmetic regimes in one body: reserve writes must revert on overflow (the uint112 cast is guarded by require), while accumulator increments must allow overflow so modular subtraction over an oracle window produces the correct average price.

What You Are Building

The _update() function is called after every mint, burn, and swap. It does two things: store the new reserves and accumulate prices for the TWAP (time-weighted average price) oracle. This is one of the most elegant pieces of Uniswap V2. A few lines of code give the entire DeFi ecosystem a manipulation-resistant price feed, and the design decisions behind each line are worth understanding deeply.

Why TWAP Exists

The spot price of a Uniswap V2 pair is simply reserve1 / reserve0. Anyone can read it in one call. So why build an oracle at all?

Because spot price is trivially manipulable. An attacker can take a flash loan, dump a massive amount of token0 into the pool, read the now distorted price, use that price to exploit a lending protocol or options contract, then unwind the position. All in a single transaction. The spot price moved, the victim protocol acted on it, and the attacker profited. This is not theoretical. Flash loan price manipulation attacks have drained hundreds of millions of dollars from DeFi protocols that relied on spot price oracles.

TWAP resists this because it measures the average price over a window of time, not a single instant. To manipulate a 30 minute TWAP, an attacker would need to sustain the distorted price across multiple blocks for 30 minutes. That means locking up capital against arbitrageurs who would immediately correct the price. The cost of manipulation scales linearly with the time window and the liquidity depth, making it economically infeasible for well chosen windows.

The UQ112x112 Fixed Point Library

Solidity has no floating point numbers. Division truncates: 3 / 2 = 1, not 1.5. For a price oracle, truncation is unacceptable. The price of ETH in USDC might be 2000.437, and you need that fractional part.

UQ112x112 solves this with fixed point encoding. The format uses 112 bits for the integer part and 112 bits for the fractional part, totaling 224 bits stored in a uint224.

The encode function takes a uint112 and multiplies it by 2^112:

function encode(uint112 y) internal pure returns (uint224) {
    return uint224(y) * uint224(Q112); // Q112 = 2**112
}

This shifts the value left by 112 bits, leaving the lower 112 bits as zero (representing a fractional part of zero). The number 5 becomes 5 * 2^112, which in binary is 101 followed by 112 zeros.

The uqdiv function divides an encoded value by a raw uint112:

function uqdiv(uint224 x, uint112 y) internal pure returns (uint224) {
    return x / uint224(y);
}

Because x is already scaled up by 2^112, dividing by a raw integer preserves the fractional precision in the result. For example, encode(3) / 2 gives you a uint224 representing 1.5, with the .5 encoded in the lower 112 bits.

When the oracle computes encode(reserve1).uqdiv(reserve0), it gets reserve1 / reserve0 with 112 bits of fractional precision. That is approximately 33 decimal digits of precision after the decimal point. Far more than any practical application needs, but the fixed point format comes essentially free since the EVM already operates on 256 bit words.

Why Overflow Is Intentional

The cumulative price accumulators (price0CumulativeLast and price1CumulativeLast) are uint256 values that grow continuously with every block. Eventually, they will overflow. Uniswap V2's design embraces this rather than fighting it.

The key insight: oracle consumers never read the cumulative value directly. They always compute the difference between two snapshots. Thanks to modular arithmetic, (snapshot2 - snapshot1) produces the correct result even if snapshot2 has overflowed past the uint256 maximum and wrapped around to a small number. Subtraction in modular arithmetic cancels out the wraparound.

Consider a simplified example with uint8 (max 255). If the cumulative value is 250 at time 1 and overflows to 10 at time 2, the difference is 10 - 250 = 16 in modular uint8 arithmetic, which is exactly the 16 units that were accumulated. The same principle applies to uint256, where the overflow period is so astronomically long that no observation window could span it.

In Solidity 0.8+, arithmetic overflow reverts by default. The original Uniswap V2 was written in Solidity 0.5 where overflow was silently wrapping. In a modernized version, you must wrap the price accumulation in an unchecked block to preserve this intentional overflow behavior. Without the unchecked block, the contract would revert once the cumulative values get large enough, effectively bricking the oracle.

Critically, the reserve overflow check must remain checked. The require(balance <= type(uint112).max) uses standard arithmetic and must revert on overflow. These two uses of arithmetic in the same function have opposite requirements: reserves must never overflow (checked), prices must overflow freely (unchecked).

The Reserve Overflow Guard

The first line of _update is:

require(balance0 <= type(uint112).max && balance1 <= type(uint112).max, 'UniswapV2: OVERFLOW');

This prevents a subtle but critical bug. The reserves are stored as uint112. If a token's balance exceeds 2^112 - 1 and you cast it to uint112, the value silently truncates. Reserve0 might become a tiny number while the contract actually holds a massive balance. The constant product invariant (reserve0 * reserve1 = k) would be calculated against the truncated reserves, meaning swaps would use a completely wrong price. Attackers could drain the pool by exploiting the mismatch between actual balances and stored reserves.

In practice, hitting the uint112 cap requires a token balance above approximately 5.19 * 10^33. For 18 decimal tokens, that is about 5.19 quadrillion whole tokens. No legitimate token approaches this. The check exists as a safety net for pathological tokens or deliberate attacks.

The _update() Function Step by Step

Here is the complete flow:

  1. Reserve overflow check: Require that both new balances fit in uint112. Revert if either exceeds the maximum. This must use checked arithmetic.

  2. Timestamp truncation: Compute uint32 blockTimestamp = uint32(block.timestamp % 2**32). This truncates the Unix timestamp to fit in the 32 bit slot alongside the two reserves. Compute timeElapsed = blockTimestamp - blockTimestampLast. Because both values are uint32 and subtraction wraps in the same modular space, this works correctly even when blockTimestamp has wrapped past 2^32 (around the year 2106).

  3. Price accumulation (inside unchecked): If timeElapsed > 0 AND both reserves are nonzero, accumulate both price directions. The condition on nonzero reserves prevents division by zero. The condition on elapsed time means accumulation only happens once per block (multiple swaps in the same block share the same timestamp).

unchecked {
    price0CumulativeLast += uint256(UQ112x112.encode(reserve1).uqdiv(reserve0)) * timeElapsed;
    price1CumulativeLast += uint256(UQ112x112.encode(reserve0).uqdiv(reserve1)) * timeElapsed;
}
  1. Store reserves and timestamp: Write the new uint112 reserves and the uint32 timestamp to storage. Because they share one slot, this is a single SSTORE.

  2. Emit Sync: The Sync(reserve0, reserve1) event tells off chain indexers and subgraphs the current reserves. This is essential for frontends displaying real time prices.

How External Contracts Read the TWAP

An external oracle contract snapshots price0CumulativeLast and block.timestamp at two different times. Call them snapshot_1 and snapshot_2:

averagePrice0 = (price0Cumulative_t2 - price0Cumulative_t1) / (t2 - t1)

The result is in UQ112x112 format and needs to be decoded (divided by 2^112) to get a human readable price. Longer windows smooth out volatility and resist manipulation. Shorter windows track the market more closely but are easier to attack. Most protocols use 10 to 30 minute windows as a practical balance.

Your Task

The starter code gives you the UQ112x112 library and the _update() function signature. You need to write the complete function body. Pay careful attention to the unchecked block around price accumulation, the timestamp truncation, and the require statement for overflow protection.

Your Code

Solution.sol
Solidity
Loading editor...

Requirements

Write your implementation, then click Run Tests. Tests execute on the server.