harb/onchain/test/abstracts/PriceOracle.t.sol
johba db1c26838d fix: _isPriceStable fallback interval can still revert on pools with very short history (#610)
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>
2026-03-22 20:31:04 +00:00

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");
}
}
}