From ec7e6b4b46ee2066c60f0f38c37760c7088bccb1 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 27 Aug 2026 14:28:00 +0200 Subject: [PATCH] test(pool): add concentrated-liquidity pool tests --- test/FluxClPool.t.sol | 263 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 test/FluxClPool.t.sol diff --git a/test/FluxClPool.t.sol b/test/FluxClPool.t.sol new file mode 100644 index 0000000..903329d --- /dev/null +++ b/test/FluxClPool.t.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.36; + +import {Test} from "forge-std/Test.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 PoolUser 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, + address recipient, + int24 tickLower, + int24 tickUpper, + uint128 amount, + bytes calldata data + ) external returns (uint256, uint256) { + return pool.mint(recipient, tickLower, tickUpper, amount, data); + } + + function swap( + FluxClPool pool, + address recipient, + bool zeroForOne, + int256 amountSpecified, + uint160 limit, + bytes calldata data + ) external returns (int256, int256) { + return pool.swap(recipient, zeroForOne, amountSpecified, limit, data); + } + + function burn(FluxClPool pool, int24 tickLower, int24 tickUpper, uint128 amount) + external + returns (uint256, uint256) + { + return pool.burn(tickLower, tickUpper, amount); + } + + function collect(FluxClPool pool, address recipient, int24 tickLower, int24 tickUpper, uint128 a0, uint128 a1) + external + returns (uint128, uint128) + { + return pool.collect(recipient, tickLower, tickUpper, a0, a1); + } +} + +contract FluxClPoolTest is Test { + uint160 private constant priceOne = 79228162514264337593543950336; + + FluxClFactory private factory; + FluxClPool private pool; + TestErc20 private token0; + TestErc20 private token1; + PoolUser private user; + bytes private callbackData; + + int24 private constant spacing = 60; + + 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); + + user = new PoolUser(); + token0.mint(address(user), 1e30); + token1.mint(address(user), 1e30); + callbackData = abi.encode(a, b); + } + + function mintRange(int24 tickLower, int24 tickUpper, uint128 amount) private returns (uint256, uint256) { + return user.mint(pool, address(user), tickLower, tickUpper, amount, callbackData); + } + + function testFactoryDeterministicPool() public view { + assertEq(factory.getPool(address(token0), address(token1), 3000), address(pool)); + assertEq(factory.getPool(address(token1), address(token0), 3000), address(pool)); + assertEq(pool.tickSpacing(), spacing); + assertEq(pool.fee(), 3000); + assertEq(factory.allPoolsLength(), 1); + } + + function testCreatePoolRevertsOnUnknownFee() public { + vm.expectRevert(bytes("Flux: FEE_TIER")); + factory.createPool(address(token0), address(token1), 1234); + } + + function testCreatePoolRevertsOnDuplicate() public { + vm.expectRevert(bytes("Flux: POOL_EXISTS")); + factory.createPool(address(token0), address(token1), 3000); + } + + function testInitializeSetsTickZero() public view { + (uint160 sqrtP, int24 tick,,,,) = pool.slot0(); + assertEq(sqrtP, priceOne); + assertEq(tick, 0); + } + + function testInitializeRevertsTwice() public { + vm.expectRevert(bytes("Flux: INIT")); + pool.initialize(priceOne); + } + + function testMintBalancedAtPriceOne() public { + (uint256 amount0, uint256 amount1) = mintRange(-600, 600, 1e18); + assertGt(amount0, 0); + assertGt(amount1, 0); + assertApproxEqRel(amount0, amount1, 1e15); + assertEq(pool.liquidity(), 1e18); + assertEq(token0.balanceOf(address(pool)), amount0); + assertEq(token1.balanceOf(address(pool)), amount1); + } + + function testMintRevertsOnBadTickOrder() public { + vm.expectRevert(bytes("Flux: TLU")); + mintRange(600, -600, 1e18); + } + + function testMintRevertsOnUnalignedTick() public { + vm.expectRevert(bytes("Flux: TLS")); + mintRange(-599, 600, 1e18); + } + + function testSwapZeroForOneMovesPriceDown() public { + mintRange(-6000, 6000, 1e20); + (uint160 sqrtBefore, int24 tickBefore,,,,) = pool.slot0(); + + uint256 out1Before = token1.balanceOf(address(this)); + user.swap(pool, address(this), true, 1e17, TickMath.minSqrtRatio + 1, callbackData); + uint256 received = token1.balanceOf(address(this)) - out1Before; + + (uint160 sqrtAfter, int24 tickAfter,,,,) = pool.slot0(); + assertLt(sqrtAfter, sqrtBefore); + assertLe(tickAfter, tickBefore); + assertGt(received, 0); + } + + function testSwapOneForZeroMovesPriceUp() public { + mintRange(-6000, 6000, 1e20); + (uint160 sqrtBefore,,,,,) = pool.slot0(); + + uint256 out0Before = token0.balanceOf(address(this)); + user.swap(pool, address(this), false, 1e17, TickMath.maxSqrtRatio - 1, callbackData); + uint256 received = token0.balanceOf(address(this)) - out0Before; + + (uint160 sqrtAfter,,,,,) = pool.slot0(); + assertGt(sqrtAfter, sqrtBefore); + assertGt(received, 0); + } + + function testSwapCrossesTickAndDropsLiquidity() public { + mintRange(-6000, 6000, 1e20); + mintRange(-120, 120, 5e20); + assertEq(pool.liquidity(), 6e20); + + user.swap(pool, address(this), true, 1e19, TickMath.minSqrtRatio + 1, callbackData); + + (, int24 tickAfter,,,,) = pool.slot0(); + assertLt(tickAfter, -120); + assertGt(tickAfter, -6000); + assertEq(pool.liquidity(), 1e20); + } + + function testFeesAccrueToInRangePosition() public { + mintRange(-6000, 6000, 1e20); + + user.swap(pool, address(this), true, 1e18, TickMath.minSqrtRatio + 1, callbackData); + user.swap(pool, address(this), false, 1e18, TickMath.maxSqrtRatio - 1, callbackData); + + user.burn(pool, -6000, 6000, 0); + (, uint256 fg0,, uint128 owed0, uint128 owed1) = + pool.positions(keccak256(abi.encodePacked(address(user), int24(-6000), int24(6000)))); + fg0; + assertGt(owed0, 0); + assertGt(owed1, 0); + } + + function testBurnCollectReturnsPrincipalPlusFees() public { + (uint256 minted0, uint256 minted1) = mintRange(-6000, 6000, 1e20); + + user.swap(pool, address(this), true, 1e18, TickMath.minSqrtRatio + 1, callbackData); + user.swap(pool, address(this), false, 1e18, TickMath.maxSqrtRatio - 1, callbackData); + + user.burn(pool, -6000, 6000, 1e20); + (uint128 collected0, uint128 collected1) = + user.collect(pool, address(user), -6000, 6000, type(uint128).max, type(uint128).max); + + assertGe(uint256(collected0) + collected1, minted0 + minted1); + } + + function testSwapExactOutputDeliversRequestedAmount() public { + mintRange(-6000, 6000, 1e20); + + uint256 before = token1.balanceOf(address(this)); + user.swap(pool, address(this), true, -1e16, TickMath.minSqrtRatio + 1, callbackData); + uint256 received = token1.balanceOf(address(this)) - before; + assertEq(received, 1e16); + } + + function testFuzzSwapExactInputNeverExceedsInput(uint128 amountIn, bool zeroForOne) public { + mintRange(-60000, 60000, 1e22); + amountIn = uint128(bound(amountIn, 1e6, 1e24)); + + uint160 limit = zeroForOne ? TickMath.minSqrtRatio + 1 : TickMath.maxSqrtRatio - 1; + (int256 amount0, int256 amount1) = + user.swap(pool, address(this), zeroForOne, int256(uint256(amountIn)), limit, callbackData); + + if (zeroForOne) { + assertLe(uint256(amount0), amountIn); + assertLe(amount1, 0); + } else { + assertLe(uint256(amount1), amountIn); + assertLe(amount0, 0); + } + + (uint160 sqrtP,,,,,) = pool.slot0(); + assertGe(sqrtP, TickMath.minSqrtRatio); + assertLt(sqrtP, TickMath.maxSqrtRatio); + } + + function testFuzzMintThenFullBurnConservesTokens(uint128 amount, int24 lowerSeed, int24 widthSeed) public { + amount = uint128(bound(amount, 1e12, 1e24)); + int24 lower = int24(bound(lowerSeed, -600, 0)) / spacing * spacing; + int24 width = int24(bound(widthSeed, 1, 10)) * spacing; + int24 upper = lower + width; + + uint256 balUser0Before = token0.balanceOf(address(user)); + uint256 balUser1Before = token1.balanceOf(address(user)); + + (uint256 amount0, uint256 amount1) = mintRange(lower, upper, amount); + user.burn(pool, lower, upper, amount); + user.collect(pool, address(user), lower, upper, type(uint128).max, type(uint128).max); + + assertLe(token0.balanceOf(address(user)), balUser0Before); + assertLe(token1.balanceOf(address(user)), balUser1Before); + assertLe(balUser0Before - token0.balanceOf(address(user)), 2); + assertLe(balUser1Before - token1.balanceOf(address(user)), 2); + amount0; + amount1; + } +}