TWAP Oracles For Auditors

·

5 min read

What is a TWAP?

A TWAP oracle is a Time-weighted average price oracle that calculates the average price of an asset over some predetermined period of time. If a user wants to know the price of ETH over 28 days then the TWAP will return the average price of ETH for 28 days. TWAPs are used primarily because spot prices on AMMs are not reliable since they can be easily manipulated with a flash loan.

Open source Implementations

There are a few open-sourced implementations that have been audited in which we can study to get a better idea of what a secure TWAP implementation may look like. This one from Compound is a great example.

    function fetchAnchorPrice(string memory symbol, TokenConfig memory config, uint conversionFactor) internal virtual returns (uint) {
        (uint nowCumulativePrice, uint oldCumulativePrice, uint oldTimestamp) = pokeWindowValues(config);

        // This should be impossible, but better safe than sorry
        require(block.timestamp > oldTimestamp, "now must come after before");
        uint timeElapsed = block.timestamp - oldTimestamp;

        // Calculate uniswap time-weighted average price
        // Underflow is a property of the accumulators: https://uniswap.org/audit.html#orgc9b3190
        FixedPoint.uq112x112 memory priceAverage = FixedPoint.uq112x112(uint224((nowCumulativePrice - oldCumulativePrice) / timeElapsed));
        uint rawUniswapPriceMantissa = priceAverage.decode112with18();
        uint unscaledPriceMantissa = mul(rawUniswapPriceMantissa, conversionFactor);
        uint anchorPrice;

        // Adjust rawUniswapPrice according to the units of the non-ETH asset
        // In the case of ETH, we would have to scale by 1e6 / USDC_UNITS, but since baseUnit2 is 1e6 (USDC), it cancels

        // In the case of non-ETH tokens
        // a. pokeWindowValues already handled uniswap reversed cases, so priceAverage will always be Token/ETH TWAP price.
        // b. conversionFactor = ETH price * 1e6
        // unscaledPriceMantissa = priceAverage(token/ETH TWAP price) * expScale * conversionFactor
        // so ->
        // anchorPrice = priceAverage * tokenBaseUnit / ethBaseUnit * ETH_price * 1e6
        //             = priceAverage * conversionFactor * tokenBaseUnit / ethBaseUnit
        //             = unscaledPriceMantissa / expScale * tokenBaseUnit / ethBaseUnit
        anchorPrice = mul(unscaledPriceMantissa, config.baseUnit) / ethBaseUnit / expScale;

        emit AnchorPriceUpdated(symbol, anchorPrice, oldTimestamp, block.timestamp);

        return anchorPrice;
    }

    /**
     * @dev Get time-weighted average prices for a token at the current timestamp.
     *  Update new and old observations of lagging window if period elapsed.
     */
    function pokeWindowValues(TokenConfig memory config) internal returns (uint, uint, uint) {
        bytes32 symbolHash = config.symbolHash;
        uint cumulativePrice = currentCumulativePrice(config);

        Observation memory newObservation = newObservations[symbolHash];

        // Update new and old observations if elapsed time is greater than or equal to anchor period
        uint timeElapsed = block.timestamp - newObservation.timestamp;
        if (timeElapsed >= anchorPeriod) {
            oldObservations[symbolHash].timestamp = newObservation.timestamp;
            oldObservations[symbolHash].acc = newObservation.acc;

            newObservations[symbolHash].timestamp = block.timestamp;
            newObservations[symbolHash].acc = cumulativePrice;
            emit UniswapWindowUpdated(config.symbolHash, newObservation.timestamp, block.timestamp, newObservation.acc, cumulativePrice);
        }
        return (cumulativePrice, oldObservations[symbolHash].acc, oldObservations[symbolHash].timestamp);
    }

You can see here where the TWAP updates the price feed but first checks to verify that the timeElapsed is greater than the anchorPeriod.
This allows enough blocks to occur before a new feed can be registered.

  if (timeElapsed >= anchorPeriod) {
            oldObservations[symbolHash].timestamp = newObservation.timestamp;
            oldObservations[symbolHash].acc = newObservation.acc;

            newObservations[symbolHash].timestamp = block.timestamp;
            newObservations[symbolHash].acc = cumulativePrice;
            emit UniswapWindowUpdated(config.symbolHash, newObservation.timestamp, block.timestamp, newObservation.acc, cumulativePrice);
        }
        return (cumulativePrice, oldObservations[symbolHash].acc, oldObservations[symbolHash].timestamp);

How easy is it to manipulate the price of a TWAP?

To calculate the Cost of an attack you can assume the following, moving the price 5% on a 1-hour TWAP is roughly equal to the amount an attacker would lose to arbitrage, flashloan, and swap fees for moving the price 5% every block for 1 hour.

To demonstrate this concept with numbers, let's assume the following scenario:

  1. The asset being traded has a current market price of $100.

  2. We want to move the price by 5% on a 1-hour TWAP (Time-Weighted Average Price).

  3. The total trading period is 1 hour, and there are 60 blocks (assuming a block is generated every minute) in this period.

  4. The cost of arbitrage and fees for moving the price 5% in each block is a fixed amount.

Now, let's calculate:

  1. A 5% increase in the market price of the asset ($100) means the price needs to move to $105.

  2. For each block, to achieve this 5% movement, traders would execute transactions that push the price towards $105.

  3. The cost incurred in each block due to arbitrage and fees is constant. Let’s assume it's $1000 per block.

  4. Therefore, for 60 blocks (1 hour), the total cost would be 60 blocks * $1000/block = $60,000.

This is why as liquidity increases in a pool it becomes harder to manipulate the price and a smaller anchorPeriod can be used. However, when liquidity is low a small anchorPeriod could make the price very vulnerable to flashloan leaving the protocol implementing it at risk.

*Note: these values don't reflect real-world values they are being used in this example to display a simplified version of price movement.

Common Issues Found with TWAP

TWAP_Interval is set as an immutable value

    /// @notice The minimum amount of time in seconds required for the old uniswap price accumulator to be replaced
    uint public immutable anchorPeriod;

The period in which a new price feed can be updated is important because it allows the owner of the contract to dynamically set it based on market conditions.

The anchorPeriod, is set as an immutable variable. It determines the update period for which TWAP oracles will allow a new price to be fetched. The inability to change this variable after deployment poses a risk.anchorPeriod should be changeable to allow the owner to update it based on market conditions and manipulation risks.

What Impact does this have?

The 'anchorPeriod' determines the frequency in which price updates can be made. Updating more frequently allows for more accurate prices. However, this can be easily manipulated with a flash loan. Longer periods can protect against this however, they sacrifice accuracy. If there is a black swan event, prices might not update as quickly leading to more risk.

How to mitigate this?

Introduce a dynamic setAnchorPeriod function, accessible only by the contract owner through onlyOwner access controls. This will provide more flexibility to adjust the anchorPeriod. When TVL is low and there is low liquidity, flashloans can be used to easily manipulate the price. So a longer

Create a setter function that allows the owner to update the anchorPeriod.

function setUpdatePeriod(uint256 newUpdatePeriod) external onlyOwner {
    require(newUpdatePeriod > minUpdatePeriod,"UpdatePeriod not met);
    _updatePeriod = newUpdatePeriod;
}