Wrap the fallback pool.observe() call in a try/catch so that pools with insufficient observation history for both the primary (30s) and fallback (6000s) intervals return false (price unstable) instead of reverting with an opaque Uniswap V3 error. This prevents recenter() from failing for unpermissioned callers on newly created pools. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
406 lines
16 KiB
Solidity
406 lines
16 KiB
Solidity
// 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;
|
|
|
|
// Fallback path support: separate tick cumulatives for the 60000s window
|
|
int56[] public fallbackTickCumulatives;
|
|
bool public revertOnlyPrimary; // true = revert on 300s, succeed on 60000s
|
|
|
|
function setTickCumulatives(int56[] memory _tickCumulatives) external {
|
|
tickCumulatives = _tickCumulatives;
|
|
}
|
|
|
|
function setLiquidityCumulatives(uint160[] memory _liquidityCumulatives) external {
|
|
liquidityCumulatives = _liquidityCumulatives;
|
|
}
|
|
|
|
function setShouldRevert(bool _shouldRevert) external {
|
|
shouldRevert = _shouldRevert;
|
|
}
|
|
|
|
function setFallbackTickCumulatives(int56[] memory _fallbackTickCumulatives) external {
|
|
fallbackTickCumulatives = _fallbackTickCumulatives;
|
|
}
|
|
|
|
function setRevertOnlyPrimary(bool _revertOnlyPrimary) external {
|
|
revertOnlyPrimary = _revertOnlyPrimary;
|
|
}
|
|
|
|
function observe(uint32[] calldata secondsAgo) external view returns (int56[] memory, uint160[] memory) {
|
|
if (shouldRevert) {
|
|
revert("Mock oracle failure");
|
|
}
|
|
// If revertOnlyPrimary is set, revert on 300s but succeed on 60000s
|
|
if (revertOnlyPrimary && secondsAgo[0] == 30) {
|
|
revert("Old observations not available");
|
|
}
|
|
if (revertOnlyPrimary && secondsAgo[0] == 6000 && fallbackTickCumulatives.length > 0) {
|
|
return (fallbackTickCumulatives, liquidityCumulatives);
|
|
}
|
|
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 = 30; // 30 seconds
|
|
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 testFallbackPathUsesCorrectDivisor() public {
|
|
// Primary observe (300s) reverts, fallback (60000s) succeeds
|
|
// The fallback window is 60000 seconds, so tickCumulativeDiff / 60000 = averageTick
|
|
int24 averageTick = 1000;
|
|
uint32 fallbackInterval = 6000;
|
|
|
|
int56[] memory fallbackCumulatives = new int56[](2);
|
|
fallbackCumulatives[0] = 0;
|
|
fallbackCumulatives[1] = int56(averageTick) * int56(int32(fallbackInterval));
|
|
|
|
uint160[] memory liquidityCumulatives = new uint160[](2);
|
|
liquidityCumulatives[0] = 1000;
|
|
liquidityCumulatives[1] = 1000;
|
|
|
|
mockPool.setRevertOnlyPrimary(true);
|
|
mockPool.setFallbackTickCumulatives(fallbackCumulatives);
|
|
mockPool.setLiquidityCumulatives(liquidityCumulatives);
|
|
|
|
// currentTick = 1020, averageTick = 1000 → within 50-tick deviation → stable
|
|
bool isStable = priceOracle.isPriceStable(1020);
|
|
assertTrue(isStable, "Fallback: price within deviation should be stable");
|
|
|
|
// currentTick = 1100, averageTick = 1000 → 100 ticks away → unstable
|
|
isStable = priceOracle.isPriceStable(1100);
|
|
assertFalse(isStable, "Fallback: price outside deviation should be unstable");
|
|
}
|
|
|
|
function testFallbackPathWithNegativeTick() public {
|
|
// Verify fallback works correctly with negative ticks
|
|
int24 averageTick = -500;
|
|
uint32 fallbackInterval = 6000;
|
|
|
|
int56[] memory fallbackCumulatives = new int56[](2);
|
|
fallbackCumulatives[0] = 0;
|
|
fallbackCumulatives[1] = int56(averageTick) * int56(int32(fallbackInterval));
|
|
|
|
uint160[] memory liquidityCumulatives = new uint160[](2);
|
|
liquidityCumulatives[0] = 1000;
|
|
liquidityCumulatives[1] = 1000;
|
|
|
|
mockPool.setRevertOnlyPrimary(true);
|
|
mockPool.setFallbackTickCumulatives(fallbackCumulatives);
|
|
mockPool.setLiquidityCumulatives(liquidityCumulatives);
|
|
|
|
// currentTick = -480, averageTick = -500 → diff = 20 → stable
|
|
bool isStable = priceOracle.isPriceStable(-480);
|
|
assertTrue(isStable, "Fallback with negative tick: within deviation should be stable");
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
function testDoubleFailureReturnsFalse() public {
|
|
// When pool has < 6000 seconds of history, both observe() calls fail.
|
|
// _isPriceStable should return false (safe default) instead of reverting.
|
|
mockPool.setShouldRevert(true);
|
|
|
|
bool isStable = priceOracle.isPriceStable(1000);
|
|
assertFalse(isStable, "Double observe failure should return false, not revert");
|
|
}
|
|
|
|
// ========================================
|
|
// 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");
|
|
}
|
|
}
|
|
}
|