From 9b6384506574d29d76cdf97d4ee982231b5d67b5 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 27 Aug 2026 17:12:00 +0200 Subject: [PATCH] test(oracle): add twap oracle tests --- test/FluxOracle.t.sol | 224 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 test/FluxOracle.t.sol diff --git a/test/FluxOracle.t.sol b/test/FluxOracle.t.sol new file mode 100644 index 0000000..946418d --- /dev/null +++ b/test/FluxOracle.t.sol @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.36; + +import {Test} from "forge-std/Test.sol"; +import {StdInvariant} from "forge-std/StdInvariant.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {FluxClFactory} from "../src/FluxClFactory.sol"; +import {FluxClPool} from "../src/FluxClPool.sol"; +import {TestErc20} from "../src/TestErc20.sol"; +import {IErc20} from "../src/interfaces/IErc20.sol"; +import {IFluxMintCallback} from "../src/interfaces/IFluxMintCallback.sol"; +import {IFluxSwapCallback} from "../src/interfaces/IFluxSwapCallback.sol"; +import {TickMath} from "../src/libraries/TickMath.sol"; +import {OracleLibrary} from "../src/libraries/OracleLibrary.sol"; + +contract OracleUser is IFluxMintCallback, IFluxSwapCallback { + function fluxMintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata data) external { + (address token0, address token1) = abi.decode(data, (address, address)); + if (amount0Owed > 0) IErc20(token0).transfer(msg.sender, amount0Owed); + if (amount1Owed > 0) IErc20(token1).transfer(msg.sender, amount1Owed); + } + + function fluxSwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external { + (address token0, address token1) = abi.decode(data, (address, address)); + if (amount0Delta > 0) IErc20(token0).transfer(msg.sender, uint256(amount0Delta)); + if (amount1Delta > 0) IErc20(token1).transfer(msg.sender, uint256(amount1Delta)); + } + + function mint(FluxClPool pool, int24 tickLower, int24 tickUpper, uint128 amount, bytes calldata data) external { + pool.mint(address(this), tickLower, tickUpper, amount, data); + } + + function swap(FluxClPool pool, bool zeroForOne, int256 amountSpecified, uint160 limit, bytes calldata data) + external + { + pool.swap(address(this), zeroForOne, amountSpecified, limit, data); + } +} + +contract FluxOracleTest is Test { + uint160 private constant priceOne = 79228162514264337593543950336; + + FluxClFactory private factory; + FluxClPool private pool; + TestErc20 private token0; + TestErc20 private token1; + OracleUser private user; + bytes private cb; + + function setUp() public { + vm.warp(1_000_000); + factory = new FluxClFactory(); + TestErc20 tokenA = new TestErc20("Alpha", "ALPHA", 0); + TestErc20 tokenB = new TestErc20("Beta", "BETA", 0); + (address a, address b) = + address(tokenA) < address(tokenB) ? (address(tokenA), address(tokenB)) : (address(tokenB), address(tokenA)); + token0 = TestErc20(a); + token1 = TestErc20(b); + cb = abi.encode(a, b); + + pool = FluxClPool(factory.createPool(a, b, 3000)); + pool.initialize(priceOne); + + user = new OracleUser(); + token0.mint(address(user), 1e33); + token1.mint(address(user), 1e33); + user.mint(pool, -600000, 600000, 1e20, cb); + } + + function testCardinalityGrows() public { + (,,, uint16 cardinalityBefore, uint16 cardinalityNextBefore,) = pool.slot0(); + assertEq(cardinalityBefore, 1); + assertEq(cardinalityNextBefore, 1); + + pool.increaseObservationCardinalityNext(20); + (,,,, uint16 cardinalityNextAfter,) = pool.slot0(); + assertEq(cardinalityNextAfter, 20); + + for (uint256 i = 0; i < 25; i++) { + vm.warp(block.timestamp + 60); + user.swap(pool, true, 1e18, TickMath.minSqrtRatio + 1, cb); + } + (,,, uint16 cardinalityAfter,,) = pool.slot0(); + assertEq(cardinalityAfter, 20); + } + + function testTwapReflectsPriceDrop() public { + pool.increaseObservationCardinalityNext(50); + + for (uint256 i = 0; i < 30; i++) { + vm.warp(block.timestamp + 120); + user.swap(pool, true, 1e18, TickMath.minSqrtRatio + 1, cb); + } + vm.warp(block.timestamp + 120); + + (, int24 spotTick,,,,) = pool.slot0(); + int24 meanTick = OracleLibrary.consult(address(pool), 1800); + + assertLt(meanTick, 0); + assertGt(meanTick, spotTick); + assertGt(meanTick, TickMath.minTick); + } + + function testObserveInterpolatesCumulative() public { + pool.increaseObservationCardinalityNext(10); + vm.warp(block.timestamp + 300); + user.swap(pool, true, 1e18, TickMath.minSqrtRatio + 1, cb); + vm.warp(block.timestamp + 300); + + uint32[] memory secondsAgos = new uint32[](3); + secondsAgos[0] = 500; + secondsAgos[1] = 200; + secondsAgos[2] = 0; + (int56[] memory cumulatives,) = pool.observe(secondsAgos); + + assertLt(cumulatives[2], cumulatives[1]); + assertLe(cumulatives[1], cumulatives[0]); + } + + function testQuoteAtTickAtPriceOne() public view { + uint256 quote = OracleLibrary.getQuoteAtTick(0, 1e18, address(token0), address(token1)); + assertApproxEqRel(quote, 1e18, 1e12); + } + + function testObserveRevertsForTooOldWindow() public { + pool.increaseObservationCardinalityNext(3); + vm.warp(block.timestamp + 100); + user.swap(pool, true, 1e18, TickMath.minSqrtRatio + 1, cb); + + uint32[] memory secondsAgos = new uint32[](2); + secondsAgos[0] = 100000; + secondsAgos[1] = 0; + vm.expectRevert(bytes("Oracle: OLD")); + pool.observe(secondsAgos); + } +} + +contract OracleMonotonicHandler is IFluxMintCallback, IFluxSwapCallback { + Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + FluxClPool public pool; + TestErc20 public token0; + TestErc20 public token1; + bool public orderingHeld = true; + + constructor(FluxClPool poolAddress, TestErc20 first, TestErc20 second) { + pool = poolAddress; + token0 = first; + token1 = second; + token0.mint(address(this), 1e33); + token1.mint(address(this), 1e33); + } + + function fluxMintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external { + if (amount0Owed > 0) token0.transfer(msg.sender, amount0Owed); + if (amount1Owed > 0) token1.transfer(msg.sender, amount1Owed); + } + + function fluxSwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external { + if (amount0Delta > 0) token0.transfer(msg.sender, uint256(amount0Delta)); + if (amount1Delta > 0) token1.transfer(msg.sender, uint256(amount1Delta)); + } + + function seed() external { + pool.mint(address(this), -600000, 600000, 1e24, ""); + } + + function warpAndSwap(uint16 dtSeed, uint256 amountSeed, bool zeroForOne) external { + uint256 dt = 1 + (uint256(dtSeed) % 600); + vm.warp(block.timestamp + dt); + + int256 amount = int256(1e12 + (amountSeed % 1e22)); + uint160 limit = zeroForOne ? TickMath.minSqrtRatio + 1 : TickMath.maxSqrtRatio - 1; + try pool.swap(address(this), zeroForOne, amount, limit, "") {} catch {} + + _checkOrdering(); + } + + function _checkOrdering() private { + (,, uint16 index, uint16 cardinality,,) = pool.slot0(); + uint32 prev; + bool started; + for (uint16 i = 0; i < cardinality; i++) { + uint16 slot = uint16((uint256(index) + 1 + i) % cardinality); + (uint32 ts,,, bool initialized) = pool.observations(slot); + if (!initialized) continue; + if (started && ts < prev) orderingHeld = false; + prev = ts; + started = true; + } + } +} + +contract FluxOracleInvariantTest is StdInvariant, Test { + uint160 private constant priceOne = 79228162514264337593543950336; + + FluxClPool private pool; + OracleMonotonicHandler private handler; + + function setUp() public { + vm.warp(1_000_000); + FluxClFactory factory = new FluxClFactory(); + TestErc20 tokenA = new TestErc20("Alpha", "ALPHA", 0); + TestErc20 tokenB = new TestErc20("Beta", "BETA", 0); + (address a, address b) = + address(tokenA) < address(tokenB) ? (address(tokenA), address(tokenB)) : (address(tokenB), address(tokenA)); + + pool = FluxClPool(factory.createPool(a, b, 3000)); + pool.initialize(priceOne); + + handler = new OracleMonotonicHandler(pool, TestErc20(a), TestErc20(b)); + pool.increaseObservationCardinalityNext(30); + handler.seed(); + + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = OracleMonotonicHandler.warpAndSwap.selector; + targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); + targetContract(address(handler)); + } + + function invariantObservationTimestampsAreMonotonic() public view { + assertTrue(handler.orderingHeld()); + } +}