Skip to content

Price Oracle

Uniswap V2’s price oracle mechanism is designed to provide highly decentralized, manipulation-resistant on-chain price feeds by shifting from spot price lookups to Time-Weighted Average Prices (TWAP).

Uniswap V1 normal AMM behavior: if you buy a lot from a thin pool, the reserve ratio changes and the quoted spot price moves sharply.

The problem appears when another contract treats that instantly movable spot price as a reliable oracle for collateral valuation, borrowing limits, minting, or liquidation decisions.

The victim is the protocol that trusted the manipulated price, not the Uniswap V1 pool simply for existing and repricing after a big trade.

Attack pattern: manipulate Uniswap price (especially in thin markets), call the victim contract while the price is distorted, then profit from the victim’s bad accounting.

REP/ETH Oracle Manipulation (from uniswapV2 white-paper)

StepActionProtocol TouchedKey NumbersPair Price (REP/ETH)Why It Matters
1Check REP/ETH spot priceUniswap V1 + KyberPool holds 8,856.10 REP0.057621 ETH per REPThis is the honest baseline price before any manipulation
2Buy 7,970.49 REP from poolUniswap V17,970.49 REP bought (~90% of pool liquidity)Price starts rising sharplyDraining 90% of REP reserves forces the constant-product formula to reprice drastically
3Check REP/ETH spot price againUniswap V1 + KyberSlippage-adjusted quote = 5.486688 ETH for 10 REP5.656380 ETH per REP (manipulated)Price is now ~98.2x inflated vs. step 1 — the oracle sees a fake market
4Deposit REP as collateralbZx (lending pool)collateral_required = 702.265284; interest_required = 205.4332135.656380 ETH per REP (manipulated)bZx reads the Kyber/Uniswap price and accepts REP as if it were worth 98x more than reality
5Borrow ETH against REP collateralbZx (lending pool)Loan sized based on inflated collateral value5.656380 ETH per REP (manipulated)Protocol issues far more ETH than REP is truly worth — now undercollateralized
6Unwind: sell REP back into poolUniswap V1REP returned to pool, reserves normalize~0.057621 ETH per REP (back to normal)The manipulated price only needed to exist for the duration of steps 3–5
7Pocket the profitFinal profit = 2,425.71 ETHAttacker borrowed real ETH against fake collateral value and keeps the difference

V2 mitigates this by

  • Uniswap v2 measures the price before the first trade of each block, which is effectively the previous block’s closing price.
    • This makes one-transaction price manipulation much less effective against the oracle.
      • To manipulate the measured price, two things must happen:
        • The attacker executes a bad trade near the end of one block, taking on slippage and fees.
        • the first state-changing interaction with the pair in the next block updates the oracle price using the manipulated carried-over reserves carried over from the previous block.
          • The attacker would prefer to commit the first tx in the next block, which is highly unlikely unless they control ordering or consecutive blocks,
          • During that time, arbitrageurs can trade against the mispricing.
      • (Optional) The attacker may then exploit a victim protocol.
      • (Optional) The attacker may then unwind / recovery trade
      • (Optional) During that extended time, arbitrageurs can trade against the mispricing.
  • Shifting from spot price lookups to Time-Weighted Average Prices (TWAP)
    • Smooths short-lived spikes, so a brief manipulation usually affects the average much less than it affects the instantaneous spot price.

Let’s see how it achieves those defenses

// contracts/uniswap-v2/v2-core/contracts/UniswapV2Pair.sol:22:24
uint112 private reserve0; // uses single storage slot, accessible via getReserves
uint112 private reserve1; // uses single storage slot, accessible via getReserves
uint32 private blockTimestampLast; // uses single storage slot, accessible via getReserves
// contracts/uniswap-v2/v2-core/contracts/UniswapV2Pair.sol:73:86
function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {
require(balance0 <= uint112(-1) && balance1 <= uint112(-1), 'UniswapV2: OVERFLOW');
uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired
if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
// * never overflows, and + overflow is desired
price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
}
reserve0 = uint112(balance0);
reserve1 = uint112(balance1);
blockTimestampLast = blockTimestamp;
emit Sync(reserve0, reserve1);
}
  • Every time an interaction (mint, swap, burn) occurs _update() is called
  • Contract updates its internal accumulator on the first interaction in block N+1N+1
    • changes the reserves immediately on block NN,
    • price is added into the oracle accumulator in block N+1N+1 based on reserves on block NN
    • block.timestamp increases when block increases
    • it updates its price only when block.timestamp increases
    • blockTimestamp stores a cropped version of block.timestamp (uint256), truncated to uint32 for storage packing efficiency.
      • 232=4,294,967,2962^{32} = 4,294,967,296 seconds after the Unix epoch (January 1, 1970)
      • 4,294,967,2964,294,967,296 seconds after epoch = February 7, 2106, at 06:28:16 UTC
  • Contract does not store historical prices
  • Contract tracks a single cumulative sum of the price for every second in the entire history of the contract
  • Each pair tracks price0CumulativeLast and price1CumulativeLast
  • There might be cases when a block did not have any interaction; therefore, the price is not updated

Reference Fixed Point Binary Numbers

Uses UQ112.112 to store TWAP

PropertyValue
Total bits224
Min value00
Max value21122112+5.19×10332^{112} − 2^{−112} \approx +5.19×10^{33}
Resolution21121.926×10342^{−112} \approx 1.926×10^{-34}

This is an enormous format

  • it gives you a range on the scale of 5×10335×10^{33}
  • while simultaneously resolving differences as small as 103410^{-34}

For reserves, Uniswap stores reserve0 and reserve1 as uint112 plus blockTimestampLast as uint32 in one packed slot.

TWAP=P1T1+P2T2++PnTni=1nTi\text{TWAP} = \frac{P_1T_1 + P_2T_2 + \cdots + P_nT_n} {\sum_{i=1}^{n} T_i}

priceXCumulativeLast=P1T1+P2T2+P3T3+P4T4+P5T5+P6T6\text{priceXCumulativeLast} = P_1T_1 + P_2T_2 + P_3T_3 + P_4T_4 + P_5T_5 + P_6T_6

TiT_i is a duration from block N to the first interaction from block N+1 (timeElapsed)

Retrieving TWAP for block M formula:

NN = prior block, MM = target block

Δprice0Cumulative=price0CumulativeLastMprice0CumulativeLastN\Delta\text{price0Cumulative} = \text{price0CumulativeLast}_M - \text{price0CumulativeLast}_N

timeElapsed=(blockTimestampLastMmod232)(blockTimestampLastNmod232)\text{timeElapsed} = (\text{blockTimestampLast}_M \mod 2^{32}) - (\text{blockTimestampLast}_N \mod 2^{32})

TWAP0=Δprice0CumulativetimeElapsed×2112\text{TWAP}_0 = \frac{\Delta\text{price0Cumulative}}{\text{timeElapsed}} \times 2^{112}

Notes:

  • MN180M-N \approx 180 blocks (\sim30min): gives manipulation-resistant average
  • mod232\mod 2^{32}: Modulo handles Solidity’s 32-bit timestamp wrapping
  • 21122^{112}: Decoding converts fixed-point to decimal