CenturionDEX
Launch App

Oracle

Last modified:

Every v3 pool doubles as an on-chain oracle, recording historical price and liquidity data that external contracts can query. This eliminates the need for off-chain feeds in many use cases.

Observation storage

Historical data lives in a ring buffer of observations. A freshly created pool tracks a single observation, overwriting it each block. Any party may call increaseObservationCardinalityNext (up to a maximum of 65,535 slots) to extend the buffer — expanding the available history to roughly nine days or more.

Keeping history inside the pool contract reduces integration complexity: consuming contracts need not maintain their own storage, and the risk of logic errors from external caching is lower. The large maximum buffer also makes price manipulation significantly more expensive, because an attacker must sustain a distorted price across an arbitrarily long observation window.

Observations

Each Observation is a struct:

struct Observation {
    uint32 blockTimestamp;
    int56 tickCumulative;          // tick × elapsed seconds since pool init
    uint160 secondsPerLiquidityCumulativeX128; // seconds / max(1, liquidity)
    bool initialized;
}

Raw observations can be read via observations, but the recommended entry point is observe:

function observe(uint32[] calldata secondsAgos)
    external
    view
    returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s);

The caller passes an array of secondsAgo values. For timestamps that fall between recorded observations, the contract interpolates — removing the need to align queries with block boundaries. Calling observe with secondsAgo = 0 returns the most recently written observation, which may be as old as the start of the current block.

Tick accumulator

The tick accumulator stores the cumulative sum of the active tick, growing by the current tick's value every second. To compute an arithmetic mean tick over an interval, query two observations, take the delta, and divide by the elapsed time:

mean_tick = (tickCumulative_later - tickCumulative_earlier) / Δt

An arithmetic mean tick corresponds to a geometric mean price — a property that makes the resulting TWAP inherently more resistant to outlier manipulation than an arithmetic mean price.

Solidity implementation

Computing a 10-minute TWAP:

uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = 600; // 10 minutes ago
secondsAgos[1] = 0;   // now
 
(int56[] memory tickCumulatives, ) = pool.observe(secondsAgos);
 
int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0];
int24 arithmeticMeanTick = int24(tickCumulativesDelta / int56(int32(600)));
 
// Convert tick → price (`token0` in terms of `token1`)
// price = 1.0001 ^ tick
uint256 price = OracleLibrary.getQuoteAtTick(
    arithmeticMeanTick,
    1e18,           // baseAmount (1 token0)
    token0,
    token1
);

Key considerations:

See OracleLibrary for the full reference implementation.

TWAP manipulation resistance

TWAPs are expensive to attack. The attacker must hold the spot price at an artificial level for the entire window to shift the average meaningfully.

Cost to manipulate

Moving the spot price by a factor d (e.g., 1.10 for a 10 % manipulation) in a pool with liquidity L requires approximately:

capital_required ≈ L * |sqrt(d) - 1| * sqrt(P_current)

Example: Shifting a CTN/USDC pool (L = 1,000,000, current price $2,000) by 10 %:

capital = 1,000,000 * |sqrt(1.10) - 1| * sqrt(2000)
        = 1,000,000 * 0.04881 * 44.721
        ≈ $2,183,000

This only displaces the spot price. To move the TWAP by the same amount, the attacker must sustain the distortion for the full window while absorbing arbitrage losses.

Why longer windows are safer

A manipulation lasting t seconds within a W-second window shifts the TWAP by:

TWAP_shift = (manipulated_tick - true_tick) * t / W

Against a 30-minute (1,800 s) window, a 60-second manipulation produces:

Effective TWAP shift = 10 % * 60 / 1800 = 0.33 %

Protocols consuming on-chain prices should therefore use windows of at least 10–30 minutes. Single-block windows remain vulnerable to within-block manipulation.

Geometric vs arithmetic mean

The tick accumulator yields an arithmetic mean tick, which maps to a geometric mean price:

geometric_mean_price = 1.0001 ^ arithmetic_mean_tick

The geometric mean penalises outliers far more than an arithmetic mean:

5-block pricesArithmetic meanGeometric mean
2000, 2000, 2000, 2000, 2000$2,000$2,000
2000, 2000, 2000, 2000, 10,000$3,600$2,639
2000, 2000, 2000, 2000, 100,000$21,600$4,574

Even a 50× single-block spike barely moves the geometric mean.

Liquidity accumulator

The liquidity accumulator records seconds / in-range liquidity, growing monotonically. To derive the harmonic mean liquidity over an interval, take two observations, compute the delta, and divide the elapsed time by that delta. The whitepaper covers the full derivation.

Use the in-range liquidity accumulator with care. The active tick and the in-range liquidity can be entirely uncorrelated; taking the arithmetic mean tick and harmonic mean liquidity over the same interval may mischaracterise a pool relative to another.

Deriving price from a tick

"Active tick" refers to the lower tick boundary closest to the current price.

At pool creation, each token is assigned as token0 or token1 by contract-address ordering. The assignment is arbitrary — it exists solely to establish a consistent numeraire within the pool contract.

Because the pool always expresses token0 in terms of token1, the current tick maps directly to a price:

p(i) = 1.0001 ^ i

Example: WCTN price in a WCTN/USDC pool

Assume WCTN is token0 and USDC is token1. An oracle reading returns tickCumulative values of [70,000, 1,070,000] with 10 seconds between observations.

mean_tick = (1,070,000 − 70,000) / 10 = 100,000
 
price = 1.0001 ^ 100,000 ≈ 22,015.5 USDC / WCTN

Ticks are signed integers. When token0 is worth less than token1, tickCumulative is negative and the derived price is less than 1.