// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import "../../src/abstracts/PriceOracle.sol"; import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; import "forge-std/Test.sol"; /** * @title PriceOracle Test Suite * @notice Unit tests for price stability validation using Uniswap V3 TWAP oracle */ // Mock Uniswap V3 Pool for testing contract MockUniswapV3Pool { int56[] public tickCumulatives; uint160[] public liquidityCumulatives; bool public shouldRevert; function setTickCumulatives(int56[] memory _tickCumulatives) external { tickCumulatives = _tickCumulatives; } function setLiquidityCumulatives(uint160[] memory _liquidityCumulatives) external { liquidityCumulatives = _liquidityCumulatives; } function setShouldRevert(bool _shouldRevert) external { shouldRevert = _shouldRevert; } function observe(uint32[] calldata) external view returns (int56[] memory, uint160[] memory) { if (shouldRevert) { revert("Mock oracle failure"); } return (tickCumulatives, liquidityCumulatives); } } // Test implementation of PriceOracle contract MockPriceOracle is PriceOracle { MockUniswapV3Pool public mockPool; constructor() { mockPool = new MockUniswapV3Pool(); } function _getPool() internal view override returns (IUniswapV3Pool) { return IUniswapV3Pool(address(mockPool)); } // Expose internal functions for testing function isPriceStable(int24 currentTick) external view returns (bool) { return _isPriceStable(currentTick); } function validatePriceMovement( int24 currentTick, int24 centerTick, int24 tickSpacing, bool token0isWeth ) external pure returns (bool isUp, bool isEnough) { return _validatePriceMovement(currentTick, centerTick, tickSpacing, token0isWeth); } function getMockPool() external view returns (MockUniswapV3Pool) { return mockPool; } } contract PriceOracleTest is Test { MockPriceOracle internal priceOracle; MockUniswapV3Pool internal mockPool; int24 internal constant TICK_SPACING = 200; uint32 internal constant PRICE_STABILITY_INTERVAL = 300; // 5 minutes int24 internal constant MAX_TICK_DEVIATION = 50; function setUp() public { priceOracle = new MockPriceOracle(); mockPool = priceOracle.getMockPool(); } // ======================================== // PRICE STABILITY TESTS // ======================================== function testPriceStableWithinDeviation() public { // Setup: current tick should be within MAX_TICK_DEVIATION of TWAP average int24 currentTick = 1000; int24 averageTick = 1025; // Within 50 tick deviation // Mock oracle to return appropriate tick cumulatives int56[] memory tickCumulatives = new int56[](2); tickCumulatives[0] = 0; // 5 minutes ago tickCumulatives[1] = averageTick * int56(int32(PRICE_STABILITY_INTERVAL)); // Current uint160[] memory liquidityCumulatives = new uint160[](2); liquidityCumulatives[0] = 1000; liquidityCumulatives[1] = 1000; mockPool.setTickCumulatives(tickCumulatives); mockPool.setLiquidityCumulatives(liquidityCumulatives); bool isStable = priceOracle.isPriceStable(currentTick); assertTrue(isStable, "Price should be stable when within deviation threshold"); } function testPriceUnstableOutsideDeviation() public { // Setup: current tick outside MAX_TICK_DEVIATION of TWAP average int24 currentTick = 1000; int24 averageTick = 1100; // 100 ticks away, outside deviation int56[] memory tickCumulatives = new int56[](2); tickCumulatives[0] = 0; tickCumulatives[1] = averageTick * int56(int32(PRICE_STABILITY_INTERVAL)); uint160[] memory liquidityCumulatives = new uint160[](2); liquidityCumulatives[0] = 1000; liquidityCumulatives[1] = 1000; mockPool.setTickCumulatives(tickCumulatives); mockPool.setLiquidityCumulatives(liquidityCumulatives); bool isStable = priceOracle.isPriceStable(currentTick); assertFalse(isStable, "Price should be unstable when outside deviation threshold"); } function testPriceStabilityOracleFailureFallback() public { // Test fallback behavior when oracle fails mockPool.setShouldRevert(true); // Should not revert but should still return a boolean // The actual implementation tries a longer timeframe on failure int24 currentTick = 1000; // This might fail or succeed depending on implementation details // The key is that it doesn't cause the entire transaction to revert try priceOracle.isPriceStable(currentTick) returns (bool result) { // If it succeeds, that's fine console.log("Oracle fallback succeeded, result:", result); } catch { // If it fails, that's also expected behavior for this test console.log("Oracle fallback failed as expected"); } } function testPriceStabilityExactBoundary() public { // Test exactly at the boundary of MAX_TICK_DEVIATION int24 currentTick = 1000; int24 averageTick = currentTick + MAX_TICK_DEVIATION; // Exactly at boundary int56[] memory tickCumulatives = new int56[](2); tickCumulatives[0] = 0; tickCumulatives[1] = averageTick * int56(int32(PRICE_STABILITY_INTERVAL)); uint160[] memory liquidityCumulatives = new uint160[](2); liquidityCumulatives[0] = 1000; liquidityCumulatives[1] = 1000; mockPool.setTickCumulatives(tickCumulatives); mockPool.setLiquidityCumulatives(liquidityCumulatives); bool isStable = priceOracle.isPriceStable(currentTick); assertTrue(isStable, "Price should be stable exactly at deviation boundary"); } function testPriceStabilityNegativeTicks() public { // Test with negative tick values int24 currentTick = -1000; int24 averageTick = -1025; // Within deviation int56[] memory tickCumulatives = new int56[](2); tickCumulatives[0] = 0; tickCumulatives[1] = averageTick * int56(int32(PRICE_STABILITY_INTERVAL)); uint160[] memory liquidityCumulatives = new uint160[](2); liquidityCumulatives[0] = 1000; liquidityCumulatives[1] = 1000; mockPool.setTickCumulatives(tickCumulatives); mockPool.setLiquidityCumulatives(liquidityCumulatives); bool isStable = priceOracle.isPriceStable(currentTick); assertTrue(isStable, "Price stability should work with negative ticks"); } // ======================================== // PRICE MOVEMENT VALIDATION TESTS // ======================================== function testPriceMovementWethToken0Up() public { // When WETH is token0, price goes "up" when currentTick < centerTick int24 currentTick = 1000; int24 centerTick = 1500; bool token0isWeth = true; (bool isUp, bool isEnough) = priceOracle.validatePriceMovement(currentTick, centerTick, TICK_SPACING, token0isWeth); assertTrue(isUp, "Should be up when WETH is token0 and currentTick < centerTick"); assertTrue(isEnough, "Movement should be enough (500 > 400)"); } function testPriceMovementWethToken0Down() public { // When WETH is token0, price goes "down" when currentTick > centerTick int24 currentTick = 1500; int24 centerTick = 1000; bool token0isWeth = true; (bool isUp, bool isEnough) = priceOracle.validatePriceMovement(currentTick, centerTick, TICK_SPACING, token0isWeth); assertFalse(isUp, "Should be down when WETH is token0 and currentTick > centerTick"); assertTrue(isEnough, "Movement should be enough (500 > 400)"); } function testPriceMovementTokenToken0Up() public { // When token is token0, price goes "up" when currentTick > centerTick int24 currentTick = 1500; int24 centerTick = 1000; bool token0isWeth = false; (bool isUp, bool isEnough) = priceOracle.validatePriceMovement(currentTick, centerTick, TICK_SPACING, token0isWeth); assertTrue(isUp, "Should be up when token is token0 and currentTick > centerTick"); assertTrue(isEnough, "Movement should be enough (500 > 400)"); } function testPriceMovementTokenToken0Down() public { // When token is token0, price goes "down" when currentTick < centerTick int24 currentTick = 1000; int24 centerTick = 1500; bool token0isWeth = false; (bool isUp, bool isEnough) = priceOracle.validatePriceMovement(currentTick, centerTick, TICK_SPACING, token0isWeth); assertFalse(isUp, "Should be down when token is token0 and currentTick < centerTick"); assertTrue(isEnough, "Movement should be enough (500 > 400)"); } function testPriceMovementInsufficientAmplitude() public { // Test when movement is less than minimum amplitude (2 * TICK_SPACING = 400) int24 currentTick = 1000; int24 centerTick = 1300; // Difference of 300, less than 400 bool token0isWeth = true; (bool isUp, bool isEnough) = priceOracle.validatePriceMovement(currentTick, centerTick, TICK_SPACING, token0isWeth); assertTrue(isUp, "Direction should still be correct"); assertFalse(isEnough, "Movement should not be enough (300 < 400)"); } function testPriceMovementExactAmplitude() public { // Test when movement is exactly at minimum amplitude int24 currentTick = 1000; int24 centerTick = 1400; // Difference of exactly 400 bool token0isWeth = true; (bool isUp, bool isEnough) = priceOracle.validatePriceMovement(currentTick, centerTick, TICK_SPACING, token0isWeth); assertTrue(isUp, "Direction should be correct"); assertFalse(isEnough, "Movement should not be enough (400 == 400, needs >)"); } function testPriceMovementJustEnoughAmplitude() public { // Test when movement is just above minimum amplitude int24 currentTick = 1000; int24 centerTick = 1401; // Difference of 401, just above 400 bool token0isWeth = true; (bool isUp, bool isEnough) = priceOracle.validatePriceMovement(currentTick, centerTick, TICK_SPACING, token0isWeth); assertTrue(isUp, "Direction should be correct"); assertTrue(isEnough, "Movement should be enough (401 > 400)"); } function testPriceMovementNegativeTicks() public { // Test with negative tick values int24 currentTick = -1000; int24 centerTick = -500; // Movement of 500 ticks bool token0isWeth = false; (bool isUp, bool isEnough) = priceOracle.validatePriceMovement(currentTick, centerTick, TICK_SPACING, token0isWeth); assertFalse(isUp, "Should be down when token0 != weth and currentTick < centerTick"); assertTrue(isEnough, "Movement should be enough (500 > 400)"); } // ======================================== // EDGE CASE TESTS // ======================================== function testPriceMovementZeroDifference() public { // Test when currentTick equals centerTick int24 currentTick = 1000; int24 centerTick = 1000; bool token0isWeth = true; (bool isUp, bool isEnough) = priceOracle.validatePriceMovement(currentTick, centerTick, TICK_SPACING, token0isWeth); assertFalse(isUp, "Should be down when currentTick == centerTick for WETH token0"); assertFalse(isEnough, "Movement should not be enough (0 < 400)"); } function testPriceMovementExtremeValues() public { // Test with large but safe tick values to avoid overflow int24 currentTick = 100_000; int24 centerTick = -100_000; bool token0isWeth = true; (bool isUp, bool isEnough) = priceOracle.validatePriceMovement(currentTick, centerTick, TICK_SPACING, token0isWeth); assertFalse(isUp, "Should be down when currentTick > centerTick for WETH token0"); assertTrue(isEnough, "Movement should definitely be enough with large values"); } // ======================================== // FUZZ TESTS // ======================================== function testFuzzPriceMovementValidation(int24 currentTick, int24 centerTick, int24 tickSpacing, bool token0isWeth) public { // Bound inputs to reasonable ranges currentTick = int24(bound(int256(currentTick), -1_000_000, 1_000_000)); centerTick = int24(bound(int256(centerTick), -1_000_000, 1_000_000)); tickSpacing = int24(bound(int256(tickSpacing), 1, 1000)); (bool isUp, bool isEnough) = priceOracle.validatePriceMovement(currentTick, centerTick, tickSpacing, token0isWeth); // Validate direction logic if (token0isWeth) { if (currentTick < centerTick) { assertTrue(isUp, "Should be up when WETH token0 and currentTick < centerTick"); } else { assertFalse(isUp, "Should be down when WETH token0 and currentTick >= centerTick"); } } else { if (currentTick > centerTick) { assertTrue(isUp, "Should be up when token token0 and currentTick > centerTick"); } else { assertFalse(isUp, "Should be down when token token0 and currentTick <= centerTick"); } } // Validate amplitude logic int256 diff = int256(currentTick) - int256(centerTick); uint256 amplitude = diff >= 0 ? uint256(diff) : uint256(-diff); uint256 minAmplitude = uint256(int256(tickSpacing)) * 2; if (amplitude > minAmplitude) { assertTrue(isEnough, "Should be enough when amplitude > minAmplitude"); } else { assertFalse(isEnough, "Should not be enough when amplitude <= minAmplitude"); } } }