Building a Uniswap V3 TWAP Oracle: The Math and the Footguns

Part 3 of “Oracles in Practice” — a hands-on series for smart-contract engineers.

With Chainlink the enemy was staleness. With an on-chain TWAP the enemy flips to manipulation — and the whole design is about making a single-block price shove economically pointless. Here’s how a Uniswap V3 TWAP oracle actually works, and the three footguns that turn a working consult() into an exploitable one.

Why time-weighting defeats flash loans

An AMM’s spot price is whatever the last trade left it at, so an attacker with a flash loan can move it violently within one transaction and exploit your read. A time-weighted average price neutralizes this: to move a 30-minute average, you’d have to hold the price distorted across many blocks, bleeding money to arbitrage the entire time. A one-block attack becomes a sustained, expensive siege.

Uniswap V3 makes this cheap to read because the pool stores the history for you. Every pool keeps an array of observations, each holding a tick accumulator — the running sum of the pool’s tick, one per second, since inception. Give it two points in time and the average tick between them falls out of a subtraction.

The core: consult()

You ask the pool for the accumulator now and secondsAgo in the past via observe(), then divide the delta by the window:

function consult(uint32 secondsAgo) public view returns (int24 arithmeticMeanTick) {
    if (secondsAgo < minWindow) revert WindowTooShort(secondsAgo, minWindow);

    (, , , uint16 cardinality, , , ) = pool.slot0();
    if (cardinality < minCardinality) revert InsufficientCardinality(cardinality, minCardinality);

    uint32[] memory secondsAgos = new uint32[](2);
    secondsAgos[0] = secondsAgo;   // t - secondsAgo
    secondsAgos[1] = 0;            // now
    (int56[] memory tickCumulatives, ) = pool.observe(secondsAgos);

    int56 delta = tickCumulatives[1] - tickCumulatives[0];
    int56 window = int56(uint56(secondsAgo));
    arithmeticMeanTick = int24(delta / window);
    // round toward negative infinity
    if (delta < 0 && (delta % window != 0)) arithmeticMeanTick--;
}

Three things in that function are the difference between correct and subtly broken.

Footgun 1 — Rounding. Solidity integer division truncates toward zero. For a negative delta that doesn’t divide evenly, truncation rounds up (toward zero), biasing the derived price upward. Uniswap’s own OracleLibrary rounds toward negative infinity, and so must you — hence the delta < 0 && delta % window != 0 correction. Skip it and your oracle systematically over-quotes on negative ticks.

Footgun 2 — Cardinality. A freshly deployed pool stores exactly one observation and overwrites it every block. observe() for any real window will revert until someone has grown the pool’s ring buffer via increaseObservationCardinalityNext. If your oracle doesn’t check slot0().observationCardinality, it works in your fork tests and reverts (or worse, silently misbehaves near the edge) against a young pool in production. Check it explicitly and fail with a legible error.

Footgun 3 — Window length. Short windows are cheap to manipulate, and there’s a subtler post-Merge problem: with deterministic proposer scheduling, a single validator can sometimes build every block inside a very short window, making even the “average” attacker-controlled. Audit guidance is a minimum window on the order of 30 minutes for low-to-mid liquidity pools. Enforce minWindow; don’t let a caller pass secondsAgo = 60.

From tick to price

consult() returns an average tick. To get a usable quote you convert it with the canonical Uniswap helpers — TickMath.getSqrtRatioAtTick then getQuoteAtTick (which uses a 512-bit FullMath.mulDiv to avoid overflow):

function getTwapQuote(uint32 secondsAgo, uint128 baseAmount)
    external view returns (uint256 quoteAmount, int24 meanTick)
{
    meanTick = consult(secondsAgo);
    quoteAmount = OracleMath.getQuoteAtTick(meanTick, baseAmount, baseToken, quoteToken);
}

Two sanity checks worth writing tests for, because the tick math is easy to get subtly wrong: tick 0 must produce a 1:1 quote (it exercises getSqrtRatioAtTick(0) == 2^96 and getQuoteAtTick together), and a known tick like 80067 should map to roughly 1.0001^80067 ≈ 3000. I verified both against a mock pool with preset accumulators on a local EVM — mean-tick computation, negative-infinity rounding, the cardinality and window guards, and the tick→price conversion all pass.

A note on Uniswap V4

If you’re targeting V4, the ground has shifted: V4 pools have no built-in oracle. Observation storage was removed from the core, and oracle behavior moves into optional hooks. The notable pattern is the truncated oracle hook, which caps how far the recorded tick can move in a single block — the same manipulation-resistance goal as TWAP, enforced per-block rather than by averaging after the fact. On V4 you deploy or select an oracle hook; you don’t read the pool’s accumulator directly, because there isn’t one.

When to reach for TWAP

Use it when you want a purely on-chain source with no external operator, for an asset with a deep V3 pool. Its weaknesses are the mirror of Chainlink’s strengths: it only knows tokens that trade in that pool, thin liquidity makes even a long window manipulable, and it can’t price real-world assets that don’t trade on-chain. The mature move is to run it alongside a push feed and revert on divergence — each covers the other’s failure mode.

Next, the third pattern: when there’s no market to average and no public feed, you fall back to a quorum of signers — and increasingly, that’s how AI outputs get onto the chain.


Tags: Uniswap, DeFi, Solidity, Oracles, Ethereum

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *