From 6372fec4f0aab33f24a15e989e807256a3e5fd7e Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 28 Aug 2026 19:15:00 +0200 Subject: [PATCH] test(pool): add concentrated-liquidity invariant tests --- test/FluxClInvariant.t.sol | 155 +++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 test/FluxClInvariant.t.sol diff --git a/test/FluxClInvariant.t.sol b/test/FluxClInvariant.t.sol new file mode 100644 index 0000000..ad23654 --- /dev/null +++ b/test/FluxClInvariant.t.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.36; + +import {Test} from "forge-std/Test.sol"; +import {StdInvariant} from "forge-std/StdInvariant.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"; + +contract ClHandler is IFluxMintCallback, IFluxSwapCallback { + FluxClPool public pool; + TestErc20 public token0; + TestErc20 public token1; + int24 private constant spacing = 60; + + int256 public ghost0; + int256 public ghost1; + + struct Range { + int24 lower; + int24 upper; + } + + Range[] public ranges; + mapping(bytes32 => uint128) public liquidityOf; + + 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); + ghost0 += int256(amount0Owed); + ghost1 += int256(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)); + ghost0 += amount0Delta; + ghost1 += amount1Delta; + } + + function doMint(uint128 amountSeed, int24 lowerSeed, int24 widthSeed) external { + uint128 amount = uint128(bound(uint256(amountSeed), 1e12, 1e24)); + int24 lower = int24(bound(int256(lowerSeed), -30000, 30000)) / spacing * spacing; + int24 width = int24(bound(int256(widthSeed), 1, 100)) * spacing; + int24 upper = lower + width; + if (upper > TickMath.maxTick) return; + + try pool.mint(address(this), lower, upper, amount, "") { + bytes32 key = keccak256(abi.encodePacked(lower, upper)); + if (liquidityOf[key] == 0) ranges.push(Range({lower: lower, upper: upper})); + liquidityOf[key] += amount; + } catch {} + } + + function doSwap(uint256 amountSeed, bool zeroForOne) external { + int256 amountSpecified = int256(bound(amountSeed, 1e6, 1e26)); + uint160 limit = zeroForOne ? TickMath.minSqrtRatio + 1 : TickMath.maxSqrtRatio - 1; + try pool.swap(address(this), zeroForOne, amountSpecified, limit, "") {} catch {} + } + + function doBurn(uint256 rangeSeed, uint128 amountSeed) external { + if (ranges.length == 0) return; + Range memory r = ranges[rangeSeed % ranges.length]; + bytes32 key = keccak256(abi.encodePacked(r.lower, r.upper)); + uint128 have = liquidityOf[key]; + if (have == 0) return; + uint128 amount = uint128(bound(uint256(amountSeed), 1, have)); + + try pool.burn(r.lower, r.upper, amount) { + liquidityOf[key] -= amount; + (uint128 c0, uint128 c1) = + pool.collect(address(this), r.lower, r.upper, type(uint128).max, type(uint128).max); + ghost0 -= int256(uint256(c0)); + ghost1 -= int256(uint256(c1)); + } catch {} + } + + function bound(uint256 x, uint256 min, uint256 max) private pure returns (uint256) { + if (max <= min) return min; + return min + (x % (max - min + 1)); + } + + function bound(int256 x, int256 min, int256 max) private pure returns (int256) { + if (max <= min) return min; + uint256 range = uint256(max - min) + 1; + uint256 offset = uint256(x < 0 ? -x : x) % range; + return min + int256(offset); + } + + function rangesLength() external view returns (uint256) { + return ranges.length; + } +} + +contract FluxClInvariantTest is StdInvariant, Test { + uint160 private constant priceOne = 79228162514264337593543950336; + + FluxClFactory private factory; + FluxClPool private pool; + TestErc20 private token0; + TestErc20 private token1; + ClHandler private handler; + + function setUp() public { + 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); + + pool = FluxClPool(factory.createPool(a, b, 3000)); + pool.initialize(priceOne); + + handler = new ClHandler(pool, token0, token1); + + bytes4[] memory selectors = new bytes4[](3); + selectors[0] = ClHandler.doMint.selector; + selectors[1] = ClHandler.doSwap.selector; + selectors[2] = ClHandler.doBurn.selector; + targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); + targetContract(address(handler)); + } + + function invariantPoolBalanceMatchesAccounting() public view { + assertEq(int256(token0.balanceOf(address(pool))), handler.ghost0()); + assertEq(int256(token1.balanceOf(address(pool))), handler.ghost1()); + } + + function invariantPriceStaysWithinTickRange() public view { + (uint160 sqrtP, int24 tick,,,,) = pool.slot0(); + assertGe(sqrtP, TickMath.minSqrtRatio); + assertLt(sqrtP, TickMath.maxSqrtRatio); + assertGe(tick, TickMath.minTick); + assertLe(tick, TickMath.maxTick); + } + + function invariantPoolNeverOwesMoreThanItHolds() public view { + assertGe(handler.ghost0(), 0); + assertGe(handler.ghost1(), 0); + } +}