code-423n4 / 2024-04-panoptic-findings

7 stars 3 forks source link

Incorrect duration used for the TWAP calculation in `PanopticMath.twapFilter()` #268

Open c4-bot-4 opened 4 months ago

c4-bot-4 commented 4 months ago

Lines of code

https://github.com/code-423n4/2024-04-panoptic/blob/833312ebd600665b577fbd9c03ffa0daf250ed24/contracts/libraries/PanopticMath.sol#L241-L268

Vulnerability details

Proof of Concept

Take a look at https://github.com/code-423n4/2024-04-panoptic/blob/833312ebd600665b577fbd9c03ffa0daf250ed24/contracts/libraries/PanopticMath.sol#L241-L268

    function twapFilter(IUniswapV3Pool univ3pool, uint32 twapWindow) external view returns (int24) {
        uint32[] memory secondsAgos = new uint32[](20);

        int256[] memory twapMeasurement = new int256[](19);

        unchecked {
            // construct the time stots
            for (uint256 i = 0; i < 20; ++i) {
                secondsAgos[i] = uint32(((i + 1) * twapWindow) / 20);
            }

            // observe the tickCumulative at the 20 pre-defined time slots
            (int56[] memory tickCumulatives, ) = univ3pool.observe(secondsAgos);

            // compute the average tick per 30s window
            //@audit
            for (uint256 i = 0; i < 19; ++i) {
                twapMeasurement[i] = int24(
                    (tickCumulatives[i] - tickCumulatives[i + 1]) / int56(uint56(twapWindow / 20))
                );
            }

            // sort the tick measurements
            int256[] memory sortedTicks = Math.sort(twapMeasurement);

            // Get the median value
            return int24(sortedTicks[10]);
        }
    }

The twapFilter function in the provided code snippet is designed to compute the Time-Weighted Average Price (TWAP) of a Uniswap V3 pool using data from its oracle. The TWAP is calculated by observing the average price over a series of time intervals and defining the TWAP as the median of those averages. However there are significant discrepancies between the documented behaviour and the actual implementation.

The documentation states that the TWAP should be computed over a time window specified by the twapWindow parameter, Note that protocol has heavily stated that this would be a 10 minutes window, from subtly hinting it in this instance in the readMe to the current hardcoded configuration of TWAP_WINDOW here https://github.com/code-423n4/2024-04-panoptic/blob/833312ebd600665b577fbd9c03ffa0daf250ed24/contracts/PanopticPool.sol#L125-L129

    /// @dev The window to calculate the TWAP used for solvency checks
    /// Currently calculated by dividing this value into 20 periods, averaging them together, then taking the median
    /// May be configurable on a pool-by-pool basis in the future, but hardcoded for now
    uint32 internal constant TWAP_WINDOW = 600;

Currently PanopticMath.twapFilter() is only queried via the PanopticPool's getUniV3TWAP() whose implementation is below

    function getUniV3TWAP() internal view returns (int24 twapTick) {
        twapTick = PanopticMath.twapFilter(s_univ3pool, TWAP_WINDOW);
    }

This obviously means that all instances where this price getter (getUniV3TWAP()) is used has a hardcoded TWAP_WINDOW attached to it, i.e in both liquidations and whenever force exercising a single position.

Now back to the implementation in twapFilter() with the average price observed over a series of time intervals. The intervals are supposed to be evenly spaced within the specified time window, starting from 0 seconds and ending at the duration of the TWAP window. However, the implementation starts the intervals at 30 seconds, which is inconsistent with the documentation and the expected behaviour of a TWAP calculation. The current implementation uses the formula secondsAgos[i] = uint32(((i + 1) * twapWindow) / 20);and loops through 0 <= i < 20 to calculate the time intervals, running a minimalistic python script POC we can see that this formula, is flawed, run the script below on replit.


def construct_time_slots(twapWindow=600, numSlots=20):
  secondsAgos = []
  for i in range(numSlots):
      secondsAgo = ((i + 1) * twapWindow) // 20
      secondsAgos.append(secondsAgo)
  return secondsAgos

twapWindow = 600
secondsAgos = construct_time_slots(twapWindow)
print(secondsAgos)

We get the result

[30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330, 360, 390, 420, 450, 480, 510, 540, 570, 600]

The python POC above shows that This formula results the first interval starting at 30 seconds, which is incorrect, cause this way we get the first secondsAgos to be 30 and the last secondsAgos to be 600, making the real duration of the TWAP to be 570 seconds, instead of the expected 600 seconds.

Now, according to the documentation it's only right for the first interval to start at 0 seconds, and the last interval should end at the duration of the TWAP window (600 seconds). Would be key to note that this is the right implementation as is being done in Uniswap, though here we have only two entries for the secondsAgos[] array, where secondsAgos[0] is the secondsAgo argument passed into the consult() function and and secondsAgos[1] = 0, to ensure that the real duration used is (secondsAgo - 0 = senodsAgo)... link to Uniswap's original implementation.

The correct formula should instead be secondsAgos[i] = uint32((i * twapWindow) / 20);, which ensures that the intervals start at 0 seconds and end at the specified duration, and then the secondsAgos also needs to be extended to 21 elements to include the secondsAgos[0] = 0 this way we start calculating the twap from 0 seconds up until 600 seconds using a 30 seconds interval.

Impact

The incorrect implementation of the TWAP calculation in the twapFilter function has several implications:

Recommended Mitigation Steps

Apply the suggestions from Proof of Concept

First modify the calculation of the secondsAgos array to start at 0 seconds and end at the duration of the TWAP window. This can be achieved by using the correct formula secondsAgos[i] = uint32((i * twapWindow) / 20);.

Then ensure that the secondsAgos array is correctly sized to match the number of intervals being calculated. This would involve adjusting the loop that populates the array to use the correct number of elements, from testing, a 21 elements array should be enough, so the below can be considered a pseudo fix

    function twapFilter(IUniswapV3Pool univ3pool, uint32 twapWindow) external view returns (int24) {
# (...)
-       uint32[] memory secondsAgos = new uint32[](20);
+        uint32[] memory secondsAgos = new uint32[](21);
# (...)
-            for (uint256 i = 0; i < 20; ++i) {
+            for (uint256 i = 0; i < 21; ++i) {
-                secondsAgos[i] = uint32(((i + 1) * twapWindow) / 20);
+                secondsAgos[i] = uint32((i  * twapWindow) / 20);
            }
# (...)
}

Assessed type

Oracle

c4-judge commented 4 months ago

Picodes changed the severity to QA (Quality Assurance)

c4-judge commented 4 months ago

This previously downgraded issue has been upgraded by Picodes

c4-judge commented 4 months ago

Picodes marked the issue as duplicate of #239

c4-judge commented 4 months ago

Picodes changed the severity to QA (Quality Assurance)