From 343668f6c667aecac18c7dae0493eb5cbc47697d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Lakhal?= <39790678+clemlak@users.noreply.github.com> Date: Wed, 23 Oct 2024 23:31:05 +0400 Subject: [PATCH 1/4] Feat/eth support 2 (#133) * test: add payable casting to hook address (cherry picked from commit 74a5df3f4e2bda4db3a57994aeda21f582ce44c6) * feat: add receive function (cherry picked from commit ed8efe3a1a6bd644c7562a049684a45056eae900) * test: add receive test (cherry picked from commit ba31923b8ca57a4feb2d0d49115efd3feddd68a7) * fix: modifyLiquidity !isToken0 case (cherry picked from commit 538a4a86b7f4d6125b896747e704f0bc4edf2e07) * fix: valid constructor params test (cherry picked from commit 415953595ed31c310b3e21e4d0fe11b6701d82eb) * build: add via_ir setting in Foundry config * build: add new test workflows * build: merge all the test workflows together * test: add usingEth, change test tokens to address type * test: remove useless casting * test: add missing TestERC20 casting * test: add buy and sell functions * test: add Quoter, implement computeBuyAmountIn, fix buy * test: add approve in sell, fix price limit * test: use buy and sell instead of swap, clean up imports * test: add assert descriptions, format file * test: use buy and sell in test_rebalance_CollectsFeeFromAllSlugs * test: use buy and sell in test_rebalance_FullFlow * test: use buy in testPriceDiscoverySlug_LastEpoch * test: use buy and sell in test_rebalance_MaxDutchAuction * test: use buy to swap * test: use buy instead of swap in test_rebalance_RelativeDutchAuction * test: use buy and sell instead of swap in test_rebalance_ExtremeOversoldCase * test: use buy and sell instead of swap in test_rebalance_LowerSlug_SufficientProceeds * test: use buy and sell instead of swap in test_rebalance_LowerSlug_InsufficientProceeds * test: use buy instead of swap in test_rebalance_OversoldCase * test: use buy instead of swap in test_rebalance_PriceDiscoverySlug_RemainingEpoch * test: use buy instead of swap in test_rebalance_UpperSlug_Undersold * test: fix buy negative mintAmount * test: fix sell function * chore: add NatSpec to buy and sell * fix: useApproxEqAbs on currentTick == upperSlug.tickLower * test: fix tests using assertApproxEqAbs * test: use isToken0 to fix broken tests * test: add buy / sell exact in / out * test: attempt to fix more tests using isToken0 condition * test: add Quoter to DopplerHandler * test: add buy and sell functions to DopplerHandler * test: return values in buy and sell functions * test: add CustomRouter * test: add mintAndBuy, remove minting from buy * test: remove buy and sell util functions, implement buyExactAmountIn * test: add custom router to BaseTest * chore: fix typo in NatSpec * test: update invariant tests setup --------- Co-authored-by: kinrezc --- .github/workflows/test.yml | 1 - src/Doppler.sol | 22 +- test/integration/Rebalance.t.sol | 447 ++++++++------------------- test/invariant/DopplerHandler.sol | 40 ++- test/invariant/DopplerInvariants.sol | 20 +- test/shared/BaseTest.sol | 172 +++++++++-- test/shared/CustomRouter.sol | 191 ++++++++++++ test/unit/Constructor.t.sol | 3 +- test/unit/EarlyExit.t.sol | 18 +- test/unit/Receive.t.sol | 21 ++ test/unit/SlugVis.t.sol | 22 +- test/unit/Swap.sol | 94 +----- 12 files changed, 582 insertions(+), 469 deletions(-) create mode 100644 test/shared/CustomRouter.sol create mode 100644 test/unit/Receive.t.sol diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4fb4dec5..2d04cbdf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,4 +56,3 @@ jobs: export PROTOCOL_FEE=50 forge test -vvv --via-ir id: test2 - diff --git a/src/Doppler.sol b/src/Doppler.sol index dd299b83..8473f96d 100644 --- a/src/Doppler.sol +++ b/src/Doppler.sol @@ -78,6 +78,8 @@ contract Doppler is BaseHook { bool immutable isToken0; // whether token0 is the token being sold (true) or token1 (false) uint256 immutable numPDSlugs; // number of price discovery slugs + receive() external payable {} + constructor( IPoolManager _poolManager, PoolKey memory _poolKey, @@ -726,8 +728,8 @@ contract Doppler is BaseHook { (BalanceDelta positionDeltas, BalanceDelta feesAccrued) = poolManager.modifyLiquidity( key, IPoolManager.ModifyLiquidityParams({ - tickLower: lastEpochPositions[i].tickLower, - tickUpper: lastEpochPositions[i].tickUpper, + tickLower: isToken0 ? lastEpochPositions[i].tickLower : lastEpochPositions[i].tickUpper, + tickUpper: isToken0 ? lastEpochPositions[i].tickUpper : lastEpochPositions[i].tickLower, liquidityDelta: -int128(lastEpochPositions[i].liquidity), salt: bytes32(uint256(lastEpochPositions[i].salt)) }), @@ -763,12 +765,8 @@ contract Doppler is BaseHook { poolManager.modifyLiquidity( key, IPoolManager.ModifyLiquidityParams({ - tickLower: newPositions[i].tickLower < newPositions[i].tickUpper - ? newPositions[i].tickLower - : newPositions[i].tickUpper, - tickUpper: newPositions[i].tickUpper > newPositions[i].tickLower - ? newPositions[i].tickUpper - : newPositions[i].tickLower, + tickLower: isToken0 ? newPositions[i].tickLower : newPositions[i].tickUpper, + tickUpper: isToken0 ? newPositions[i].tickUpper : newPositions[i].tickLower, liquidityDelta: int128(newPositions[i].liquidity), salt: bytes32(uint256(newPositions[i].salt)) }), @@ -824,8 +822,8 @@ contract Doppler is BaseHook { (BalanceDelta callerDelta,) = poolManager.modifyLiquidity( key, IPoolManager.ModifyLiquidityParams({ - tickLower: upperSlug.tickLower, - tickUpper: upperSlug.tickUpper, + tickLower: isToken0 ? upperSlug.tickLower : upperSlug.tickUpper, + tickUpper: isToken0 ? upperSlug.tickUpper : upperSlug.tickLower, liquidityDelta: int128(upperSlug.liquidity), salt: UPPER_SLUG_SALT }), @@ -839,8 +837,8 @@ contract Doppler is BaseHook { (BalanceDelta callerDelta,) = poolManager.modifyLiquidity( key, IPoolManager.ModifyLiquidityParams({ - tickLower: priceDiscoverySlugs[i].tickLower, - tickUpper: priceDiscoverySlugs[i].tickUpper, + tickLower: isToken0 ? priceDiscoverySlugs[i].tickLower : priceDiscoverySlugs[i].tickUpper, + tickUpper: isToken0 ? priceDiscoverySlugs[i].tickUpper : priceDiscoverySlugs[i].tickLower, liquidityDelta: int128(priceDiscoverySlugs[i].liquidity), salt: bytes32(uint256(3 + i)) }), diff --git a/test/integration/Rebalance.t.sol b/test/integration/Rebalance.t.sol index cd17df68..0f78c91d 100644 --- a/test/integration/Rebalance.t.sol +++ b/test/integration/Rebalance.t.sol @@ -35,7 +35,6 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime()); PoolKey memory poolKey = key; - bool isToken0 = hook.getIsToken0(); // Compute the amount of tokens available in both the upper and price discovery slugs // Should be two epochs of liquidity available since we're at the startingTime @@ -43,28 +42,12 @@ contract RebalanceTest is BaseTest { // We sell all available tokens // This increases the price to the pool maximum - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(expectedAmountSold), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(expectedAmountSold)); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch // We swap again just to trigger the rebalancing logic in the new epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (, int256 tickAccumulator, uint256 totalTokensSold,,,) = hook.state(); @@ -86,16 +69,35 @@ contract RebalanceTest is BaseTest { // doesn't seem to due to rounding. Consider whether this is a problem or whether we // even need that case at all - // Validate that lower slug is not above the current tick - assertLe(lowerSlug.tickUpper, hook.getCurrentTick(poolKey.toId())); + // TODO: Double check this condition + + if (isToken0) { + // Validate that lower slug is not above the current tick + assertLe(lowerSlug.tickUpper, hook.getCurrentTick(poolKey.toId()), "lowerSlug.tickUpper > currentTick"); + } else { + // Validate that lower slug is not below the current tick + assertGe(lowerSlug.tickUpper, hook.getCurrentTick(poolKey.toId()), "lowerSlug.tickUpper < currentTick"); + } // Validate that upper slug and all price discovery slugs are placed continuously - assertEq(upperSlug.tickUpper, priceDiscoverySlugs[0].tickLower); + assertEq( + upperSlug.tickUpper, + priceDiscoverySlugs[0].tickLower, + "upperSlug.tickUpper != priceDiscoverySlugs[0].tickLower" + ); for (uint256 i; i < priceDiscoverySlugs.length; ++i) { if (i == 0) { - assertEq(upperSlug.tickUpper, priceDiscoverySlugs[i].tickLower); + assertEq( + upperSlug.tickUpper, + priceDiscoverySlugs[i].tickLower, + "upperSlug.tickUpper != priceDiscoverySlugs[i].tickLower" + ); } else { - assertEq(priceDiscoverySlugs[i - 1].tickUpper, priceDiscoverySlugs[i].tickLower); + assertEq( + priceDiscoverySlugs[i - 1].tickUpper, + priceDiscoverySlugs[i].tickLower, + "priceDiscoverySlugs[i - 1].tickUpper != priceDiscoverySlugs[i].tickLower" + ); } if (i == priceDiscoverySlugs.length - 1) { @@ -103,29 +105,23 @@ contract RebalanceTest is BaseTest { assertApproxEqAbs( priceDiscoverySlugs[i].tickUpper, tickUpper, - hook.getNumPDSlugs() * uint256(int256(poolKey.tickSpacing)) + hook.getNumPDSlugs() * uint256(int256(poolKey.tickSpacing)), + "priceDiscoverySlugs[i].tickUpper != tickUpper" ); } // Validate that each price discovery slug has liquidity - assertGt(priceDiscoverySlugs[i].liquidity, 0); + assertGt(priceDiscoverySlugs[i].liquidity, 0, "priceDiscoverySlugs[i].liquidity is 0"); } // Validate that the lower slug has liquidity - assertGt(lowerSlug.liquidity, 1e18); + assertGt(lowerSlug.liquidity, 1e18, "lowerSlug no liquidity"); // Validate that the upper slug has very little liquidity (dust) - assertLt(upperSlug.liquidity, 1e18); + assertLt(upperSlug.liquidity, 1e18, "upperSlug has liquidity"); // Validate that we can swap all tokens back into the curve - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(isToken0, -int256(totalTokensSold), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sell(-int256(totalTokensSold)); } function test_rebalance_LowerSlug_SufficientProceeds() public { @@ -133,23 +129,13 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime() + hook.getEpochLength() * 2); PoolKey memory poolKey = key; - bool isToken0 = hook.getIsToken0(); // Compute the expected amount sold to see how many tokens will be supplied in the upper slug // We should always have sufficient proceeds if we don't swap beyond the upper slug uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(1); // We sell half the expected amount to ensure that we don't surpass the upper slug - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(expectedAmountSold / 2), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(expectedAmountSold / 2)); (uint40 lastEpoch,, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); @@ -162,14 +148,7 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime() + hook.getEpochLength() * 3); // Next epoch // We swap again just to trigger the rebalancing logic in the new epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (, int256 tickAccumulator2, uint256 totalTokensSold2,,,) = hook.state(); @@ -192,14 +171,7 @@ contract RebalanceTest is BaseTest { assertGt(lowerSlug.liquidity, 0); // Validate that we can swap all tokens back into the curve - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(isToken0, -int256(totalTokensSold2), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sell(-int256(totalTokensSold2)); } function test_rebalance_LowerSlug_InsufficientProceeds() public { @@ -214,28 +186,12 @@ contract RebalanceTest is BaseTest { uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(2); // We sell 90% of the expected amount so we stay in range but trigger insufficient proceeds case - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(expectedAmountSold * 9 / 10), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(expectedAmountSold * 9 / 10)); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch // We swap again just to trigger the rebalancing logic in the new epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1); (, int256 tickAccumulator, uint256 totalTokensSold,,,) = hook.state(); @@ -294,14 +250,7 @@ contract RebalanceTest is BaseTest { assertGt(amount0Delta, totalTokensSold); // Validate that we can swap all tokens back into the curve - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(isToken0, -int256(totalTokensSold), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sell(-int256(totalTokensSold)); } function test_rebalance_LowerSlug_NoLiquidity() public { @@ -313,14 +262,7 @@ contract RebalanceTest is BaseTest { // We sell some tokens to trigger the initial rebalance // We haven't sold any tokens in previous epochs so we shouldn't place a lower slug - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); // Get the lower slug Position memory lowerSlug = hook.getPositions(bytes32(uint256(1))); @@ -344,28 +286,12 @@ contract RebalanceTest is BaseTest { uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(1); // We sell half the expected amount to ensure that we hit the undersold case - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(expectedAmountSold / 2), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(expectedAmountSold / 2)); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch // We swap again just to trigger the rebalancing logic in the new epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (, int256 tickAccumulator,,,,) = hook.state(); @@ -381,12 +307,21 @@ contract RebalanceTest is BaseTest { (int24 tickLower, int24 tickUpper) = hook.getTicksBasedOnState(tickAccumulator, poolKey.tickSpacing); // Validate that the slugs are continuous and all have liquidity - // TODO: figure out why this is happening - assertEq( - lowerSlug.tickLower, - tickLower - poolKey.tickSpacing, - "tickLower - poolKey.tickSpacing != lowerSlug.tickLower" - ); + // TODO: I tried fixing this using isToken0, not sure if it should work this way though. + if (isToken0) { + assertEq( + lowerSlug.tickLower, + tickLower - poolKey.tickSpacing, + "tickLower - poolKey.tickSpacing != lowerSlug.tickLower" + ); + } else { + assertEq( + lowerSlug.tickLower, + tickLower + poolKey.tickSpacing, + "tickLower + poolKey.tickSpacing != lowerSlug.tickLower" + ); + } + assertEq(lowerSlug.tickUpper, upperSlug.tickLower, "lowerSlug.tickUpper != upperSlug.tickLower"); for (uint256 i; i < priceDiscoverySlugs.length; ++i) { @@ -448,14 +383,7 @@ contract RebalanceTest is BaseTest { bool isToken0 = hook.getIsToken0(); // We sell one wei to trigger the rebalance without messing with resulting liquidity positions - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1); // Get the upper and price discover slugs Position memory upperSlug = hook.getPositions(bytes32(uint256(2))); @@ -465,7 +393,9 @@ contract RebalanceTest is BaseTest { } // Assert that the slugs are continuous - assertEq(hook.getCurrentTick(poolKey.toId()), upperSlug.tickLower); + assertApproxEqAbs( + hook.getCurrentTick(poolKey.toId()), upperSlug.tickLower, 1, "currentTick != upperSlug.tickLower" + ); // We should only have one price discovery slug at this point assertEq(upperSlug.tickUpper, priceDiscoverySlugs[0].tickLower); @@ -513,21 +443,16 @@ contract RebalanceTest is BaseTest { bool isToken0 = hook.getIsToken0(); // We sell one wei to trigger the rebalance without messing with resulting liquidity positions - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1); // Get the upper and price discover slugs Position memory upperSlug = hook.getPositions(bytes32(uint256(2))); Position memory priceDiscoverySlug = hook.getPositions(bytes32(uint256(3))); // Assert that the upperSlug is correctly placed - assertEq(hook.getCurrentTick(poolKey.toId()), upperSlug.tickLower); + assertApproxEqAbs( + hook.getCurrentTick(poolKey.toId()), upperSlug.tickLower, 1, "currentTick != upperSlug.tickLower" + ); // Assert that the priceDiscoverySlug has no liquidity assertEq(priceDiscoverySlug.liquidity, 0); @@ -556,14 +481,8 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime()); PoolKey memory poolKey = key; - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + + buy(1 ether); (uint40 lastEpoch,, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); @@ -577,14 +496,7 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Swap tokens back into the pool, netSold == 0 - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(isToken0, -1 ether, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sell(-1 ether); (uint40 lastEpoch2, int256 tickAccumulator2, uint256 totalTokensSold2,, uint256 totalTokensSoldLastEpoch2,) = hook.state(); @@ -596,17 +508,9 @@ contract RebalanceTest is BaseTest { assertEq(totalTokensSoldLastEpoch2, 1 ether); vm.warp(hook.getStartingTime() + hook.getEpochLength() * 2); // Next epoch - SlugVis.visualizeSlugs(hook, poolKey.toId(), "test", block.timestamp); // We swap again just to trigger the rebalancing logic in the new epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (uint40 lastEpoch3, int256 tickAccumulator3, uint256 totalTokensSold3,, uint256 totalTokensSoldLastEpoch3,) = hook.state(); @@ -667,56 +571,39 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime()); PoolKey memory poolKey = key; - bool isToken0 = hook.getIsToken0(); // Get the expected amount sold by next epoch uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(1); // We sell half the expected amount - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(expectedAmountSold / 2), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(expectedAmountSold / 2)); (uint40 lastEpoch, int256 tickAccumulator, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); - assertEq(lastEpoch, 1); + assertEq(lastEpoch, 1, "Wrong last epoch"); // Confirm we sold half the expected amount - assertEq(totalTokensSold, expectedAmountSold / 2); + assertEq(totalTokensSold, expectedAmountSold / 2, "Wrong tokens sold"); // Previous epoch didn't exist so no tokens would have been sold at the time - assertEq(totalTokensSoldLastEpoch, 0); + assertEq(totalTokensSoldLastEpoch, 0, "Wrong tokens sold last epoch"); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch // We swap again just to trigger the rebalancing logic in the new epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (uint40 lastEpoch2, int256 tickAccumulator2, uint256 totalTokensSold2,, uint256 totalTokensSoldLastEpoch2,) = hook.state(); - assertEq(lastEpoch2, 2); + assertEq(lastEpoch2, 2, "Wrong last epoch (2)"); // We sold some tokens just now - assertEq(totalTokensSold2, expectedAmountSold / 2 + 1e18); + assertEq(totalTokensSold2, expectedAmountSold / 2 + 1e18, "Wrong tokens sold (2)"); // The net sold amount in the previous epoch half the expected amount - assertEq(totalTokensSoldLastEpoch2, expectedAmountSold / 2); + assertEq(totalTokensSoldLastEpoch2, expectedAmountSold / 2, "Wrong tokens sold last epoch (2)"); // Assert that we reduced the accumulator by half the max amount as intended int256 maxTickDeltaPerEpoch = hook.getMaxTickDeltaPerEpoch(); - assertEq(tickAccumulator2, tickAccumulator + maxTickDeltaPerEpoch / 2); + assertEq(tickAccumulator2, tickAccumulator + maxTickDeltaPerEpoch / 2, "Wrong tick accumulator"); // Get positions Position memory lowerSlug = hook.getPositions(bytes32(uint256(1))); @@ -734,13 +621,21 @@ contract RebalanceTest is BaseTest { int24 currentTick = hook.getCurrentTick(poolId); // Slugs must be inline and continuous - assertEq(lowerSlug.tickUpper, upperSlug.tickLower); + assertEq(lowerSlug.tickUpper, upperSlug.tickLower, "Wrong ticks for lower and upper slugs"); for (uint256 i; i < priceDiscoverySlugs.length; ++i) { if (i == 0) { - assertEq(upperSlug.tickUpper, priceDiscoverySlugs[i].tickLower); + assertEq( + upperSlug.tickUpper, + priceDiscoverySlugs[i].tickLower, + "Wrong ticks upperSlug.tickUpper / priceDiscoverySlugs[i].tickLower" + ); } else { - assertEq(priceDiscoverySlugs[i - 1].tickUpper, priceDiscoverySlugs[i].tickLower); + assertEq( + priceDiscoverySlugs[i - 1].tickUpper, + priceDiscoverySlugs[i].tickLower, + "Wrong ticks priceDiscoverySlugs[i - 1].tickUpper / priceDiscoverySlugs[i].tickLower" + ); } if (i == priceDiscoverySlugs.length - 1) { @@ -753,15 +648,15 @@ contract RebalanceTest is BaseTest { } // Validate that each price discovery slug has liquidity - assertGt(priceDiscoverySlugs[i].liquidity, 0); + assertGt(priceDiscoverySlugs[i].liquidity, 0, "Wrong liquidity for price discovery slug"); } // Lower slug upper tick should be at the currentTick - assertEq(lowerSlug.tickUpper, currentTick); + assertEq(lowerSlug.tickUpper, currentTick, "lowerSlug.tickUpper not at currentTick"); // All slugs must be set - assertNotEq(lowerSlug.liquidity, 0); - assertNotEq(upperSlug.liquidity, 0); + assertNotEq(lowerSlug.liquidity, 0, "lowerSlug.liquidity is 0"); + assertNotEq(upperSlug.liquidity, 0, "upperSlug.liquidity is 0"); } function test_rebalance_OversoldCase() public { @@ -774,16 +669,7 @@ contract RebalanceTest is BaseTest { uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(1); // We buy 1.5x the expectedAmountSold - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(expectedAmountSold * 3 / 2), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(expectedAmountSold * 3 / 2)); (uint40 lastEpoch, int256 tickAccumulator, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); @@ -801,14 +687,7 @@ contract RebalanceTest is BaseTest { int24 currentTick = hook.getCurrentTick(poolId); // We swap again just to trigger the rebalancing logic in the new epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (uint40 lastEpoch2, int256 tickAccumulator2, uint256 totalTokensSold2,, uint256 totalTokensSoldLastEpoch2,) = hook.state(); @@ -867,12 +746,8 @@ contract RebalanceTest is BaseTest { } function test_rebalance_CollectsFeeFromAllSlugs() public { - PoolKey memory poolKey = key; - vm.warp(hook.getStartingTime()); - bool isToken0 = hook.getIsToken0(); - (,, uint24 protocolFee, uint24 lpFee) = manager.getSlot0(key.toId()); (,,,,, BalanceDelta feesAccrued) = hook.state(); @@ -889,14 +764,7 @@ contract RebalanceTest is BaseTest { upperSlug.liquidity ) * 9 / 10; - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, -int256(amount1ToSwap), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(-int256(amount1ToSwap)); uint256 amount0ToSwap = LiquidityAmounts.getAmount0ForLiquidity( TickMath.getSqrtPriceAtTick(upperSlug.tickLower), @@ -904,26 +772,12 @@ contract RebalanceTest is BaseTest { upperSlug.liquidity ) * 9 / 10; - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(isToken0, -int256(amount0ToSwap), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(-int256(amount0ToSwap)); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // trigger rebalance to accrue fees - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(isToken0, 1, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1); (,,,,, feesAccrued) = hook.state(); @@ -948,7 +802,6 @@ contract RebalanceTest is BaseTest { function test_rebalance_FullFlow() public { PoolKey memory poolKey = key; - bool isToken0 = hook.getIsToken0(); // Max dutch auction over first few skipped epochs // =============================================== @@ -957,14 +810,8 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime() + hook.getEpochLength() * 3); // Swap less then expected amount - to be used checked in the next epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + + buy(1 ether); (uint40 lastEpoch, int256 tickAccumulator, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); @@ -1021,7 +868,7 @@ contract RebalanceTest is BaseTest { // Lower slug should be unset with ticks at the current price assertEq(lowerSlug.tickLower, lowerSlug.tickUpper, "first swap: lowerSlug.tickLower != lowerSlug.tickUpper"); assertEq(lowerSlug.liquidity, 0, "first swap: lowerSlug.liquidity != 0"); - assertEq(lowerSlug.tickUpper, currentTick, "first swap: lowerSlug.tickUpper != currentTick"); + assertApproxEqAbs(lowerSlug.tickUpper, currentTick, 1, "first swap: lowerSlug.tickUpper != currentTick"); // Upper and price discovery slugs must be set assertNotEq(upperSlug.liquidity, 0, "first swap: upperSlug.liquidity != 0"); @@ -1036,16 +883,7 @@ contract RebalanceTest is BaseTest { uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(1); // Trigger the oversold case by selling more than expected - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(expectedAmountSold), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(expectedAmountSold)); (uint40 lastEpoch2, int256 tickAccumulator2, uint256 totalTokensSold2,, uint256 totalTokensSoldLastEpoch2,) = hook.state(); @@ -1116,14 +954,7 @@ contract RebalanceTest is BaseTest { currentTick = hook.getCurrentTick(poolId); // Trigger rebalance - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (uint40 lastEpoch3, int256 tickAccumulator3, uint256 totalTokensSold3,, uint256 totalTokensSoldLastEpoch3,) = hook.state(); @@ -1153,8 +984,13 @@ contract RebalanceTest is BaseTest { // Get current tick currentTick = hook.getCurrentTick(poolId); - // Lower slug must not be above current tick - assertLe(lowerSlug.tickUpper, currentTick, "third swap: lowerSlug.tickUpper > currentTick"); + if (isToken0) { + // Lower slug must not be above current tick + assertLe(lowerSlug.tickUpper, currentTick, "third swap: lowerSlug.tickUpper > currentTick"); + } else { + // Lower slug must not be below current tick + assertGe(lowerSlug.tickUpper, currentTick, "third swap: lowerSlug.tickUpper < currentTick"); + } // Upper slugs must be inline and continuous for (uint256 i; i < priceDiscoverySlugs.length; ++i) { @@ -1182,14 +1018,7 @@ contract RebalanceTest is BaseTest { assertNotEq(upperSlug.liquidity, 0, "third swap: upperSlug.liquidity != 0"); // Validate that we can swap all tokens back into the curve - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(isToken0, -int256(totalTokensSold3), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sell(-int256(totalTokensSold3)); // Swap in second last epoch // ======================== @@ -1201,14 +1030,7 @@ contract RebalanceTest is BaseTest { ); // Swap some tokens - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (, int256 tickAccumulator4,,,,) = hook.state(); @@ -1225,8 +1047,13 @@ contract RebalanceTest is BaseTest { // Get current tick currentTick = hook.getCurrentTick(poolId); - // Lower slug must not be greater than current tick - assertLe(lowerSlug.tickUpper, currentTick, "fourth swap: lowerSlug.tickUpper > currentTick"); + if (isToken0) { + // Lower slug must not be greater than current tick + assertLe(lowerSlug.tickUpper, currentTick, "fourth swap: lowerSlug.tickUpper > currentTick"); + } else { + // Lower slug must not be less than current tick + assertGe(lowerSlug.tickUpper, currentTick, "fourth swap: lowerSlug.tickUpper < currentTick"); + } // Upper slugs must be inline and continuous // In this case we only have one price discovery slug since we're on the second last epoch @@ -1249,14 +1076,7 @@ contract RebalanceTest is BaseTest { ); // Swap some tokens - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (, int256 tickAccumulator5,,,,) = hook.state(); @@ -1275,11 +1095,19 @@ contract RebalanceTest is BaseTest { // Slugs must be inline and continuous if (currentTick == tickLower) { - assertEq( - tickLower - poolKey.tickSpacing, - lowerSlug.tickLower, - "fifth swap: lowerSlug.tickLower != global tickLower" - ); + if (isToken0) { + assertEq( + tickLower - poolKey.tickSpacing, + lowerSlug.tickLower, + "fifth swap: lowerSlug.tickLower != global tickLower" + ); + } else { + assertEq( + tickLower + poolKey.tickSpacing, + lowerSlug.tickLower, + "fifth swap: lowerSlug.tickUpper != global tickLower" + ); + } } else { assertEq(tickLower, lowerSlug.tickLower, "fifth swap: lowerSlug.tickUpper != global tickLower"); } @@ -1308,16 +1136,7 @@ contract RebalanceTest is BaseTest { (,, uint256 totalTokensSold4,,,) = hook.state(); // Swap all remaining tokens - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(numTokensToSell - totalTokensSold4), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(numTokensToSell - totalTokensSold4)); (, int256 tickAccumulator6,,,,) = hook.state(); diff --git a/test/invariant/DopplerHandler.sol b/test/invariant/DopplerHandler.sol index b574023c..1bf27057 100644 --- a/test/invariant/DopplerHandler.sol +++ b/test/invariant/DopplerHandler.sol @@ -5,18 +5,24 @@ import {Test} from "forge-std/Test.sol"; import {AddressSet, LibAddressSet} from "./AddressSet.sol"; import {DopplerImplementation} from "test/shared/DopplerImplementation.sol"; import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; +import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; import {TestERC20} from "v4-core/src/test/TestERC20.sol"; import {PoolKey} from "v4-core/src/types/PoolKey.sol"; import {Currency} from "v4-core/src/types/Currency.sol"; +import {CustomRouter} from "test/shared/CustomRouter.sol"; contract DopplerHandler is Test { using LibAddressSet for AddressSet; PoolKey public poolKey; DopplerImplementation public hook; - PoolSwapTest public swapRouter; + CustomRouter public router; TestERC20 public token0; TestERC20 public token1; + TestERC20 public numeraire; + TestERC20 public asset; + bool public isToken0; + bool public isUsingEth; uint256 public ghost_reserve0; uint256 public ghost_reserve1; @@ -48,17 +54,43 @@ contract DopplerHandler is Test { _; } - constructor(PoolKey memory poolKey_, DopplerImplementation hook_, PoolSwapTest swapRouter_) { + constructor( + PoolKey memory poolKey_, + DopplerImplementation hook_, + CustomRouter router_, + bool isToken0_, + bool isUsingEth_ + ) { poolKey = poolKey_; hook = hook_; - swapRouter = swapRouter_; + router = router_; + isToken0 = isToken0_; + isUsingEth = isUsingEth_; token0 = TestERC20(Currency.unwrap(poolKey.currency0)); token1 = TestERC20(Currency.unwrap(poolKey.currency1)); + if (isToken0) { + numeraire = token0; + asset = token1; + } else { + numeraire = token1; + asset = token0; + } + ghost_reserve0 = token0.balanceOf(address(hook)); ghost_reserve1 = token1.balanceOf(address(hook)); } - function buyExactAmount(uint256 amount) public createActor countCall(this.buyExactAmount.selector) {} + /// @notice Buys an amount of asset tokens using an exact amount of numeraire tokens + function buyExactAmountIn(uint256 amount) public createActor countCall(this.buyExactAmountIn.selector) { + if (isUsingEth) { + deal(currentActor, amount); + } else { + numeraire.mint(currentActor, amount); + numeraire.approve(address(router), amount); + } + + uint256 bought = router.buyExactIn{value: isUsingEth ? amount : 0}(amount); + } } diff --git a/test/invariant/DopplerInvariants.sol b/test/invariant/DopplerInvariants.sol index ac6f127b..69127401 100644 --- a/test/invariant/DopplerInvariants.sol +++ b/test/invariant/DopplerInvariants.sol @@ -1,16 +1,32 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; +import {console} from "forge-std/console.sol"; import {BaseTest} from "test/shared/BaseTest.sol"; +import {DopplerHandler} from "test/invariant/DopplerHandler.sol"; contract DopplerInvariantsTest is BaseTest { + DopplerHandler public handler; + function setUp() public override { super.setUp(); + handler = new DopplerHandler(key, hook, router, isToken0, usingEth); + + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = handler.buyExactAmountIn.selector; + + targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); + targetContract(address(handler)); } - function afterInvariant() public view {} + function afterInvariant() public view { + console.log("Handler address", address(handler)); + console.log("Calls: ", handler.totalCalls()); + console.log("buyExactAmountIn: ", handler.calls(handler.buyExactAmountIn.selector)); + } - function invariant_works() public pure { + /// forge-config: default.invariant.fail-on-revert = true + function invariant_works() public { assertTrue(true); } } diff --git a/test/shared/BaseTest.sol b/test/shared/BaseTest.sol index c3464984..9529381c 100644 --- a/test/shared/BaseTest.sol +++ b/test/shared/BaseTest.sol @@ -7,17 +7,21 @@ import {Deployers} from "v4-core/test/utils/Deployers.sol"; import {TestERC20} from "v4-core/src/test/TestERC20.sol"; import {PoolId, PoolIdLibrary} from "v4-periphery/lib/v4-core/src/types/PoolId.sol"; import {PoolKey} from "v4-periphery/lib/v4-core/src/types/PoolKey.sol"; -import {PoolManager} from "v4-core/src/PoolManager.sol"; +import {PoolManager, IPoolManager} from "v4-core/src/PoolManager.sol"; import {Hooks} from "v4-core/src/libraries/Hooks.sol"; import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; import {Currency} from "v4-periphery/lib/v4-core/src/types/Currency.sol"; import {TickMath} from "v4-core/src/libraries/TickMath.sol"; import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol"; +import {Quoter, IQuoter} from "v4-periphery/src/lens/Quoter.sol"; +import {BalanceDelta, BalanceDeltaLibrary} from "v4-core/src/types/BalanceDelta.sol"; +import {CustomRouter} from "test/shared/CustomRouter.sol"; import {DopplerImplementation} from "./DopplerImplementation.sol"; using PoolIdLibrary for PoolKey; +using BalanceDeltaLibrary for BalanceDelta; contract BaseTest is Test, Deployers { // TODO: Maybe add the start and end ticks to the config? @@ -73,21 +77,24 @@ contract BaseTest is Test, Deployers { // Context DopplerImplementation hook = DopplerImplementation( - address( - uint160( - Hooks.BEFORE_ADD_LIQUIDITY_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG - | Hooks.AFTER_INITIALIZE_FLAG - ) ^ (0x4444 << 144) + payable( + address( + uint160( + Hooks.BEFORE_ADD_LIQUIDITY_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG + | Hooks.AFTER_INITIALIZE_FLAG + ) ^ (0x4444 << 144) + ) ) ); - TestERC20 asset; - TestERC20 numeraire; - TestERC20 token0; - TestERC20 token1; + address asset; + address numeraire; + address token0; + address token1; PoolId poolId; bool isToken0; + bool usingEth; int24 startTick; int24 endTick; @@ -96,6 +103,11 @@ contract BaseTest is Test, Deployers { address alice = address(0xa71c3); address bob = address(0xb0b); + // Contracts + + Quoter quoter; + CustomRouter router; + // Deploy functions /// @dev Deploys a new pair of asset and numeraire tokens and the related Doppler hook @@ -107,7 +119,7 @@ contract BaseTest is Test, Deployers { /// @dev Reuses an existing pair of asset and numeraire tokens and deploys the related /// Doppler hook with the default configuration. - function _deploy(TestERC20 asset_, TestERC20 numeraire_) public { + function _deploy(address asset_, address numeraire_) public { asset = asset_; numeraire = numeraire_; _deployDoppler(); @@ -122,7 +134,7 @@ contract BaseTest is Test, Deployers { /// @dev Reuses an existing pair of asset and numeraire tokens and deploys the related Doppler /// hook with a given configuration. - function _deploy(TestERC20 asset_, TestERC20 numeraire_, DopplerConfig memory config) public { + function _deploy(address asset_, address numeraire_, DopplerConfig memory config) public { asset = asset_; numeraire = numeraire_; _deployDoppler(config); @@ -131,10 +143,25 @@ contract BaseTest is Test, Deployers { /// @dev Deploys a new pair of asset and numeraire tokens. function _deployTokens() public { isToken0 = vm.envOr("IS_TOKEN_0", true); - deployCodeTo("TestERC20.sol:TestERC20", abi.encode(2 ** 128), isToken0 ? address(TOKEN_A) : address(TOKEN_B)); - deployCodeTo("TestERC20.sol:TestERC20", abi.encode(2 ** 128), isToken0 ? address(TOKEN_B) : address(TOKEN_A)); - asset = TestERC20(isToken0 ? address(TOKEN_A) : address(TOKEN_B)); - numeraire = TestERC20(isToken0 ? address(TOKEN_B) : address(TOKEN_A)); + usingEth = vm.envOr("USING_ETH", false); + + if (usingEth) { + isToken0 = false; + deployCodeTo("TestERC20.sol:TestERC20", abi.encode(2 ** 128), address(TOKEN_B)); + token0 = address(0); + token1 = address(TOKEN_B); + numeraire = token0; + asset = token1; + } else { + deployCodeTo( + "TestERC20.sol:TestERC20", abi.encode(2 ** 128), isToken0 ? address(TOKEN_A) : address(TOKEN_B) + ); + deployCodeTo( + "TestERC20.sol:TestERC20", abi.encode(2 ** 128), isToken0 ? address(TOKEN_B) : address(TOKEN_A) + ); + asset = isToken0 ? TOKEN_A : TOKEN_B; + numeraire = isToken0 ? TOKEN_B : TOKEN_A; + } } /// @dev Deploys a new Doppler hook with the default configuration. @@ -148,7 +175,7 @@ contract BaseTest is Test, Deployers { vm.label(address(token0), "Token0"); vm.label(address(token1), "Token1"); - (isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); + TestERC20(asset).transfer(address(hook), config.numTokensToSell); // isToken0 ? startTick > endTick : endTick > startTick // In both cases, price(startTick) > price(endTick) @@ -213,10 +240,111 @@ contract BaseTest is Test, Deployers { // Note: Only used to validate that liquidity can't be manually modified modifyLiquidityRouter = new PoolModifyLiquidityTest(manager); - // Approve the router to spend tokens on behalf of the test contract - token0.approve(address(swapRouter), type(uint256).max); - token1.approve(address(swapRouter), type(uint256).max); - token0.approve(address(modifyLiquidityRouter), type(uint256).max); - token1.approve(address(modifyLiquidityRouter), type(uint256).max); + if (token0 != address(0)) { + // Approve the router to spend tokens on behalf of the test contract + TestERC20(token0).approve(address(swapRouter), type(uint256).max); + TestERC20(token0).approve(address(modifyLiquidityRouter), type(uint256).max); + } + TestERC20(token1).approve(address(swapRouter), type(uint256).max); + TestERC20(token1).approve(address(modifyLiquidityRouter), type(uint256).max); + + quoter = new Quoter(manager); + + router = new CustomRouter(swapRouter, quoter, key, isToken0, usingEth); + } + + function computeBuyExactOut(uint256 amountOut) public returns (uint256) { + (int128[] memory deltaAmounts,,) = quoter.quoteExactOutputSingle( + IQuoter.QuoteExactSingleParams({ + poolKey: key, + zeroForOne: !isToken0, + exactAmount: uint128(amountOut), + sqrtPriceLimitX96: !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT, + hookData: "" + }) + ); + + return uint256(uint128(deltaAmounts[0])); + } + + function computeSellExactOut(uint256 amountOut) public returns (uint256) { + (int128[] memory deltaAmounts,,) = quoter.quoteExactOutputSingle( + IQuoter.QuoteExactSingleParams({ + poolKey: key, + zeroForOne: isToken0, + exactAmount: uint128(amountOut), + sqrtPriceLimitX96: isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT, + hookData: "" + }) + ); + + return uint256(uint128(deltaAmounts[0])); + } + + function buyExactIn(uint256 amount) public { + buy(-int256(amount)); + } + + function buyExactOut(uint256 amount) public { + buy(int256(amount)); + } + + function sellExactIn(uint256 amount) public { + sell(-int256(amount)); + } + + function sellExactOut(uint256 amount) public { + sell(int256(amount)); + } + + /// @dev Buys a given amount of asset tokens. + /// @param amount A negative value specificies the amount of numeraire tokens to spend, + /// a positive value specifies the amount of asset tokens to buy. + /// @return Amount of asset tokens bought. + /// @return Amount of numeraire tokens used. + function buy(int256 amount) public returns (uint256, uint256) { + // Negative means exactIn, positive means exactOut. + uint256 mintAmount = amount < 0 ? uint256(-amount) : computeBuyExactOut(uint256(amount)); + + if (usingEth) { + deal(address(this), uint256(mintAmount)); + } else { + TestERC20(numeraire).mint(address(this), uint256(mintAmount)); + TestERC20(numeraire).approve(address(swapRouter), uint256(mintAmount)); + } + + BalanceDelta delta = swapRouter.swap{value: usingEth ? mintAmount : 0}( + key, + IPoolManager.SwapParams(!isToken0, amount, isToken0 ? MAX_PRICE_LIMIT : MIN_PRICE_LIMIT), + PoolSwapTest.TestSettings(false, false), + "" + ); + + uint256 delta0 = uint256(int256(delta.amount0() < 0 ? -delta.amount0() : delta.amount0())); + uint256 delta1 = uint256(int256(delta.amount1() < 0 ? -delta.amount1() : delta.amount1())); + + return isToken0 ? (delta0, delta1) : (delta1, delta0); + } + + /// @dev Sells a given amount of asset tokens. + /// @param amount A negative value specificies the amount of asset tokens to sell, a positive value + /// specifies the amount of numeraire tokens to receive. + /// @return Amount of asset tokens sold. + /// @return Amount of numeraire tokens received. + function sell(int256 amount) public returns (uint256, uint256) { + uint256 approveAmount = amount < 0 ? uint256(-amount) : computeSellExactOut(uint256(amount)); + TestERC20(asset).approve(address(swapRouter), uint256(approveAmount)); + + BalanceDelta delta = swapRouter.swap( + key, + IPoolManager.SwapParams(isToken0, amount, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(false, false), + "" + ); + + uint256 delta0 = uint256(int256(delta.amount0() < 0 ? -delta.amount0() : delta.amount0())); + uint256 delta1 = uint256(int256(delta.amount1() < 0 ? -delta.amount1() : delta.amount1())); + + return isToken0 ? (delta0, delta1) : (delta1, delta0); } } diff --git a/test/shared/CustomRouter.sol b/test/shared/CustomRouter.sol new file mode 100644 index 00000000..b2167bb9 --- /dev/null +++ b/test/shared/CustomRouter.sol @@ -0,0 +1,191 @@ +/// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {TestERC20} from "v4-core/src/test/TestERC20.sol"; +import {PoolKey} from "v4-periphery/lib/v4-core/src/types/PoolKey.sol"; +import {IPoolManager} from "v4-core/src/PoolManager.sol"; +import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; +import {Quoter, IQuoter} from "v4-periphery/src/lens/Quoter.sol"; +import {BalanceDelta, BalanceDeltaLibrary} from "v4-core/src/types/BalanceDelta.sol"; +import {TickMath} from "v4-core/src/libraries/TickMath.sol"; +import {Currency} from "v4-core/src/types/Currency.sol"; + +uint160 constant MIN_PRICE_LIMIT = TickMath.MIN_SQRT_PRICE + 1; +uint160 constant MAX_PRICE_LIMIT = TickMath.MAX_SQRT_PRICE - 1; + +/// @notice Just a custom router contract for testing purposes, I wanted to have +/// a way to reuse the same functions in the BaseTest contract and the DopplerHandler. +contract CustomRouter is Test { + using BalanceDeltaLibrary for BalanceDelta; + + PoolSwapTest public swapRouter; + Quoter public quoter; + PoolKey public key; + bool public isToken0; + bool public isUsingEth; + address public numeraire; + address public asset; + + constructor(PoolSwapTest swapRouter_, Quoter quoter_, PoolKey memory key_, bool isToken0_, bool isUsingEth_) { + swapRouter = swapRouter_; + quoter = quoter_; + key = key_; + isToken0 = isToken0_; + isUsingEth = isUsingEth_; + + asset = isToken0 ? Currency.unwrap(key.currency0) : Currency.unwrap(key.currency1); + numeraire = isToken0 ? Currency.unwrap(key.currency1) : Currency.unwrap(key.currency0); + } + + function computeBuyExactOut(uint256 amountOut) public returns (uint256) { + (int128[] memory deltaAmounts,,) = quoter.quoteExactOutputSingle( + IQuoter.QuoteExactSingleParams({ + poolKey: key, + zeroForOne: !isToken0, + exactAmount: uint128(amountOut), + sqrtPriceLimitX96: !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT, + hookData: "" + }) + ); + + return uint256(uint128(deltaAmounts[0])); + } + + function computeSellExactOut(uint256 amountOut) public returns (uint256) { + (int128[] memory deltaAmounts,,) = quoter.quoteExactOutputSingle( + IQuoter.QuoteExactSingleParams({ + poolKey: key, + zeroForOne: isToken0, + exactAmount: uint128(amountOut), + sqrtPriceLimitX96: isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT, + hookData: "" + }) + ); + + return uint256(uint128(deltaAmounts[0])); + } + + /// @notice Buys asset tokens using an exact amount of numeraire tokens. + /// @return bought Amount of asset tokens bought. + function buyExactIn(uint256 amount) public payable returns (uint256 bought) { + (bought,) = buy(-int256(amount)); + } + + /// @notice Buys an exact amount of asset tokens using numeraire tokens. + function buyExactOut(uint256 amount) public payable returns (uint256 spent) { + (, spent) = buy(int256(amount)); + } + + /// @notice Sells an exact amount of asset tokens for numeraire tokens. + /// @return received Amount of numeraire tokens received. + function sellExactIn(uint256 amount) public returns (uint256 received) { + (, received) = sell(-int256(amount)); + } + + /// @notice Sells asset tokens for an exact amount of numeraire tokens. + /// @return sold Amount of asset tokens sold. + function sellExactOut(uint256 amount) public returns (uint256 sold) { + (sold,) = sell(int256(amount)); + } + + /// @dev Buys a given amount of asset tokens. + /// @param amount A negative value specificies the amount of numeraire tokens to spend, + /// a positive value specifies the amount of asset tokens to buy. + /// @return Amount of asset tokens bought. + /// @return Amount of numeraire tokens used. + function mintAndBuy(int256 amount) public returns (uint256, uint256) { + // Negative means exactIn, positive means exactOut. + uint256 mintAmount = amount < 0 ? uint256(-amount) : computeBuyExactOut(uint256(amount)); + + // TODO: Not sure if minting should be done in here, it might be better to mint in the tests. + if (isUsingEth) { + deal(address(this), uint256(mintAmount)); + } else { + TestERC20(numeraire).mint(address(this), uint256(mintAmount)); + TestERC20(numeraire).approve(address(swapRouter), uint256(mintAmount)); + } + + BalanceDelta delta = swapRouter.swap{value: isUsingEth ? mintAmount : 0}( + key, + IPoolManager.SwapParams(!isToken0, amount, isToken0 ? MAX_PRICE_LIMIT : MIN_PRICE_LIMIT), + PoolSwapTest.TestSettings(false, false), + "" + ); + + uint256 delta0 = uint256(int256(delta.amount0() < 0 ? -delta.amount0() : delta.amount0())); + uint256 delta1 = uint256(int256(delta.amount1() < 0 ? -delta.amount1() : delta.amount1())); + + uint256 bought = isToken0 ? delta0 : delta1; + uint256 spent = isToken0 ? delta1 : delta0; + + TestERC20(asset).transfer(msg.sender, bought); + + return (bought, spent); + } + + /// @dev Buys a given amount of asset tokens. + /// @param amount A negative value specificies the amount of numeraire tokens to spend, + /// a positive value specifies the amount of asset tokens to buy. + /// @return Amount of asset tokens bought. + /// @return Amount of numeraire tokens used. + function buy(int256 amount) public payable returns (uint256, uint256) { + // Negative means exactIn, positive means exactOut. + uint256 transferAmount = amount < 0 ? uint256(-amount) : computeBuyExactOut(uint256(amount)); + + if (isUsingEth) { + require(msg.value == transferAmount, "Incorrect amount of ETH sent"); + } else { + TestERC20(numeraire).transferFrom(msg.sender, address(this), transferAmount); + TestERC20(numeraire).approve(address(swapRouter), transferAmount); + } + + BalanceDelta delta = swapRouter.swap{value: isUsingEth ? transferAmount : 0}( + key, + IPoolManager.SwapParams(!isToken0, amount, isToken0 ? MAX_PRICE_LIMIT : MIN_PRICE_LIMIT), + PoolSwapTest.TestSettings(false, false), + "" + ); + + uint256 delta0 = uint256(int256(delta.amount0() < 0 ? -delta.amount0() : delta.amount0())); + uint256 delta1 = uint256(int256(delta.amount1() < 0 ? -delta.amount1() : delta.amount1())); + + uint256 bought = isToken0 ? delta0 : delta1; + uint256 spent = isToken0 ? delta1 : delta0; + + TestERC20(asset).transfer(msg.sender, bought); + + return (bought, spent); + } + + /// @dev Sells a given amount of asset tokens. + /// @param amount A negative value specificies the amount of asset tokens to sell, a positive value + /// specifies the amount of numeraire tokens to receive. + /// @return Amount of asset tokens sold. + /// @return Amount of numeraire tokens received. + function sell(int256 amount) public returns (uint256, uint256) { + uint256 approveAmount = amount < 0 ? uint256(-amount) : computeSellExactOut(uint256(amount)); + TestERC20(asset).approve(address(swapRouter), uint256(approveAmount)); + + BalanceDelta delta = swapRouter.swap( + key, + IPoolManager.SwapParams(isToken0, amount, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(false, false), + "" + ); + + uint256 delta0 = uint256(int256(delta.amount0() < 0 ? -delta.amount0() : delta.amount0())); + uint256 delta1 = uint256(int256(delta.amount1() < 0 ? -delta.amount1() : delta.amount1())); + + uint256 sold = isToken0 ? delta0 : delta1; + uint256 received = isToken0 ? delta1 : delta0; + + if (isUsingEth) { + payable(address(msg.sender)).transfer(received); + } else { + TestERC20(numeraire).transfer(msg.sender, received); + } + + return (sold, received); + } +} diff --git a/test/unit/Constructor.t.sol b/test/unit/Constructor.t.sol index 7dc53f70..011b28d9 100644 --- a/test/unit/Constructor.t.sol +++ b/test/unit/Constructor.t.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.26; import {BaseTest} from "test/shared/BaseTest.sol"; import {DopplerImplementation} from "test/shared/DopplerImplementation.sol"; +import {TestERC20} from "test/shared/BaseTest.sol"; import { MAX_TICK_SPACING, MAX_PRICE_DISCOVERY_SLUGS, @@ -40,7 +41,7 @@ contract ConstructorTest is BaseTest { isToken0 = _isToken0; (token0, token1) = isToken0 ? (asset, numeraire) : (numeraire, asset); - (isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); + TestERC20(isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); vm.label(address(token0), "Token0"); vm.label(address(token1), "Token1"); diff --git a/test/unit/EarlyExit.t.sol b/test/unit/EarlyExit.t.sol index 773867da..753ed695 100644 --- a/test/unit/EarlyExit.t.sol +++ b/test/unit/EarlyExit.t.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.26; import {Test} from "forge-std/Test.sol"; import {BaseTest} from "test/shared/BaseTest.sol"; +import {TestERC20} from "v4-core/src/test/TestERC20.sol"; import {DopplerImplementation} from "test/shared/DopplerImplementation.sol"; import {IPoolManager} from "v4-periphery/lib/v4-core/src/interfaces/IPoolManager.sol"; import {PoolId, PoolIdLibrary} from "v4-periphery/lib/v4-core/src/types/PoolId.sol"; @@ -27,7 +28,7 @@ contract EarlyExitTest is BaseTest { function deployDoppler(DopplerConfig memory config) internal { (token0, token1) = isToken0 ? (asset, numeraire) : (numeraire, asset); - (isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); + TestERC20(isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); vm.label(address(token0), "Token0"); vm.label(address(token1), "Token1"); @@ -71,10 +72,10 @@ contract EarlyExitTest is BaseTest { modifyLiquidityRouter = new PoolModifyLiquidityTest(manager); // Approve the router to spend tokens on behalf of the test contract - token0.approve(address(swapRouter), type(uint256).max); - token1.approve(address(swapRouter), type(uint256).max); - token0.approve(address(modifyLiquidityRouter), type(uint256).max); - token1.approve(address(modifyLiquidityRouter), type(uint256).max); + TestERC20(token0).approve(address(swapRouter), type(uint256).max); + TestERC20(token1).approve(address(swapRouter), type(uint256).max); + TestERC20(token0).approve(address(modifyLiquidityRouter), type(uint256).max); + TestERC20(token1).approve(address(modifyLiquidityRouter), type(uint256).max); } function test_swap_RevertsIfMaximumProceedsReached() public { @@ -87,12 +88,7 @@ contract EarlyExitTest is BaseTest { int256 maximumProceeds = int256(hook.getMaximumProceeds()); - swapRouter.swap( - key, - IPoolManager.SwapParams(!isToken0, -maximumProceeds, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(-maximumProceeds); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch diff --git a/test/unit/Receive.t.sol b/test/unit/Receive.t.sol new file mode 100644 index 00000000..9fd26deb --- /dev/null +++ b/test/unit/Receive.t.sol @@ -0,0 +1,21 @@ +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; + +import {IPoolManager} from "v4-periphery/lib/v4-core/src/interfaces/IPoolManager.sol"; +import {Hooks} from "v4-core/src/libraries/Hooks.sol"; +import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; +import {Hooks} from "v4-core/src/libraries/Hooks.sol"; +import {PoolId, PoolIdLibrary} from "v4-periphery/lib/v4-core/src/types/PoolId.sol"; +import {PoolKey} from "v4-periphery/lib/v4-core/src/types/PoolKey.sol"; +import {StateLibrary} from "v4-periphery/lib/v4-core/src/libraries/StateLibrary.sol"; + +import {InvalidTime, SwapBelowRange} from "src/Doppler.sol"; +import {BaseTest} from "test/shared/BaseTest.sol"; +import {Position} from "../../src/Doppler.sol"; + +contract ReceiveTest is BaseTest { + function test_receive() public { + payable(address(hook)).transfer(1 ether); + } +} diff --git a/test/unit/SlugVis.t.sol b/test/unit/SlugVis.t.sol index b17f8587..e48820de 100644 --- a/test/unit/SlugVis.t.sol +++ b/test/unit/SlugVis.t.sol @@ -19,16 +19,8 @@ contract SlugVisTest is BaseTest { vm.warp(hook.getStartingTime()); PoolKey memory poolKey = key; - bool isToken0 = hook.getIsToken0(); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); SlugVis.visualizeSlugs(hook, poolKey.toId(), "test", block.timestamp); } @@ -37,16 +29,8 @@ contract SlugVisTest is BaseTest { vm.warp(hook.getStartingTime()); PoolKey memory poolKey = key; - bool isToken0 = hook.getIsToken0(); - - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + + buy(1); SlugVis.visualizeSlugs(hook, poolKey.toId(), "test", block.timestamp); } diff --git a/test/unit/Swap.sol b/test/unit/Swap.sol index 7fed29fd..b7837fcd 100644 --- a/test/unit/Swap.sol +++ b/test/unit/Swap.sol @@ -1,13 +1,9 @@ pragma solidity 0.8.26; -import {Test} from "forge-std/Test.sol"; - import {MAX_SWAP_FEE} from "src/Doppler.sol"; import {IPoolManager} from "v4-periphery/lib/v4-core/src/interfaces/IPoolManager.sol"; import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; import {Hooks} from "v4-core/src/libraries/Hooks.sol"; -import {PoolId, PoolIdLibrary} from "v4-periphery/lib/v4-core/src/types/PoolId.sol"; -import {PoolKey} from "v4-periphery/lib/v4-core/src/types/PoolKey.sol"; import {ProtocolFeeLibrary} from "v4-periphery/lib/v4-core/src/libraries/ProtocolFeeLibrary.sol"; import {StateLibrary} from "v4-periphery/lib/v4-core/src/libraries/StateLibrary.sol"; import {FullMath} from "v4-periphery/lib/v4-core/src/libraries/FullMath.sol"; @@ -18,10 +14,8 @@ import { InvalidSwapAfterMaturitySufficientProceeds } from "src/Doppler.sol"; import {BaseTest} from "test/shared/BaseTest.sol"; -import {Position} from "../../src/Doppler.sol"; contract SwapTest is BaseTest { - using PoolIdLibrary for PoolKey; using StateLibrary for IPoolManager; using ProtocolFeeLibrary for *; @@ -150,26 +144,12 @@ contract SwapTest is BaseTest { function test_swap_DoesNotRebalanceTwiceInSameEpoch() public { vm.warp(hook.getStartingTime()); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (uint40 lastEpoch, int256 tickAccumulator, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (uint40 lastEpoch2, int256 tickAccumulator2, uint256 totalTokensSold2,, uint256 totalTokensSoldLastEpoch2,) = hook.state(); @@ -186,14 +166,7 @@ contract SwapTest is BaseTest { function test_swap_UpdatesLastEpoch() public { vm.warp(hook.getStartingTime()); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (uint40 lastEpoch,,,,,) = hook.state(); @@ -201,14 +174,7 @@ contract SwapTest is BaseTest { vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (lastEpoch,,,,,) = hook.state(); @@ -218,25 +184,11 @@ contract SwapTest is BaseTest { function test_swap_UpdatesTotalTokensSoldLastEpoch() public { vm.warp(hook.getStartingTime()); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (,, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); @@ -253,12 +205,7 @@ contract SwapTest is BaseTest { uint256 amountInLessFee = FullMath.mulDiv(uint256(amountIn), MAX_SWAP_FEE - swapFee, MAX_SWAP_FEE); - swapRouter.swap( - key, - IPoolManager.SwapParams(!isToken0, -amountIn, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(-amountIn); (,, uint256 totalTokensSold, uint256 totalProceeds,,) = hook.state(); @@ -266,12 +213,7 @@ contract SwapTest is BaseTest { amountInLessFee = FullMath.mulDiv(uint256(totalTokensSold), MAX_SWAP_FEE - swapFee, MAX_SWAP_FEE); - swapRouter.swap( - key, - IPoolManager.SwapParams(isToken0, -int256(totalTokensSold), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sell(-int256(totalTokensSold)); (,, uint256 totalTokensSold2,,,) = hook.state(); @@ -300,34 +242,20 @@ contract SwapTest is BaseTest { function test_swap_CannotSwapBelowLowerSlug_AfterSoldAndUnsold() public { vm.warp(hook.getStartingTime()); - // Sell some tokens - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch // Swap to trigger lower slug being created // Unsell half of sold tokens - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(isToken0, -0.5 ether, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sell(-0.5 ether); vm.expectRevert( abi.encodeWithSelector( Hooks.Wrap__FailedHookCall.selector, hook, abi.encodeWithSelector(SwapBelowRange.selector) ) ); + // Unsell beyond remaining tokens, moving price below lower slug swapRouter.swap( // Swap asset to numeraire From d12bc45a228390c63a0ff71fee743e604673ef80 Mon Sep 17 00:00:00 2001 From: Matt Czernik Date: Wed, 23 Oct 2024 15:33:33 -0400 Subject: [PATCH 2/4] Revert "Feat/eth support 2 (#133)" (#159) This reverts commit 343668f6c667aecac18c7dae0493eb5cbc47697d. --- .github/workflows/test.yml | 1 + src/Doppler.sol | 22 +- test/integration/Rebalance.t.sol | 447 +++++++++++++++++++-------- test/invariant/DopplerHandler.sol | 40 +-- test/invariant/DopplerInvariants.sol | 20 +- test/shared/BaseTest.sol | 172 ++--------- test/shared/CustomRouter.sol | 191 ------------ test/unit/Constructor.t.sol | 3 +- test/unit/EarlyExit.t.sol | 18 +- test/unit/Receive.t.sol | 21 -- test/unit/SlugVis.t.sol | 22 +- test/unit/Swap.sol | 94 +++++- 12 files changed, 469 insertions(+), 582 deletions(-) delete mode 100644 test/shared/CustomRouter.sol delete mode 100644 test/unit/Receive.t.sol diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2d04cbdf..4fb4dec5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,3 +56,4 @@ jobs: export PROTOCOL_FEE=50 forge test -vvv --via-ir id: test2 + diff --git a/src/Doppler.sol b/src/Doppler.sol index 8473f96d..dd299b83 100644 --- a/src/Doppler.sol +++ b/src/Doppler.sol @@ -78,8 +78,6 @@ contract Doppler is BaseHook { bool immutable isToken0; // whether token0 is the token being sold (true) or token1 (false) uint256 immutable numPDSlugs; // number of price discovery slugs - receive() external payable {} - constructor( IPoolManager _poolManager, PoolKey memory _poolKey, @@ -728,8 +726,8 @@ contract Doppler is BaseHook { (BalanceDelta positionDeltas, BalanceDelta feesAccrued) = poolManager.modifyLiquidity( key, IPoolManager.ModifyLiquidityParams({ - tickLower: isToken0 ? lastEpochPositions[i].tickLower : lastEpochPositions[i].tickUpper, - tickUpper: isToken0 ? lastEpochPositions[i].tickUpper : lastEpochPositions[i].tickLower, + tickLower: lastEpochPositions[i].tickLower, + tickUpper: lastEpochPositions[i].tickUpper, liquidityDelta: -int128(lastEpochPositions[i].liquidity), salt: bytes32(uint256(lastEpochPositions[i].salt)) }), @@ -765,8 +763,12 @@ contract Doppler is BaseHook { poolManager.modifyLiquidity( key, IPoolManager.ModifyLiquidityParams({ - tickLower: isToken0 ? newPositions[i].tickLower : newPositions[i].tickUpper, - tickUpper: isToken0 ? newPositions[i].tickUpper : newPositions[i].tickLower, + tickLower: newPositions[i].tickLower < newPositions[i].tickUpper + ? newPositions[i].tickLower + : newPositions[i].tickUpper, + tickUpper: newPositions[i].tickUpper > newPositions[i].tickLower + ? newPositions[i].tickUpper + : newPositions[i].tickLower, liquidityDelta: int128(newPositions[i].liquidity), salt: bytes32(uint256(newPositions[i].salt)) }), @@ -822,8 +824,8 @@ contract Doppler is BaseHook { (BalanceDelta callerDelta,) = poolManager.modifyLiquidity( key, IPoolManager.ModifyLiquidityParams({ - tickLower: isToken0 ? upperSlug.tickLower : upperSlug.tickUpper, - tickUpper: isToken0 ? upperSlug.tickUpper : upperSlug.tickLower, + tickLower: upperSlug.tickLower, + tickUpper: upperSlug.tickUpper, liquidityDelta: int128(upperSlug.liquidity), salt: UPPER_SLUG_SALT }), @@ -837,8 +839,8 @@ contract Doppler is BaseHook { (BalanceDelta callerDelta,) = poolManager.modifyLiquidity( key, IPoolManager.ModifyLiquidityParams({ - tickLower: isToken0 ? priceDiscoverySlugs[i].tickLower : priceDiscoverySlugs[i].tickUpper, - tickUpper: isToken0 ? priceDiscoverySlugs[i].tickUpper : priceDiscoverySlugs[i].tickLower, + tickLower: priceDiscoverySlugs[i].tickLower, + tickUpper: priceDiscoverySlugs[i].tickUpper, liquidityDelta: int128(priceDiscoverySlugs[i].liquidity), salt: bytes32(uint256(3 + i)) }), diff --git a/test/integration/Rebalance.t.sol b/test/integration/Rebalance.t.sol index 0f78c91d..cd17df68 100644 --- a/test/integration/Rebalance.t.sol +++ b/test/integration/Rebalance.t.sol @@ -35,6 +35,7 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime()); PoolKey memory poolKey = key; + bool isToken0 = hook.getIsToken0(); // Compute the amount of tokens available in both the upper and price discovery slugs // Should be two epochs of liquidity available since we're at the startingTime @@ -42,12 +43,28 @@ contract RebalanceTest is BaseTest { // We sell all available tokens // This increases the price to the pool maximum - buy(int256(expectedAmountSold)); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams( + !isToken0, int256(expectedAmountSold), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT + ), + PoolSwapTest.TestSettings(true, false), + "" + ); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch // We swap again just to trigger the rebalancing logic in the new epoch - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (, int256 tickAccumulator, uint256 totalTokensSold,,,) = hook.state(); @@ -69,35 +86,16 @@ contract RebalanceTest is BaseTest { // doesn't seem to due to rounding. Consider whether this is a problem or whether we // even need that case at all - // TODO: Double check this condition - - if (isToken0) { - // Validate that lower slug is not above the current tick - assertLe(lowerSlug.tickUpper, hook.getCurrentTick(poolKey.toId()), "lowerSlug.tickUpper > currentTick"); - } else { - // Validate that lower slug is not below the current tick - assertGe(lowerSlug.tickUpper, hook.getCurrentTick(poolKey.toId()), "lowerSlug.tickUpper < currentTick"); - } + // Validate that lower slug is not above the current tick + assertLe(lowerSlug.tickUpper, hook.getCurrentTick(poolKey.toId())); // Validate that upper slug and all price discovery slugs are placed continuously - assertEq( - upperSlug.tickUpper, - priceDiscoverySlugs[0].tickLower, - "upperSlug.tickUpper != priceDiscoverySlugs[0].tickLower" - ); + assertEq(upperSlug.tickUpper, priceDiscoverySlugs[0].tickLower); for (uint256 i; i < priceDiscoverySlugs.length; ++i) { if (i == 0) { - assertEq( - upperSlug.tickUpper, - priceDiscoverySlugs[i].tickLower, - "upperSlug.tickUpper != priceDiscoverySlugs[i].tickLower" - ); + assertEq(upperSlug.tickUpper, priceDiscoverySlugs[i].tickLower); } else { - assertEq( - priceDiscoverySlugs[i - 1].tickUpper, - priceDiscoverySlugs[i].tickLower, - "priceDiscoverySlugs[i - 1].tickUpper != priceDiscoverySlugs[i].tickLower" - ); + assertEq(priceDiscoverySlugs[i - 1].tickUpper, priceDiscoverySlugs[i].tickLower); } if (i == priceDiscoverySlugs.length - 1) { @@ -105,23 +103,29 @@ contract RebalanceTest is BaseTest { assertApproxEqAbs( priceDiscoverySlugs[i].tickUpper, tickUpper, - hook.getNumPDSlugs() * uint256(int256(poolKey.tickSpacing)), - "priceDiscoverySlugs[i].tickUpper != tickUpper" + hook.getNumPDSlugs() * uint256(int256(poolKey.tickSpacing)) ); } // Validate that each price discovery slug has liquidity - assertGt(priceDiscoverySlugs[i].liquidity, 0, "priceDiscoverySlugs[i].liquidity is 0"); + assertGt(priceDiscoverySlugs[i].liquidity, 0); } // Validate that the lower slug has liquidity - assertGt(lowerSlug.liquidity, 1e18, "lowerSlug no liquidity"); + assertGt(lowerSlug.liquidity, 1e18); // Validate that the upper slug has very little liquidity (dust) - assertLt(upperSlug.liquidity, 1e18, "upperSlug has liquidity"); + assertLt(upperSlug.liquidity, 1e18); // Validate that we can swap all tokens back into the curve - sell(-int256(totalTokensSold)); + swapRouter.swap( + // Swap asset to numeraire + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(isToken0, -int256(totalTokensSold), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); } function test_rebalance_LowerSlug_SufficientProceeds() public { @@ -129,13 +133,23 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime() + hook.getEpochLength() * 2); PoolKey memory poolKey = key; + bool isToken0 = hook.getIsToken0(); // Compute the expected amount sold to see how many tokens will be supplied in the upper slug // We should always have sufficient proceeds if we don't swap beyond the upper slug uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(1); // We sell half the expected amount to ensure that we don't surpass the upper slug - buy(int256(expectedAmountSold / 2)); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams( + !isToken0, int256(expectedAmountSold / 2), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT + ), + PoolSwapTest.TestSettings(true, false), + "" + ); (uint40 lastEpoch,, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); @@ -148,7 +162,14 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime() + hook.getEpochLength() * 3); // Next epoch // We swap again just to trigger the rebalancing logic in the new epoch - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (, int256 tickAccumulator2, uint256 totalTokensSold2,,,) = hook.state(); @@ -171,7 +192,14 @@ contract RebalanceTest is BaseTest { assertGt(lowerSlug.liquidity, 0); // Validate that we can swap all tokens back into the curve - sell(-int256(totalTokensSold2)); + swapRouter.swap( + // Swap asset to numeraire + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(isToken0, -int256(totalTokensSold2), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); } function test_rebalance_LowerSlug_InsufficientProceeds() public { @@ -186,12 +214,28 @@ contract RebalanceTest is BaseTest { uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(2); // We sell 90% of the expected amount so we stay in range but trigger insufficient proceeds case - buy(int256(expectedAmountSold * 9 / 10)); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams( + !isToken0, int256(expectedAmountSold * 9 / 10), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT + ), + PoolSwapTest.TestSettings(true, false), + "" + ); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch // We swap again just to trigger the rebalancing logic in the new epoch - buy(1); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, 1, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (, int256 tickAccumulator, uint256 totalTokensSold,,,) = hook.state(); @@ -250,7 +294,14 @@ contract RebalanceTest is BaseTest { assertGt(amount0Delta, totalTokensSold); // Validate that we can swap all tokens back into the curve - sell(-int256(totalTokensSold)); + swapRouter.swap( + // Swap asset to numeraire + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(isToken0, -int256(totalTokensSold), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); } function test_rebalance_LowerSlug_NoLiquidity() public { @@ -262,7 +313,14 @@ contract RebalanceTest is BaseTest { // We sell some tokens to trigger the initial rebalance // We haven't sold any tokens in previous epochs so we shouldn't place a lower slug - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); // Get the lower slug Position memory lowerSlug = hook.getPositions(bytes32(uint256(1))); @@ -286,12 +344,28 @@ contract RebalanceTest is BaseTest { uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(1); // We sell half the expected amount to ensure that we hit the undersold case - buy(int256(expectedAmountSold / 2)); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams( + !isToken0, int256(expectedAmountSold / 2), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT + ), + PoolSwapTest.TestSettings(true, false), + "" + ); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch // We swap again just to trigger the rebalancing logic in the new epoch - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (, int256 tickAccumulator,,,,) = hook.state(); @@ -307,21 +381,12 @@ contract RebalanceTest is BaseTest { (int24 tickLower, int24 tickUpper) = hook.getTicksBasedOnState(tickAccumulator, poolKey.tickSpacing); // Validate that the slugs are continuous and all have liquidity - // TODO: I tried fixing this using isToken0, not sure if it should work this way though. - if (isToken0) { - assertEq( - lowerSlug.tickLower, - tickLower - poolKey.tickSpacing, - "tickLower - poolKey.tickSpacing != lowerSlug.tickLower" - ); - } else { - assertEq( - lowerSlug.tickLower, - tickLower + poolKey.tickSpacing, - "tickLower + poolKey.tickSpacing != lowerSlug.tickLower" - ); - } - + // TODO: figure out why this is happening + assertEq( + lowerSlug.tickLower, + tickLower - poolKey.tickSpacing, + "tickLower - poolKey.tickSpacing != lowerSlug.tickLower" + ); assertEq(lowerSlug.tickUpper, upperSlug.tickLower, "lowerSlug.tickUpper != upperSlug.tickLower"); for (uint256 i; i < priceDiscoverySlugs.length; ++i) { @@ -383,7 +448,14 @@ contract RebalanceTest is BaseTest { bool isToken0 = hook.getIsToken0(); // We sell one wei to trigger the rebalance without messing with resulting liquidity positions - buy(1); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, 1, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); // Get the upper and price discover slugs Position memory upperSlug = hook.getPositions(bytes32(uint256(2))); @@ -393,9 +465,7 @@ contract RebalanceTest is BaseTest { } // Assert that the slugs are continuous - assertApproxEqAbs( - hook.getCurrentTick(poolKey.toId()), upperSlug.tickLower, 1, "currentTick != upperSlug.tickLower" - ); + assertEq(hook.getCurrentTick(poolKey.toId()), upperSlug.tickLower); // We should only have one price discovery slug at this point assertEq(upperSlug.tickUpper, priceDiscoverySlugs[0].tickLower); @@ -443,16 +513,21 @@ contract RebalanceTest is BaseTest { bool isToken0 = hook.getIsToken0(); // We sell one wei to trigger the rebalance without messing with resulting liquidity positions - buy(1); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, 1, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); // Get the upper and price discover slugs Position memory upperSlug = hook.getPositions(bytes32(uint256(2))); Position memory priceDiscoverySlug = hook.getPositions(bytes32(uint256(3))); // Assert that the upperSlug is correctly placed - assertApproxEqAbs( - hook.getCurrentTick(poolKey.toId()), upperSlug.tickLower, 1, "currentTick != upperSlug.tickLower" - ); + assertEq(hook.getCurrentTick(poolKey.toId()), upperSlug.tickLower); // Assert that the priceDiscoverySlug has no liquidity assertEq(priceDiscoverySlug.liquidity, 0); @@ -481,8 +556,14 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime()); PoolKey memory poolKey = key; - - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + key, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (uint40 lastEpoch,, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); @@ -496,7 +577,14 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Swap tokens back into the pool, netSold == 0 - sell(-1 ether); + swapRouter.swap( + // Swap asset to numeraire + // If zeroForOne, we use max price limit (else vice versa) + key, + IPoolManager.SwapParams(isToken0, -1 ether, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (uint40 lastEpoch2, int256 tickAccumulator2, uint256 totalTokensSold2,, uint256 totalTokensSoldLastEpoch2,) = hook.state(); @@ -508,9 +596,17 @@ contract RebalanceTest is BaseTest { assertEq(totalTokensSoldLastEpoch2, 1 ether); vm.warp(hook.getStartingTime() + hook.getEpochLength() * 2); // Next epoch + SlugVis.visualizeSlugs(hook, poolKey.toId(), "test", block.timestamp); // We swap again just to trigger the rebalancing logic in the new epoch - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + key, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (uint40 lastEpoch3, int256 tickAccumulator3, uint256 totalTokensSold3,, uint256 totalTokensSoldLastEpoch3,) = hook.state(); @@ -571,39 +667,56 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime()); PoolKey memory poolKey = key; + bool isToken0 = hook.getIsToken0(); // Get the expected amount sold by next epoch uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(1); // We sell half the expected amount - buy(int256(expectedAmountSold / 2)); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams( + !isToken0, int256(expectedAmountSold / 2), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT + ), + PoolSwapTest.TestSettings(true, false), + "" + ); (uint40 lastEpoch, int256 tickAccumulator, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); - assertEq(lastEpoch, 1, "Wrong last epoch"); + assertEq(lastEpoch, 1); // Confirm we sold half the expected amount - assertEq(totalTokensSold, expectedAmountSold / 2, "Wrong tokens sold"); + assertEq(totalTokensSold, expectedAmountSold / 2); // Previous epoch didn't exist so no tokens would have been sold at the time - assertEq(totalTokensSoldLastEpoch, 0, "Wrong tokens sold last epoch"); + assertEq(totalTokensSoldLastEpoch, 0); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch // We swap again just to trigger the rebalancing logic in the new epoch - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (uint40 lastEpoch2, int256 tickAccumulator2, uint256 totalTokensSold2,, uint256 totalTokensSoldLastEpoch2,) = hook.state(); - assertEq(lastEpoch2, 2, "Wrong last epoch (2)"); + assertEq(lastEpoch2, 2); // We sold some tokens just now - assertEq(totalTokensSold2, expectedAmountSold / 2 + 1e18, "Wrong tokens sold (2)"); + assertEq(totalTokensSold2, expectedAmountSold / 2 + 1e18); // The net sold amount in the previous epoch half the expected amount - assertEq(totalTokensSoldLastEpoch2, expectedAmountSold / 2, "Wrong tokens sold last epoch (2)"); + assertEq(totalTokensSoldLastEpoch2, expectedAmountSold / 2); // Assert that we reduced the accumulator by half the max amount as intended int256 maxTickDeltaPerEpoch = hook.getMaxTickDeltaPerEpoch(); - assertEq(tickAccumulator2, tickAccumulator + maxTickDeltaPerEpoch / 2, "Wrong tick accumulator"); + assertEq(tickAccumulator2, tickAccumulator + maxTickDeltaPerEpoch / 2); // Get positions Position memory lowerSlug = hook.getPositions(bytes32(uint256(1))); @@ -621,21 +734,13 @@ contract RebalanceTest is BaseTest { int24 currentTick = hook.getCurrentTick(poolId); // Slugs must be inline and continuous - assertEq(lowerSlug.tickUpper, upperSlug.tickLower, "Wrong ticks for lower and upper slugs"); + assertEq(lowerSlug.tickUpper, upperSlug.tickLower); for (uint256 i; i < priceDiscoverySlugs.length; ++i) { if (i == 0) { - assertEq( - upperSlug.tickUpper, - priceDiscoverySlugs[i].tickLower, - "Wrong ticks upperSlug.tickUpper / priceDiscoverySlugs[i].tickLower" - ); + assertEq(upperSlug.tickUpper, priceDiscoverySlugs[i].tickLower); } else { - assertEq( - priceDiscoverySlugs[i - 1].tickUpper, - priceDiscoverySlugs[i].tickLower, - "Wrong ticks priceDiscoverySlugs[i - 1].tickUpper / priceDiscoverySlugs[i].tickLower" - ); + assertEq(priceDiscoverySlugs[i - 1].tickUpper, priceDiscoverySlugs[i].tickLower); } if (i == priceDiscoverySlugs.length - 1) { @@ -648,15 +753,15 @@ contract RebalanceTest is BaseTest { } // Validate that each price discovery slug has liquidity - assertGt(priceDiscoverySlugs[i].liquidity, 0, "Wrong liquidity for price discovery slug"); + assertGt(priceDiscoverySlugs[i].liquidity, 0); } // Lower slug upper tick should be at the currentTick - assertEq(lowerSlug.tickUpper, currentTick, "lowerSlug.tickUpper not at currentTick"); + assertEq(lowerSlug.tickUpper, currentTick); // All slugs must be set - assertNotEq(lowerSlug.liquidity, 0, "lowerSlug.liquidity is 0"); - assertNotEq(upperSlug.liquidity, 0, "upperSlug.liquidity is 0"); + assertNotEq(lowerSlug.liquidity, 0); + assertNotEq(upperSlug.liquidity, 0); } function test_rebalance_OversoldCase() public { @@ -669,7 +774,16 @@ contract RebalanceTest is BaseTest { uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(1); // We buy 1.5x the expectedAmountSold - buy(int256(expectedAmountSold * 3 / 2)); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams( + !isToken0, int256(expectedAmountSold * 3 / 2), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT + ), + PoolSwapTest.TestSettings(true, false), + "" + ); (uint40 lastEpoch, int256 tickAccumulator, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); @@ -687,7 +801,14 @@ contract RebalanceTest is BaseTest { int24 currentTick = hook.getCurrentTick(poolId); // We swap again just to trigger the rebalancing logic in the new epoch - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (uint40 lastEpoch2, int256 tickAccumulator2, uint256 totalTokensSold2,, uint256 totalTokensSoldLastEpoch2,) = hook.state(); @@ -746,8 +867,12 @@ contract RebalanceTest is BaseTest { } function test_rebalance_CollectsFeeFromAllSlugs() public { + PoolKey memory poolKey = key; + vm.warp(hook.getStartingTime()); + bool isToken0 = hook.getIsToken0(); + (,, uint24 protocolFee, uint24 lpFee) = manager.getSlot0(key.toId()); (,,,,, BalanceDelta feesAccrued) = hook.state(); @@ -764,7 +889,14 @@ contract RebalanceTest is BaseTest { upperSlug.liquidity ) * 9 / 10; - buy(-int256(amount1ToSwap)); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, -int256(amount1ToSwap), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); uint256 amount0ToSwap = LiquidityAmounts.getAmount0ForLiquidity( TickMath.getSqrtPriceAtTick(upperSlug.tickLower), @@ -772,12 +904,26 @@ contract RebalanceTest is BaseTest { upperSlug.liquidity ) * 9 / 10; - buy(-int256(amount0ToSwap)); + swapRouter.swap( + // Swap asset to numeraire + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(isToken0, -int256(amount0ToSwap), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // trigger rebalance to accrue fees - buy(1); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(isToken0, 1, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (,,,,, feesAccrued) = hook.state(); @@ -802,6 +948,7 @@ contract RebalanceTest is BaseTest { function test_rebalance_FullFlow() public { PoolKey memory poolKey = key; + bool isToken0 = hook.getIsToken0(); // Max dutch auction over first few skipped epochs // =============================================== @@ -810,8 +957,14 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime() + hook.getEpochLength() * 3); // Swap less then expected amount - to be used checked in the next epoch - - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (uint40 lastEpoch, int256 tickAccumulator, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); @@ -868,7 +1021,7 @@ contract RebalanceTest is BaseTest { // Lower slug should be unset with ticks at the current price assertEq(lowerSlug.tickLower, lowerSlug.tickUpper, "first swap: lowerSlug.tickLower != lowerSlug.tickUpper"); assertEq(lowerSlug.liquidity, 0, "first swap: lowerSlug.liquidity != 0"); - assertApproxEqAbs(lowerSlug.tickUpper, currentTick, 1, "first swap: lowerSlug.tickUpper != currentTick"); + assertEq(lowerSlug.tickUpper, currentTick, "first swap: lowerSlug.tickUpper != currentTick"); // Upper and price discovery slugs must be set assertNotEq(upperSlug.liquidity, 0, "first swap: upperSlug.liquidity != 0"); @@ -883,7 +1036,16 @@ contract RebalanceTest is BaseTest { uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(1); // Trigger the oversold case by selling more than expected - buy(int256(expectedAmountSold)); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams( + !isToken0, int256(expectedAmountSold), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT + ), + PoolSwapTest.TestSettings(true, false), + "" + ); (uint40 lastEpoch2, int256 tickAccumulator2, uint256 totalTokensSold2,, uint256 totalTokensSoldLastEpoch2,) = hook.state(); @@ -954,7 +1116,14 @@ contract RebalanceTest is BaseTest { currentTick = hook.getCurrentTick(poolId); // Trigger rebalance - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (uint40 lastEpoch3, int256 tickAccumulator3, uint256 totalTokensSold3,, uint256 totalTokensSoldLastEpoch3,) = hook.state(); @@ -984,13 +1153,8 @@ contract RebalanceTest is BaseTest { // Get current tick currentTick = hook.getCurrentTick(poolId); - if (isToken0) { - // Lower slug must not be above current tick - assertLe(lowerSlug.tickUpper, currentTick, "third swap: lowerSlug.tickUpper > currentTick"); - } else { - // Lower slug must not be below current tick - assertGe(lowerSlug.tickUpper, currentTick, "third swap: lowerSlug.tickUpper < currentTick"); - } + // Lower slug must not be above current tick + assertLe(lowerSlug.tickUpper, currentTick, "third swap: lowerSlug.tickUpper > currentTick"); // Upper slugs must be inline and continuous for (uint256 i; i < priceDiscoverySlugs.length; ++i) { @@ -1018,7 +1182,14 @@ contract RebalanceTest is BaseTest { assertNotEq(upperSlug.liquidity, 0, "third swap: upperSlug.liquidity != 0"); // Validate that we can swap all tokens back into the curve - sell(-int256(totalTokensSold3)); + swapRouter.swap( + // Swap asset to numeraire + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(isToken0, -int256(totalTokensSold3), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); // Swap in second last epoch // ======================== @@ -1030,7 +1201,14 @@ contract RebalanceTest is BaseTest { ); // Swap some tokens - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (, int256 tickAccumulator4,,,,) = hook.state(); @@ -1047,13 +1225,8 @@ contract RebalanceTest is BaseTest { // Get current tick currentTick = hook.getCurrentTick(poolId); - if (isToken0) { - // Lower slug must not be greater than current tick - assertLe(lowerSlug.tickUpper, currentTick, "fourth swap: lowerSlug.tickUpper > currentTick"); - } else { - // Lower slug must not be less than current tick - assertGe(lowerSlug.tickUpper, currentTick, "fourth swap: lowerSlug.tickUpper < currentTick"); - } + // Lower slug must not be greater than current tick + assertLe(lowerSlug.tickUpper, currentTick, "fourth swap: lowerSlug.tickUpper > currentTick"); // Upper slugs must be inline and continuous // In this case we only have one price discovery slug since we're on the second last epoch @@ -1076,7 +1249,14 @@ contract RebalanceTest is BaseTest { ); // Swap some tokens - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (, int256 tickAccumulator5,,,,) = hook.state(); @@ -1095,19 +1275,11 @@ contract RebalanceTest is BaseTest { // Slugs must be inline and continuous if (currentTick == tickLower) { - if (isToken0) { - assertEq( - tickLower - poolKey.tickSpacing, - lowerSlug.tickLower, - "fifth swap: lowerSlug.tickLower != global tickLower" - ); - } else { - assertEq( - tickLower + poolKey.tickSpacing, - lowerSlug.tickLower, - "fifth swap: lowerSlug.tickUpper != global tickLower" - ); - } + assertEq( + tickLower - poolKey.tickSpacing, + lowerSlug.tickLower, + "fifth swap: lowerSlug.tickLower != global tickLower" + ); } else { assertEq(tickLower, lowerSlug.tickLower, "fifth swap: lowerSlug.tickUpper != global tickLower"); } @@ -1136,7 +1308,16 @@ contract RebalanceTest is BaseTest { (,, uint256 totalTokensSold4,,,) = hook.state(); // Swap all remaining tokens - buy(int256(numTokensToSell - totalTokensSold4)); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams( + !isToken0, int256(numTokensToSell - totalTokensSold4), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT + ), + PoolSwapTest.TestSettings(true, false), + "" + ); (, int256 tickAccumulator6,,,,) = hook.state(); diff --git a/test/invariant/DopplerHandler.sol b/test/invariant/DopplerHandler.sol index 1bf27057..b574023c 100644 --- a/test/invariant/DopplerHandler.sol +++ b/test/invariant/DopplerHandler.sol @@ -5,24 +5,18 @@ import {Test} from "forge-std/Test.sol"; import {AddressSet, LibAddressSet} from "./AddressSet.sol"; import {DopplerImplementation} from "test/shared/DopplerImplementation.sol"; import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; -import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; import {TestERC20} from "v4-core/src/test/TestERC20.sol"; import {PoolKey} from "v4-core/src/types/PoolKey.sol"; import {Currency} from "v4-core/src/types/Currency.sol"; -import {CustomRouter} from "test/shared/CustomRouter.sol"; contract DopplerHandler is Test { using LibAddressSet for AddressSet; PoolKey public poolKey; DopplerImplementation public hook; - CustomRouter public router; + PoolSwapTest public swapRouter; TestERC20 public token0; TestERC20 public token1; - TestERC20 public numeraire; - TestERC20 public asset; - bool public isToken0; - bool public isUsingEth; uint256 public ghost_reserve0; uint256 public ghost_reserve1; @@ -54,43 +48,17 @@ contract DopplerHandler is Test { _; } - constructor( - PoolKey memory poolKey_, - DopplerImplementation hook_, - CustomRouter router_, - bool isToken0_, - bool isUsingEth_ - ) { + constructor(PoolKey memory poolKey_, DopplerImplementation hook_, PoolSwapTest swapRouter_) { poolKey = poolKey_; hook = hook_; - router = router_; - isToken0 = isToken0_; - isUsingEth = isUsingEth_; + swapRouter = swapRouter_; token0 = TestERC20(Currency.unwrap(poolKey.currency0)); token1 = TestERC20(Currency.unwrap(poolKey.currency1)); - if (isToken0) { - numeraire = token0; - asset = token1; - } else { - numeraire = token1; - asset = token0; - } - ghost_reserve0 = token0.balanceOf(address(hook)); ghost_reserve1 = token1.balanceOf(address(hook)); } - /// @notice Buys an amount of asset tokens using an exact amount of numeraire tokens - function buyExactAmountIn(uint256 amount) public createActor countCall(this.buyExactAmountIn.selector) { - if (isUsingEth) { - deal(currentActor, amount); - } else { - numeraire.mint(currentActor, amount); - numeraire.approve(address(router), amount); - } - - uint256 bought = router.buyExactIn{value: isUsingEth ? amount : 0}(amount); - } + function buyExactAmount(uint256 amount) public createActor countCall(this.buyExactAmount.selector) {} } diff --git a/test/invariant/DopplerInvariants.sol b/test/invariant/DopplerInvariants.sol index 69127401..ac6f127b 100644 --- a/test/invariant/DopplerInvariants.sol +++ b/test/invariant/DopplerInvariants.sol @@ -1,32 +1,16 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; -import {console} from "forge-std/console.sol"; import {BaseTest} from "test/shared/BaseTest.sol"; -import {DopplerHandler} from "test/invariant/DopplerHandler.sol"; contract DopplerInvariantsTest is BaseTest { - DopplerHandler public handler; - function setUp() public override { super.setUp(); - handler = new DopplerHandler(key, hook, router, isToken0, usingEth); - - bytes4[] memory selectors = new bytes4[](1); - selectors[0] = handler.buyExactAmountIn.selector; - - targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); - targetContract(address(handler)); } - function afterInvariant() public view { - console.log("Handler address", address(handler)); - console.log("Calls: ", handler.totalCalls()); - console.log("buyExactAmountIn: ", handler.calls(handler.buyExactAmountIn.selector)); - } + function afterInvariant() public view {} - /// forge-config: default.invariant.fail-on-revert = true - function invariant_works() public { + function invariant_works() public pure { assertTrue(true); } } diff --git a/test/shared/BaseTest.sol b/test/shared/BaseTest.sol index 9529381c..c3464984 100644 --- a/test/shared/BaseTest.sol +++ b/test/shared/BaseTest.sol @@ -7,21 +7,17 @@ import {Deployers} from "v4-core/test/utils/Deployers.sol"; import {TestERC20} from "v4-core/src/test/TestERC20.sol"; import {PoolId, PoolIdLibrary} from "v4-periphery/lib/v4-core/src/types/PoolId.sol"; import {PoolKey} from "v4-periphery/lib/v4-core/src/types/PoolKey.sol"; -import {PoolManager, IPoolManager} from "v4-core/src/PoolManager.sol"; +import {PoolManager} from "v4-core/src/PoolManager.sol"; import {Hooks} from "v4-core/src/libraries/Hooks.sol"; import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; import {Currency} from "v4-periphery/lib/v4-core/src/types/Currency.sol"; import {TickMath} from "v4-core/src/libraries/TickMath.sol"; import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol"; -import {Quoter, IQuoter} from "v4-periphery/src/lens/Quoter.sol"; -import {BalanceDelta, BalanceDeltaLibrary} from "v4-core/src/types/BalanceDelta.sol"; -import {CustomRouter} from "test/shared/CustomRouter.sol"; import {DopplerImplementation} from "./DopplerImplementation.sol"; using PoolIdLibrary for PoolKey; -using BalanceDeltaLibrary for BalanceDelta; contract BaseTest is Test, Deployers { // TODO: Maybe add the start and end ticks to the config? @@ -77,24 +73,21 @@ contract BaseTest is Test, Deployers { // Context DopplerImplementation hook = DopplerImplementation( - payable( - address( - uint160( - Hooks.BEFORE_ADD_LIQUIDITY_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG - | Hooks.AFTER_INITIALIZE_FLAG - ) ^ (0x4444 << 144) - ) + address( + uint160( + Hooks.BEFORE_ADD_LIQUIDITY_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG + | Hooks.AFTER_INITIALIZE_FLAG + ) ^ (0x4444 << 144) ) ); - address asset; - address numeraire; - address token0; - address token1; + TestERC20 asset; + TestERC20 numeraire; + TestERC20 token0; + TestERC20 token1; PoolId poolId; bool isToken0; - bool usingEth; int24 startTick; int24 endTick; @@ -103,11 +96,6 @@ contract BaseTest is Test, Deployers { address alice = address(0xa71c3); address bob = address(0xb0b); - // Contracts - - Quoter quoter; - CustomRouter router; - // Deploy functions /// @dev Deploys a new pair of asset and numeraire tokens and the related Doppler hook @@ -119,7 +107,7 @@ contract BaseTest is Test, Deployers { /// @dev Reuses an existing pair of asset and numeraire tokens and deploys the related /// Doppler hook with the default configuration. - function _deploy(address asset_, address numeraire_) public { + function _deploy(TestERC20 asset_, TestERC20 numeraire_) public { asset = asset_; numeraire = numeraire_; _deployDoppler(); @@ -134,7 +122,7 @@ contract BaseTest is Test, Deployers { /// @dev Reuses an existing pair of asset and numeraire tokens and deploys the related Doppler /// hook with a given configuration. - function _deploy(address asset_, address numeraire_, DopplerConfig memory config) public { + function _deploy(TestERC20 asset_, TestERC20 numeraire_, DopplerConfig memory config) public { asset = asset_; numeraire = numeraire_; _deployDoppler(config); @@ -143,25 +131,10 @@ contract BaseTest is Test, Deployers { /// @dev Deploys a new pair of asset and numeraire tokens. function _deployTokens() public { isToken0 = vm.envOr("IS_TOKEN_0", true); - usingEth = vm.envOr("USING_ETH", false); - - if (usingEth) { - isToken0 = false; - deployCodeTo("TestERC20.sol:TestERC20", abi.encode(2 ** 128), address(TOKEN_B)); - token0 = address(0); - token1 = address(TOKEN_B); - numeraire = token0; - asset = token1; - } else { - deployCodeTo( - "TestERC20.sol:TestERC20", abi.encode(2 ** 128), isToken0 ? address(TOKEN_A) : address(TOKEN_B) - ); - deployCodeTo( - "TestERC20.sol:TestERC20", abi.encode(2 ** 128), isToken0 ? address(TOKEN_B) : address(TOKEN_A) - ); - asset = isToken0 ? TOKEN_A : TOKEN_B; - numeraire = isToken0 ? TOKEN_B : TOKEN_A; - } + deployCodeTo("TestERC20.sol:TestERC20", abi.encode(2 ** 128), isToken0 ? address(TOKEN_A) : address(TOKEN_B)); + deployCodeTo("TestERC20.sol:TestERC20", abi.encode(2 ** 128), isToken0 ? address(TOKEN_B) : address(TOKEN_A)); + asset = TestERC20(isToken0 ? address(TOKEN_A) : address(TOKEN_B)); + numeraire = TestERC20(isToken0 ? address(TOKEN_B) : address(TOKEN_A)); } /// @dev Deploys a new Doppler hook with the default configuration. @@ -175,7 +148,7 @@ contract BaseTest is Test, Deployers { vm.label(address(token0), "Token0"); vm.label(address(token1), "Token1"); - TestERC20(asset).transfer(address(hook), config.numTokensToSell); + (isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); // isToken0 ? startTick > endTick : endTick > startTick // In both cases, price(startTick) > price(endTick) @@ -240,111 +213,10 @@ contract BaseTest is Test, Deployers { // Note: Only used to validate that liquidity can't be manually modified modifyLiquidityRouter = new PoolModifyLiquidityTest(manager); - if (token0 != address(0)) { - // Approve the router to spend tokens on behalf of the test contract - TestERC20(token0).approve(address(swapRouter), type(uint256).max); - TestERC20(token0).approve(address(modifyLiquidityRouter), type(uint256).max); - } - TestERC20(token1).approve(address(swapRouter), type(uint256).max); - TestERC20(token1).approve(address(modifyLiquidityRouter), type(uint256).max); - - quoter = new Quoter(manager); - - router = new CustomRouter(swapRouter, quoter, key, isToken0, usingEth); - } - - function computeBuyExactOut(uint256 amountOut) public returns (uint256) { - (int128[] memory deltaAmounts,,) = quoter.quoteExactOutputSingle( - IQuoter.QuoteExactSingleParams({ - poolKey: key, - zeroForOne: !isToken0, - exactAmount: uint128(amountOut), - sqrtPriceLimitX96: !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT, - hookData: "" - }) - ); - - return uint256(uint128(deltaAmounts[0])); - } - - function computeSellExactOut(uint256 amountOut) public returns (uint256) { - (int128[] memory deltaAmounts,,) = quoter.quoteExactOutputSingle( - IQuoter.QuoteExactSingleParams({ - poolKey: key, - zeroForOne: isToken0, - exactAmount: uint128(amountOut), - sqrtPriceLimitX96: isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT, - hookData: "" - }) - ); - - return uint256(uint128(deltaAmounts[0])); - } - - function buyExactIn(uint256 amount) public { - buy(-int256(amount)); - } - - function buyExactOut(uint256 amount) public { - buy(int256(amount)); - } - - function sellExactIn(uint256 amount) public { - sell(-int256(amount)); - } - - function sellExactOut(uint256 amount) public { - sell(int256(amount)); - } - - /// @dev Buys a given amount of asset tokens. - /// @param amount A negative value specificies the amount of numeraire tokens to spend, - /// a positive value specifies the amount of asset tokens to buy. - /// @return Amount of asset tokens bought. - /// @return Amount of numeraire tokens used. - function buy(int256 amount) public returns (uint256, uint256) { - // Negative means exactIn, positive means exactOut. - uint256 mintAmount = amount < 0 ? uint256(-amount) : computeBuyExactOut(uint256(amount)); - - if (usingEth) { - deal(address(this), uint256(mintAmount)); - } else { - TestERC20(numeraire).mint(address(this), uint256(mintAmount)); - TestERC20(numeraire).approve(address(swapRouter), uint256(mintAmount)); - } - - BalanceDelta delta = swapRouter.swap{value: usingEth ? mintAmount : 0}( - key, - IPoolManager.SwapParams(!isToken0, amount, isToken0 ? MAX_PRICE_LIMIT : MIN_PRICE_LIMIT), - PoolSwapTest.TestSettings(false, false), - "" - ); - - uint256 delta0 = uint256(int256(delta.amount0() < 0 ? -delta.amount0() : delta.amount0())); - uint256 delta1 = uint256(int256(delta.amount1() < 0 ? -delta.amount1() : delta.amount1())); - - return isToken0 ? (delta0, delta1) : (delta1, delta0); - } - - /// @dev Sells a given amount of asset tokens. - /// @param amount A negative value specificies the amount of asset tokens to sell, a positive value - /// specifies the amount of numeraire tokens to receive. - /// @return Amount of asset tokens sold. - /// @return Amount of numeraire tokens received. - function sell(int256 amount) public returns (uint256, uint256) { - uint256 approveAmount = amount < 0 ? uint256(-amount) : computeSellExactOut(uint256(amount)); - TestERC20(asset).approve(address(swapRouter), uint256(approveAmount)); - - BalanceDelta delta = swapRouter.swap( - key, - IPoolManager.SwapParams(isToken0, amount, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(false, false), - "" - ); - - uint256 delta0 = uint256(int256(delta.amount0() < 0 ? -delta.amount0() : delta.amount0())); - uint256 delta1 = uint256(int256(delta.amount1() < 0 ? -delta.amount1() : delta.amount1())); - - return isToken0 ? (delta0, delta1) : (delta1, delta0); + // Approve the router to spend tokens on behalf of the test contract + token0.approve(address(swapRouter), type(uint256).max); + token1.approve(address(swapRouter), type(uint256).max); + token0.approve(address(modifyLiquidityRouter), type(uint256).max); + token1.approve(address(modifyLiquidityRouter), type(uint256).max); } } diff --git a/test/shared/CustomRouter.sol b/test/shared/CustomRouter.sol deleted file mode 100644 index b2167bb9..00000000 --- a/test/shared/CustomRouter.sol +++ /dev/null @@ -1,191 +0,0 @@ -/// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test} from "forge-std/Test.sol"; -import {TestERC20} from "v4-core/src/test/TestERC20.sol"; -import {PoolKey} from "v4-periphery/lib/v4-core/src/types/PoolKey.sol"; -import {IPoolManager} from "v4-core/src/PoolManager.sol"; -import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; -import {Quoter, IQuoter} from "v4-periphery/src/lens/Quoter.sol"; -import {BalanceDelta, BalanceDeltaLibrary} from "v4-core/src/types/BalanceDelta.sol"; -import {TickMath} from "v4-core/src/libraries/TickMath.sol"; -import {Currency} from "v4-core/src/types/Currency.sol"; - -uint160 constant MIN_PRICE_LIMIT = TickMath.MIN_SQRT_PRICE + 1; -uint160 constant MAX_PRICE_LIMIT = TickMath.MAX_SQRT_PRICE - 1; - -/// @notice Just a custom router contract for testing purposes, I wanted to have -/// a way to reuse the same functions in the BaseTest contract and the DopplerHandler. -contract CustomRouter is Test { - using BalanceDeltaLibrary for BalanceDelta; - - PoolSwapTest public swapRouter; - Quoter public quoter; - PoolKey public key; - bool public isToken0; - bool public isUsingEth; - address public numeraire; - address public asset; - - constructor(PoolSwapTest swapRouter_, Quoter quoter_, PoolKey memory key_, bool isToken0_, bool isUsingEth_) { - swapRouter = swapRouter_; - quoter = quoter_; - key = key_; - isToken0 = isToken0_; - isUsingEth = isUsingEth_; - - asset = isToken0 ? Currency.unwrap(key.currency0) : Currency.unwrap(key.currency1); - numeraire = isToken0 ? Currency.unwrap(key.currency1) : Currency.unwrap(key.currency0); - } - - function computeBuyExactOut(uint256 amountOut) public returns (uint256) { - (int128[] memory deltaAmounts,,) = quoter.quoteExactOutputSingle( - IQuoter.QuoteExactSingleParams({ - poolKey: key, - zeroForOne: !isToken0, - exactAmount: uint128(amountOut), - sqrtPriceLimitX96: !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT, - hookData: "" - }) - ); - - return uint256(uint128(deltaAmounts[0])); - } - - function computeSellExactOut(uint256 amountOut) public returns (uint256) { - (int128[] memory deltaAmounts,,) = quoter.quoteExactOutputSingle( - IQuoter.QuoteExactSingleParams({ - poolKey: key, - zeroForOne: isToken0, - exactAmount: uint128(amountOut), - sqrtPriceLimitX96: isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT, - hookData: "" - }) - ); - - return uint256(uint128(deltaAmounts[0])); - } - - /// @notice Buys asset tokens using an exact amount of numeraire tokens. - /// @return bought Amount of asset tokens bought. - function buyExactIn(uint256 amount) public payable returns (uint256 bought) { - (bought,) = buy(-int256(amount)); - } - - /// @notice Buys an exact amount of asset tokens using numeraire tokens. - function buyExactOut(uint256 amount) public payable returns (uint256 spent) { - (, spent) = buy(int256(amount)); - } - - /// @notice Sells an exact amount of asset tokens for numeraire tokens. - /// @return received Amount of numeraire tokens received. - function sellExactIn(uint256 amount) public returns (uint256 received) { - (, received) = sell(-int256(amount)); - } - - /// @notice Sells asset tokens for an exact amount of numeraire tokens. - /// @return sold Amount of asset tokens sold. - function sellExactOut(uint256 amount) public returns (uint256 sold) { - (sold,) = sell(int256(amount)); - } - - /// @dev Buys a given amount of asset tokens. - /// @param amount A negative value specificies the amount of numeraire tokens to spend, - /// a positive value specifies the amount of asset tokens to buy. - /// @return Amount of asset tokens bought. - /// @return Amount of numeraire tokens used. - function mintAndBuy(int256 amount) public returns (uint256, uint256) { - // Negative means exactIn, positive means exactOut. - uint256 mintAmount = amount < 0 ? uint256(-amount) : computeBuyExactOut(uint256(amount)); - - // TODO: Not sure if minting should be done in here, it might be better to mint in the tests. - if (isUsingEth) { - deal(address(this), uint256(mintAmount)); - } else { - TestERC20(numeraire).mint(address(this), uint256(mintAmount)); - TestERC20(numeraire).approve(address(swapRouter), uint256(mintAmount)); - } - - BalanceDelta delta = swapRouter.swap{value: isUsingEth ? mintAmount : 0}( - key, - IPoolManager.SwapParams(!isToken0, amount, isToken0 ? MAX_PRICE_LIMIT : MIN_PRICE_LIMIT), - PoolSwapTest.TestSettings(false, false), - "" - ); - - uint256 delta0 = uint256(int256(delta.amount0() < 0 ? -delta.amount0() : delta.amount0())); - uint256 delta1 = uint256(int256(delta.amount1() < 0 ? -delta.amount1() : delta.amount1())); - - uint256 bought = isToken0 ? delta0 : delta1; - uint256 spent = isToken0 ? delta1 : delta0; - - TestERC20(asset).transfer(msg.sender, bought); - - return (bought, spent); - } - - /// @dev Buys a given amount of asset tokens. - /// @param amount A negative value specificies the amount of numeraire tokens to spend, - /// a positive value specifies the amount of asset tokens to buy. - /// @return Amount of asset tokens bought. - /// @return Amount of numeraire tokens used. - function buy(int256 amount) public payable returns (uint256, uint256) { - // Negative means exactIn, positive means exactOut. - uint256 transferAmount = amount < 0 ? uint256(-amount) : computeBuyExactOut(uint256(amount)); - - if (isUsingEth) { - require(msg.value == transferAmount, "Incorrect amount of ETH sent"); - } else { - TestERC20(numeraire).transferFrom(msg.sender, address(this), transferAmount); - TestERC20(numeraire).approve(address(swapRouter), transferAmount); - } - - BalanceDelta delta = swapRouter.swap{value: isUsingEth ? transferAmount : 0}( - key, - IPoolManager.SwapParams(!isToken0, amount, isToken0 ? MAX_PRICE_LIMIT : MIN_PRICE_LIMIT), - PoolSwapTest.TestSettings(false, false), - "" - ); - - uint256 delta0 = uint256(int256(delta.amount0() < 0 ? -delta.amount0() : delta.amount0())); - uint256 delta1 = uint256(int256(delta.amount1() < 0 ? -delta.amount1() : delta.amount1())); - - uint256 bought = isToken0 ? delta0 : delta1; - uint256 spent = isToken0 ? delta1 : delta0; - - TestERC20(asset).transfer(msg.sender, bought); - - return (bought, spent); - } - - /// @dev Sells a given amount of asset tokens. - /// @param amount A negative value specificies the amount of asset tokens to sell, a positive value - /// specifies the amount of numeraire tokens to receive. - /// @return Amount of asset tokens sold. - /// @return Amount of numeraire tokens received. - function sell(int256 amount) public returns (uint256, uint256) { - uint256 approveAmount = amount < 0 ? uint256(-amount) : computeSellExactOut(uint256(amount)); - TestERC20(asset).approve(address(swapRouter), uint256(approveAmount)); - - BalanceDelta delta = swapRouter.swap( - key, - IPoolManager.SwapParams(isToken0, amount, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(false, false), - "" - ); - - uint256 delta0 = uint256(int256(delta.amount0() < 0 ? -delta.amount0() : delta.amount0())); - uint256 delta1 = uint256(int256(delta.amount1() < 0 ? -delta.amount1() : delta.amount1())); - - uint256 sold = isToken0 ? delta0 : delta1; - uint256 received = isToken0 ? delta1 : delta0; - - if (isUsingEth) { - payable(address(msg.sender)).transfer(received); - } else { - TestERC20(numeraire).transfer(msg.sender, received); - } - - return (sold, received); - } -} diff --git a/test/unit/Constructor.t.sol b/test/unit/Constructor.t.sol index 011b28d9..7dc53f70 100644 --- a/test/unit/Constructor.t.sol +++ b/test/unit/Constructor.t.sol @@ -2,7 +2,6 @@ pragma solidity 0.8.26; import {BaseTest} from "test/shared/BaseTest.sol"; import {DopplerImplementation} from "test/shared/DopplerImplementation.sol"; -import {TestERC20} from "test/shared/BaseTest.sol"; import { MAX_TICK_SPACING, MAX_PRICE_DISCOVERY_SLUGS, @@ -41,7 +40,7 @@ contract ConstructorTest is BaseTest { isToken0 = _isToken0; (token0, token1) = isToken0 ? (asset, numeraire) : (numeraire, asset); - TestERC20(isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); + (isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); vm.label(address(token0), "Token0"); vm.label(address(token1), "Token1"); diff --git a/test/unit/EarlyExit.t.sol b/test/unit/EarlyExit.t.sol index 753ed695..773867da 100644 --- a/test/unit/EarlyExit.t.sol +++ b/test/unit/EarlyExit.t.sol @@ -3,7 +3,6 @@ pragma solidity 0.8.26; import {Test} from "forge-std/Test.sol"; import {BaseTest} from "test/shared/BaseTest.sol"; -import {TestERC20} from "v4-core/src/test/TestERC20.sol"; import {DopplerImplementation} from "test/shared/DopplerImplementation.sol"; import {IPoolManager} from "v4-periphery/lib/v4-core/src/interfaces/IPoolManager.sol"; import {PoolId, PoolIdLibrary} from "v4-periphery/lib/v4-core/src/types/PoolId.sol"; @@ -28,7 +27,7 @@ contract EarlyExitTest is BaseTest { function deployDoppler(DopplerConfig memory config) internal { (token0, token1) = isToken0 ? (asset, numeraire) : (numeraire, asset); - TestERC20(isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); + (isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); vm.label(address(token0), "Token0"); vm.label(address(token1), "Token1"); @@ -72,10 +71,10 @@ contract EarlyExitTest is BaseTest { modifyLiquidityRouter = new PoolModifyLiquidityTest(manager); // Approve the router to spend tokens on behalf of the test contract - TestERC20(token0).approve(address(swapRouter), type(uint256).max); - TestERC20(token1).approve(address(swapRouter), type(uint256).max); - TestERC20(token0).approve(address(modifyLiquidityRouter), type(uint256).max); - TestERC20(token1).approve(address(modifyLiquidityRouter), type(uint256).max); + token0.approve(address(swapRouter), type(uint256).max); + token1.approve(address(swapRouter), type(uint256).max); + token0.approve(address(modifyLiquidityRouter), type(uint256).max); + token1.approve(address(modifyLiquidityRouter), type(uint256).max); } function test_swap_RevertsIfMaximumProceedsReached() public { @@ -88,7 +87,12 @@ contract EarlyExitTest is BaseTest { int256 maximumProceeds = int256(hook.getMaximumProceeds()); - buy(-maximumProceeds); + swapRouter.swap( + key, + IPoolManager.SwapParams(!isToken0, -maximumProceeds, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch diff --git a/test/unit/Receive.t.sol b/test/unit/Receive.t.sol deleted file mode 100644 index 9fd26deb..00000000 --- a/test/unit/Receive.t.sol +++ /dev/null @@ -1,21 +0,0 @@ -pragma solidity 0.8.26; - -import {Test} from "forge-std/Test.sol"; - -import {IPoolManager} from "v4-periphery/lib/v4-core/src/interfaces/IPoolManager.sol"; -import {Hooks} from "v4-core/src/libraries/Hooks.sol"; -import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; -import {Hooks} from "v4-core/src/libraries/Hooks.sol"; -import {PoolId, PoolIdLibrary} from "v4-periphery/lib/v4-core/src/types/PoolId.sol"; -import {PoolKey} from "v4-periphery/lib/v4-core/src/types/PoolKey.sol"; -import {StateLibrary} from "v4-periphery/lib/v4-core/src/libraries/StateLibrary.sol"; - -import {InvalidTime, SwapBelowRange} from "src/Doppler.sol"; -import {BaseTest} from "test/shared/BaseTest.sol"; -import {Position} from "../../src/Doppler.sol"; - -contract ReceiveTest is BaseTest { - function test_receive() public { - payable(address(hook)).transfer(1 ether); - } -} diff --git a/test/unit/SlugVis.t.sol b/test/unit/SlugVis.t.sol index e48820de..b17f8587 100644 --- a/test/unit/SlugVis.t.sol +++ b/test/unit/SlugVis.t.sol @@ -19,8 +19,16 @@ contract SlugVisTest is BaseTest { vm.warp(hook.getStartingTime()); PoolKey memory poolKey = key; + bool isToken0 = hook.getIsToken0(); - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); SlugVis.visualizeSlugs(hook, poolKey.toId(), "test", block.timestamp); } @@ -29,8 +37,16 @@ contract SlugVisTest is BaseTest { vm.warp(hook.getStartingTime()); PoolKey memory poolKey = key; - - buy(1); + bool isToken0 = hook.getIsToken0(); + + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + poolKey, + IPoolManager.SwapParams(!isToken0, 1, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); SlugVis.visualizeSlugs(hook, poolKey.toId(), "test", block.timestamp); } diff --git a/test/unit/Swap.sol b/test/unit/Swap.sol index b7837fcd..7fed29fd 100644 --- a/test/unit/Swap.sol +++ b/test/unit/Swap.sol @@ -1,9 +1,13 @@ pragma solidity 0.8.26; +import {Test} from "forge-std/Test.sol"; + import {MAX_SWAP_FEE} from "src/Doppler.sol"; import {IPoolManager} from "v4-periphery/lib/v4-core/src/interfaces/IPoolManager.sol"; import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; import {Hooks} from "v4-core/src/libraries/Hooks.sol"; +import {PoolId, PoolIdLibrary} from "v4-periphery/lib/v4-core/src/types/PoolId.sol"; +import {PoolKey} from "v4-periphery/lib/v4-core/src/types/PoolKey.sol"; import {ProtocolFeeLibrary} from "v4-periphery/lib/v4-core/src/libraries/ProtocolFeeLibrary.sol"; import {StateLibrary} from "v4-periphery/lib/v4-core/src/libraries/StateLibrary.sol"; import {FullMath} from "v4-periphery/lib/v4-core/src/libraries/FullMath.sol"; @@ -14,8 +18,10 @@ import { InvalidSwapAfterMaturitySufficientProceeds } from "src/Doppler.sol"; import {BaseTest} from "test/shared/BaseTest.sol"; +import {Position} from "../../src/Doppler.sol"; contract SwapTest is BaseTest { + using PoolIdLibrary for PoolKey; using StateLibrary for IPoolManager; using ProtocolFeeLibrary for *; @@ -144,12 +150,26 @@ contract SwapTest is BaseTest { function test_swap_DoesNotRebalanceTwiceInSameEpoch() public { vm.warp(hook.getStartingTime()); - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + key, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (uint40 lastEpoch, int256 tickAccumulator, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + key, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (uint40 lastEpoch2, int256 tickAccumulator2, uint256 totalTokensSold2,, uint256 totalTokensSoldLastEpoch2,) = hook.state(); @@ -166,7 +186,14 @@ contract SwapTest is BaseTest { function test_swap_UpdatesLastEpoch() public { vm.warp(hook.getStartingTime()); - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + key, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (uint40 lastEpoch,,,,,) = hook.state(); @@ -174,7 +201,14 @@ contract SwapTest is BaseTest { vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + key, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (lastEpoch,,,,,) = hook.state(); @@ -184,11 +218,25 @@ contract SwapTest is BaseTest { function test_swap_UpdatesTotalTokensSoldLastEpoch() public { vm.warp(hook.getStartingTime()); - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + key, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch - buy(1 ether); + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + key, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (,, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); @@ -205,7 +253,12 @@ contract SwapTest is BaseTest { uint256 amountInLessFee = FullMath.mulDiv(uint256(amountIn), MAX_SWAP_FEE - swapFee, MAX_SWAP_FEE); - buy(-amountIn); + swapRouter.swap( + key, + IPoolManager.SwapParams(!isToken0, -amountIn, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (,, uint256 totalTokensSold, uint256 totalProceeds,,) = hook.state(); @@ -213,7 +266,12 @@ contract SwapTest is BaseTest { amountInLessFee = FullMath.mulDiv(uint256(totalTokensSold), MAX_SWAP_FEE - swapFee, MAX_SWAP_FEE); - sell(-int256(totalTokensSold)); + swapRouter.swap( + key, + IPoolManager.SwapParams(isToken0, -int256(totalTokensSold), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); (,, uint256 totalTokensSold2,,,) = hook.state(); @@ -242,20 +300,34 @@ contract SwapTest is BaseTest { function test_swap_CannotSwapBelowLowerSlug_AfterSoldAndUnsold() public { vm.warp(hook.getStartingTime()); - buy(1 ether); + // Sell some tokens + swapRouter.swap( + // Swap numeraire to asset + // If zeroForOne, we use max price limit (else vice versa) + key, + IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch // Swap to trigger lower slug being created // Unsell half of sold tokens - sell(-0.5 ether); + swapRouter.swap( + // Swap asset to numeraire + // If zeroForOne, we use max price limit (else vice versa) + key, + IPoolManager.SwapParams(isToken0, -0.5 ether, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); vm.expectRevert( abi.encodeWithSelector( Hooks.Wrap__FailedHookCall.selector, hook, abi.encodeWithSelector(SwapBelowRange.selector) ) ); - // Unsell beyond remaining tokens, moving price below lower slug swapRouter.swap( // Swap asset to numeraire From 2cf7329cf71dbe0c20ced91aba8bf4cec3707041 Mon Sep 17 00:00:00 2001 From: Matt Czernik Date: Wed, 23 Oct 2024 15:41:42 -0400 Subject: [PATCH 3/4] Feat/eth support 2 (#160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert swap utils (#152) * Add USING_ETH workflow + fix eth test cases (#154) --------- Co-authored-by: clemlak Co-authored-by: Clément Lakhal <39790678+clemlak@users.noreply.github.com> --- .github/workflows/test.yml | 31 +- src/Doppler.sol | 33 +- test/integration/Rebalance.t.sol | 505 +++++++++------------------ test/invariant/DopplerHandler.sol | 40 ++- test/invariant/DopplerInvariants.sol | 30 +- test/shared/BaseTest.sol | 253 ++++++++++++-- test/shared/CustomRouter.sol | 191 ++++++++++ test/unit/Constructor.t.sol | 8 +- test/unit/EarlyExit.t.sol | 41 +-- test/unit/Receive.t.sol | 21 ++ test/unit/SlugVis.t.sol | 22 +- test/unit/Swap.sol | 226 ++---------- 12 files changed, 769 insertions(+), 632 deletions(-) create mode 100644 test/shared/CustomRouter.sol create mode 100644 test/unit/Receive.t.sol diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4fb4dec5..6d655201 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,18 +42,41 @@ jobs: forge test -vvv --via-ir id: test0 - - name: Run forge tests isToken0 (true) fee (300) protocolFee (0) + - name: Run forge tests isToken0 (true) fee (30) protocolFee (0) run: | export IS_TOKEN_0=TRUE - export FEE=300 + export FEE=30 forge test -vvv --via-ir id: test1 - - name: Run forge tests isToken0 (true) fee (300) protocolFee (50) + - name: Run forge tests isToken0 (true) fee (30) protocolFee (50) run: | export IS_TOKEN_0=TRUE - export FEE=300 + export FEE=30 export PROTOCOL_FEE=50 forge test -vvv --via-ir id: test2 + - name: Run forge tests with usingEth (true) + run: | + export USING_ETH=TRUE + export FEE=0 + export PROTOCOL_FEE=0 + forge test -vvv --via-ir + id: test3 + + - name: Run forge tests with usingEth (true) fee (30) protocolFee (0) + run: | + export USING_ETH=TRUE + export FEE=30 + export PROTOCOL_FEE=0 + forge test -vvv --via-ir + id: test4 + + - name: Run forge tests with usingEth (true) fee (30) protocolFee (50) + run: | + export USING_ETH=TRUE + export FEE=30 + export PROTOCOL_FEE=50 + forge test -vvv --via-ir + id: test5 diff --git a/src/Doppler.sol b/src/Doppler.sol index dd299b83..c1c1cd8c 100644 --- a/src/Doppler.sol +++ b/src/Doppler.sol @@ -78,6 +78,8 @@ contract Doppler is BaseHook { bool immutable isToken0; // whether token0 is the token being sold (true) or token1 (false) uint256 immutable numPDSlugs; // number of price discovery slugs + receive() external payable {} + constructor( IPoolManager _poolManager, PoolKey memory _poolKey, @@ -216,11 +218,13 @@ contract Doppler is BaseHook { for (uint256 i; i < numPDSlugs + 1; ++i) { delete positions[bytes32(uint256(2 + i))]; } - } else { - revert InvalidSwapAfterMaturitySufficientProceeds(); } } + if (block.timestamp > endingTime && !insufficientProceeds) { + revert InvalidSwapAfterMaturitySufficientProceeds(); + } + if (!insufficientProceeds) { _rebalance(key); } else if (isToken0) { @@ -230,7 +234,7 @@ contract Doppler is BaseHook { } } else { if (swapParams.zeroForOne == true) { - revert InvalidSwapAfterMaturitySufficientProceeds(); + revert InvalidSwapAfterMaturityInsufficientProceeds(); } } @@ -427,7 +431,8 @@ contract Doppler is BaseHook { SlugData memory lowerSlug = _computeLowerSlugData(key, requiredProceeds, numeraireAvailable, totalTokensSold_, tickLower, currentTick); - (SlugData memory upperSlug, uint256 assetRemaining) = _computeUpperSlugData(key, totalTokensSold_, currentTick, assetAvailable); + (SlugData memory upperSlug, uint256 assetRemaining) = + _computeUpperSlugData(key, totalTokensSold_, currentTick, assetAvailable); SlugData[] memory priceDiscoverySlugs = _computePriceDiscoverySlugsData(key, upperSlug, tickUpper, assetRemaining); @@ -726,8 +731,8 @@ contract Doppler is BaseHook { (BalanceDelta positionDeltas, BalanceDelta feesAccrued) = poolManager.modifyLiquidity( key, IPoolManager.ModifyLiquidityParams({ - tickLower: lastEpochPositions[i].tickLower, - tickUpper: lastEpochPositions[i].tickUpper, + tickLower: isToken0 ? lastEpochPositions[i].tickLower : lastEpochPositions[i].tickUpper, + tickUpper: isToken0 ? lastEpochPositions[i].tickUpper : lastEpochPositions[i].tickLower, liquidityDelta: -int128(lastEpochPositions[i].liquidity), salt: bytes32(uint256(lastEpochPositions[i].salt)) }), @@ -763,12 +768,8 @@ contract Doppler is BaseHook { poolManager.modifyLiquidity( key, IPoolManager.ModifyLiquidityParams({ - tickLower: newPositions[i].tickLower < newPositions[i].tickUpper - ? newPositions[i].tickLower - : newPositions[i].tickUpper, - tickUpper: newPositions[i].tickUpper > newPositions[i].tickLower - ? newPositions[i].tickUpper - : newPositions[i].tickLower, + tickLower: isToken0 ? newPositions[i].tickLower : newPositions[i].tickUpper, + tickUpper: isToken0 ? newPositions[i].tickUpper : newPositions[i].tickLower, liquidityDelta: int128(newPositions[i].liquidity), salt: bytes32(uint256(newPositions[i].salt)) }), @@ -824,8 +825,8 @@ contract Doppler is BaseHook { (BalanceDelta callerDelta,) = poolManager.modifyLiquidity( key, IPoolManager.ModifyLiquidityParams({ - tickLower: upperSlug.tickLower, - tickUpper: upperSlug.tickUpper, + tickLower: isToken0 ? upperSlug.tickLower : upperSlug.tickUpper, + tickUpper: isToken0 ? upperSlug.tickUpper : upperSlug.tickLower, liquidityDelta: int128(upperSlug.liquidity), salt: UPPER_SLUG_SALT }), @@ -839,8 +840,8 @@ contract Doppler is BaseHook { (BalanceDelta callerDelta,) = poolManager.modifyLiquidity( key, IPoolManager.ModifyLiquidityParams({ - tickLower: priceDiscoverySlugs[i].tickLower, - tickUpper: priceDiscoverySlugs[i].tickUpper, + tickLower: isToken0 ? priceDiscoverySlugs[i].tickLower : priceDiscoverySlugs[i].tickUpper, + tickUpper: isToken0 ? priceDiscoverySlugs[i].tickUpper : priceDiscoverySlugs[i].tickLower, liquidityDelta: int128(priceDiscoverySlugs[i].liquidity), salt: bytes32(uint256(3 + i)) }), diff --git a/test/integration/Rebalance.t.sol b/test/integration/Rebalance.t.sol index cd17df68..ac4df8e3 100644 --- a/test/integration/Rebalance.t.sol +++ b/test/integration/Rebalance.t.sol @@ -22,20 +22,21 @@ import {ProtocolFeeLibrary} from "v4-periphery/lib/v4-core/src/libraries/Protoco import {InvalidTime, SwapBelowRange} from "src/Doppler.sol"; import {BaseTest} from "test/shared/BaseTest.sol"; import {Position} from "../../src/Doppler.sol"; - +import {stdMath} from "forge-std/StdMath.sol"; +import "forge-std/console.sol"; contract RebalanceTest is BaseTest { using PoolIdLibrary for PoolKey; using StateLibrary for IPoolManager; using BalanceDeltaLibrary for BalanceDelta; using ProtocolFeeLibrary for *; + using stdMath for *; function test_rebalance_ExtremeOversoldCase() public { // Go to starting time vm.warp(hook.getStartingTime()); PoolKey memory poolKey = key; - bool isToken0 = hook.getIsToken0(); // Compute the amount of tokens available in both the upper and price discovery slugs // Should be two epochs of liquidity available since we're at the startingTime @@ -43,28 +44,12 @@ contract RebalanceTest is BaseTest { // We sell all available tokens // This increases the price to the pool maximum - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(expectedAmountSold), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(expectedAmountSold)); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch // We swap again just to trigger the rebalancing logic in the new epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (, int256 tickAccumulator, uint256 totalTokensSold,,,) = hook.state(); @@ -86,16 +71,35 @@ contract RebalanceTest is BaseTest { // doesn't seem to due to rounding. Consider whether this is a problem or whether we // even need that case at all - // Validate that lower slug is not above the current tick - assertLe(lowerSlug.tickUpper, hook.getCurrentTick(poolKey.toId())); + // TODO: Double check this condition + + if (isToken0) { + // Validate that lower slug is not above the current tick + assertLe(lowerSlug.tickUpper, hook.getCurrentTick(poolKey.toId()), "lowerSlug.tickUpper > currentTick"); + } else { + // Validate that lower slug is not below the current tick + assertGe(lowerSlug.tickUpper, hook.getCurrentTick(poolKey.toId()), "lowerSlug.tickUpper < currentTick"); + } // Validate that upper slug and all price discovery slugs are placed continuously - assertEq(upperSlug.tickUpper, priceDiscoverySlugs[0].tickLower); + assertEq( + upperSlug.tickUpper, + priceDiscoverySlugs[0].tickLower, + "upperSlug.tickUpper != priceDiscoverySlugs[0].tickLower" + ); for (uint256 i; i < priceDiscoverySlugs.length; ++i) { if (i == 0) { - assertEq(upperSlug.tickUpper, priceDiscoverySlugs[i].tickLower); + assertEq( + upperSlug.tickUpper, + priceDiscoverySlugs[i].tickLower, + "upperSlug.tickUpper != priceDiscoverySlugs[i].tickLower" + ); } else { - assertEq(priceDiscoverySlugs[i - 1].tickUpper, priceDiscoverySlugs[i].tickLower); + assertEq( + priceDiscoverySlugs[i - 1].tickUpper, + priceDiscoverySlugs[i].tickLower, + "priceDiscoverySlugs[i - 1].tickUpper != priceDiscoverySlugs[i].tickLower" + ); } if (i == priceDiscoverySlugs.length - 1) { @@ -103,29 +107,23 @@ contract RebalanceTest is BaseTest { assertApproxEqAbs( priceDiscoverySlugs[i].tickUpper, tickUpper, - hook.getNumPDSlugs() * uint256(int256(poolKey.tickSpacing)) + hook.getNumPDSlugs() * uint256(int256(poolKey.tickSpacing)), + "priceDiscoverySlugs[i].tickUpper != tickUpper" ); } // Validate that each price discovery slug has liquidity - assertGt(priceDiscoverySlugs[i].liquidity, 0); + assertGt(priceDiscoverySlugs[i].liquidity, 0, "priceDiscoverySlugs[i].liquidity is 0"); } // Validate that the lower slug has liquidity - assertGt(lowerSlug.liquidity, 1e18); + assertGt(lowerSlug.liquidity, 1e18, "lowerSlug no liquidity"); // Validate that the upper slug has very little liquidity (dust) - assertLt(upperSlug.liquidity, 1e18); + assertLt(upperSlug.liquidity, 1e18, "upperSlug has liquidity"); // Validate that we can swap all tokens back into the curve - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(isToken0, -int256(totalTokensSold), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sell(-int256(totalTokensSold)); } function test_rebalance_LowerSlug_SufficientProceeds() public { @@ -133,23 +131,13 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime() + hook.getEpochLength() * 2); PoolKey memory poolKey = key; - bool isToken0 = hook.getIsToken0(); // Compute the expected amount sold to see how many tokens will be supplied in the upper slug // We should always have sufficient proceeds if we don't swap beyond the upper slug uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(1); // We sell half the expected amount to ensure that we don't surpass the upper slug - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(expectedAmountSold / 2), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(expectedAmountSold / 2)); (uint40 lastEpoch,, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); @@ -162,14 +150,7 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime() + hook.getEpochLength() * 3); // Next epoch // We swap again just to trigger the rebalancing logic in the new epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (, int256 tickAccumulator2, uint256 totalTokensSold2,,,) = hook.state(); @@ -181,8 +162,12 @@ contract RebalanceTest is BaseTest { (int24 tickLower,) = hook.getTicksBasedOnState(tickAccumulator2, poolKey.tickSpacing); // Validate that the lower slug is spanning the full range - if (hook.getCurrentTick(poolKey.toId()) == tickLower) { - assertEq(tickLower - poolKey.tickSpacing, lowerSlug.tickLower, "lowerSlug.tickLower != global tickLower"); + if (stdMath.delta(hook.getCurrentTick(poolKey.toId()), tickLower) <= 1) { + assertEq( + tickLower + (isToken0 ? -poolKey.tickSpacing : poolKey.tickSpacing), + lowerSlug.tickLower, + "lowerSlug.tickLower != global tickLower" + ); } else { assertEq(tickLower, lowerSlug.tickLower, "lowerSlug.tickUpper != global tickLower"); } @@ -192,14 +177,7 @@ contract RebalanceTest is BaseTest { assertGt(lowerSlug.liquidity, 0); // Validate that we can swap all tokens back into the curve - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(isToken0, -int256(totalTokensSold2), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sell(-int256(totalTokensSold2)); } function test_rebalance_LowerSlug_InsufficientProceeds() public { @@ -214,28 +192,12 @@ contract RebalanceTest is BaseTest { uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(2); // We sell 90% of the expected amount so we stay in range but trigger insufficient proceeds case - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(expectedAmountSold * 9 / 10), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(expectedAmountSold * 9 / 10)); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch // We swap again just to trigger the rebalancing logic in the new epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1); (, int256 tickAccumulator, uint256 totalTokensSold,,,) = hook.state(); @@ -251,7 +213,9 @@ contract RebalanceTest is BaseTest { (, int24 tickUpper) = hook.getTicksBasedOnState(tickAccumulator, poolKey.tickSpacing); // Validate that lower slug is not above the current tick - assertLe(lowerSlug.tickUpper, hook.getCurrentTick(poolKey.toId())); + isToken0 + ? assertLe(lowerSlug.tickUpper, hook.getCurrentTick(poolKey.toId())) + : assertGe(lowerSlug.tickUpper, hook.getCurrentTick(poolKey.toId())); if (isToken0) { assertEq(lowerSlug.tickUpper - lowerSlug.tickLower, poolKey.tickSpacing); } else { @@ -282,26 +246,25 @@ contract RebalanceTest is BaseTest { assertGt(priceDiscoverySlugs[i].liquidity, 0); } - uint256 amount0Delta = LiquidityAmounts.getAmount0ForLiquidity( - TickMath.getSqrtPriceAtTick(lowerSlug.tickLower), - TickMath.getSqrtPriceAtTick(lowerSlug.tickUpper), - lowerSlug.liquidity - ); + uint256 amountDelta = isToken0 + ? LiquidityAmounts.getAmount0ForLiquidity( + TickMath.getSqrtPriceAtTick(lowerSlug.tickLower), + TickMath.getSqrtPriceAtTick(lowerSlug.tickUpper), + lowerSlug.liquidity + ) + : LiquidityAmounts.getAmount1ForLiquidity( + TickMath.getSqrtPriceAtTick(lowerSlug.tickLower), + TickMath.getSqrtPriceAtTick(lowerSlug.tickUpper), + lowerSlug.liquidity + ); // assert that the lowerSlug can support the purchase of 99.9% of the tokens sold - assertApproxEqAbs(amount0Delta, totalTokensSold, totalTokensSold * 1 / 1000); + assertApproxEqAbs(amountDelta, totalTokensSold, totalTokensSold * 1 / 1000); // TODO: Figure out how this can possibly fail even though the following trade succeeds - assertGt(amount0Delta, totalTokensSold); + assertGt(amountDelta, totalTokensSold); // Validate that we can swap all tokens back into the curve - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(isToken0, -int256(totalTokensSold), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sell(-int256(totalTokensSold)); } function test_rebalance_LowerSlug_NoLiquidity() public { @@ -313,14 +276,7 @@ contract RebalanceTest is BaseTest { // We sell some tokens to trigger the initial rebalance // We haven't sold any tokens in previous epochs so we shouldn't place a lower slug - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); // Get the lower slug Position memory lowerSlug = hook.getPositions(bytes32(uint256(1))); @@ -344,28 +300,12 @@ contract RebalanceTest is BaseTest { uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(1); // We sell half the expected amount to ensure that we hit the undersold case - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(expectedAmountSold / 2), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(expectedAmountSold / 2)); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch // We swap again just to trigger the rebalancing logic in the new epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (, int256 tickAccumulator,,,,) = hook.state(); @@ -381,12 +321,21 @@ contract RebalanceTest is BaseTest { (int24 tickLower, int24 tickUpper) = hook.getTicksBasedOnState(tickAccumulator, poolKey.tickSpacing); // Validate that the slugs are continuous and all have liquidity - // TODO: figure out why this is happening - assertEq( - lowerSlug.tickLower, - tickLower - poolKey.tickSpacing, - "tickLower - poolKey.tickSpacing != lowerSlug.tickLower" - ); + // TODO: I tried fixing this using isToken0, not sure if it should work this way though. + if (isToken0) { + assertEq( + lowerSlug.tickLower, + tickLower - poolKey.tickSpacing, + "tickLower - poolKey.tickSpacing != lowerSlug.tickLower" + ); + } else { + assertEq( + lowerSlug.tickLower, + tickLower + poolKey.tickSpacing, + "tickLower + poolKey.tickSpacing != lowerSlug.tickLower" + ); + } + assertEq(lowerSlug.tickUpper, upperSlug.tickLower, "lowerSlug.tickUpper != upperSlug.tickLower"); for (uint256 i; i < priceDiscoverySlugs.length; ++i) { @@ -418,6 +367,7 @@ contract RebalanceTest is BaseTest { // Explicitly checking that accumulatorDelta is nonzero to show issues with // implicit assumption that gamma is positive. accumulatorDelta = accumulatorDelta != 0 ? accumulatorDelta : poolKey.tickSpacing; + // TODO(matt): why are we adding/subtracting the tickSpacing here??? if (isToken0) { assertEq( hook.alignComputedTickWithTickSpacing(upperSlug.tickLower + accumulatorDelta, poolKey.tickSpacing) @@ -427,7 +377,8 @@ contract RebalanceTest is BaseTest { ); } else { assertEq( - hook.alignComputedTickWithTickSpacing(upperSlug.tickLower - accumulatorDelta, poolKey.tickSpacing), + hook.alignComputedTickWithTickSpacing(upperSlug.tickLower - accumulatorDelta, poolKey.tickSpacing) + - poolKey.tickSpacing, upperSlug.tickUpper, "upperSlug.tickUpper != upperSlug.tickLower - accumulatorDelta" ); @@ -448,14 +399,7 @@ contract RebalanceTest is BaseTest { bool isToken0 = hook.getIsToken0(); // We sell one wei to trigger the rebalance without messing with resulting liquidity positions - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1); // Get the upper and price discover slugs Position memory upperSlug = hook.getPositions(bytes32(uint256(2))); @@ -465,7 +409,9 @@ contract RebalanceTest is BaseTest { } // Assert that the slugs are continuous - assertEq(hook.getCurrentTick(poolKey.toId()), upperSlug.tickLower); + assertApproxEqAbs( + hook.getCurrentTick(poolKey.toId()), upperSlug.tickLower, 1, "currentTick != upperSlug.tickLower" + ); // We should only have one price discovery slug at this point assertEq(upperSlug.tickUpper, priceDiscoverySlugs[0].tickLower); @@ -513,21 +459,16 @@ contract RebalanceTest is BaseTest { bool isToken0 = hook.getIsToken0(); // We sell one wei to trigger the rebalance without messing with resulting liquidity positions - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1); // Get the upper and price discover slugs Position memory upperSlug = hook.getPositions(bytes32(uint256(2))); Position memory priceDiscoverySlug = hook.getPositions(bytes32(uint256(3))); // Assert that the upperSlug is correctly placed - assertEq(hook.getCurrentTick(poolKey.toId()), upperSlug.tickLower); + assertApproxEqAbs( + hook.getCurrentTick(poolKey.toId()), upperSlug.tickLower, 1, "currentTick != upperSlug.tickLower" + ); // Assert that the priceDiscoverySlug has no liquidity assertEq(priceDiscoverySlug.liquidity, 0); @@ -556,17 +497,10 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime()); PoolKey memory poolKey = key; - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); - (uint40 lastEpoch,, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = - hook.state(); + buy(1 ether); + + (uint40 lastEpoch,, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); assertEq(lastEpoch, 1); // We sold 1e18 tokens just now @@ -577,14 +511,7 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Swap tokens back into the pool, netSold == 0 - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(isToken0, -1 ether, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sell(-1 ether); (uint40 lastEpoch2, int256 tickAccumulator2, uint256 totalTokensSold2,, uint256 totalTokensSoldLastEpoch2,) = hook.state(); @@ -596,17 +523,9 @@ contract RebalanceTest is BaseTest { assertEq(totalTokensSoldLastEpoch2, 1 ether); vm.warp(hook.getStartingTime() + hook.getEpochLength() * 2); // Next epoch - SlugVis.visualizeSlugs(hook, poolKey.toId(), "test", block.timestamp); // We swap again just to trigger the rebalancing logic in the new epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (uint40 lastEpoch3, int256 tickAccumulator3, uint256 totalTokensSold3,, uint256 totalTokensSoldLastEpoch3,) = hook.state(); @@ -667,56 +586,39 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime()); PoolKey memory poolKey = key; - bool isToken0 = hook.getIsToken0(); // Get the expected amount sold by next epoch uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(1); // We sell half the expected amount - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(expectedAmountSold / 2), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(expectedAmountSold / 2)); (uint40 lastEpoch, int256 tickAccumulator, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); - assertEq(lastEpoch, 1); + assertEq(lastEpoch, 1, "Wrong last epoch"); // Confirm we sold half the expected amount - assertEq(totalTokensSold, expectedAmountSold / 2); + assertEq(totalTokensSold, expectedAmountSold / 2, "Wrong tokens sold"); // Previous epoch didn't exist so no tokens would have been sold at the time - assertEq(totalTokensSoldLastEpoch, 0); + assertEq(totalTokensSoldLastEpoch, 0, "Wrong tokens sold last epoch"); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch // We swap again just to trigger the rebalancing logic in the new epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (uint40 lastEpoch2, int256 tickAccumulator2, uint256 totalTokensSold2,, uint256 totalTokensSoldLastEpoch2,) = hook.state(); - assertEq(lastEpoch2, 2); + assertEq(lastEpoch2, 2, "Wrong last epoch (2)"); // We sold some tokens just now - assertEq(totalTokensSold2, expectedAmountSold / 2 + 1e18); + assertEq(totalTokensSold2, expectedAmountSold / 2 + 1e18, "Wrong tokens sold (2)"); // The net sold amount in the previous epoch half the expected amount - assertEq(totalTokensSoldLastEpoch2, expectedAmountSold / 2); + assertEq(totalTokensSoldLastEpoch2, expectedAmountSold / 2, "Wrong tokens sold last epoch (2)"); // Assert that we reduced the accumulator by half the max amount as intended int256 maxTickDeltaPerEpoch = hook.getMaxTickDeltaPerEpoch(); - assertEq(tickAccumulator2, tickAccumulator + maxTickDeltaPerEpoch / 2); + assertEq(tickAccumulator2, tickAccumulator + maxTickDeltaPerEpoch / 2, "Wrong tick accumulator"); // Get positions Position memory lowerSlug = hook.getPositions(bytes32(uint256(1))); @@ -734,13 +636,21 @@ contract RebalanceTest is BaseTest { int24 currentTick = hook.getCurrentTick(poolId); // Slugs must be inline and continuous - assertEq(lowerSlug.tickUpper, upperSlug.tickLower); + assertEq(lowerSlug.tickUpper, upperSlug.tickLower, "Wrong ticks for lower and upper slugs"); for (uint256 i; i < priceDiscoverySlugs.length; ++i) { if (i == 0) { - assertEq(upperSlug.tickUpper, priceDiscoverySlugs[i].tickLower); + assertEq( + upperSlug.tickUpper, + priceDiscoverySlugs[i].tickLower, + "Wrong ticks upperSlug.tickUpper / priceDiscoverySlugs[i].tickLower" + ); } else { - assertEq(priceDiscoverySlugs[i - 1].tickUpper, priceDiscoverySlugs[i].tickLower); + assertEq( + priceDiscoverySlugs[i - 1].tickUpper, + priceDiscoverySlugs[i].tickLower, + "Wrong ticks priceDiscoverySlugs[i - 1].tickUpper / priceDiscoverySlugs[i].tickLower" + ); } if (i == priceDiscoverySlugs.length - 1) { @@ -753,15 +663,16 @@ contract RebalanceTest is BaseTest { } // Validate that each price discovery slug has liquidity - assertGt(priceDiscoverySlugs[i].liquidity, 0); + assertGt(priceDiscoverySlugs[i].liquidity, 0, "Wrong liquidity for price discovery slug"); } // Lower slug upper tick should be at the currentTick - assertEq(lowerSlug.tickUpper, currentTick); + // use abs because if !istoken0 the tick will be currentTick - 1 because swappingn 1 wei causes us to round down + assertApproxEqAbs(lowerSlug.tickUpper, currentTick, 1, "lowerSlug.tickUpper not at currentTick"); // All slugs must be set - assertNotEq(lowerSlug.liquidity, 0); - assertNotEq(upperSlug.liquidity, 0); + assertNotEq(lowerSlug.liquidity, 0, "lowerSlug.liquidity is 0"); + assertNotEq(upperSlug.liquidity, 0, "upperSlug.liquidity is 0"); } function test_rebalance_OversoldCase() public { @@ -774,16 +685,7 @@ contract RebalanceTest is BaseTest { uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(1); // We buy 1.5x the expectedAmountSold - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(expectedAmountSold * 3 / 2), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(expectedAmountSold * 3 / 2)); (uint40 lastEpoch, int256 tickAccumulator, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); @@ -801,14 +703,7 @@ contract RebalanceTest is BaseTest { int24 currentTick = hook.getCurrentTick(poolId); // We swap again just to trigger the rebalancing logic in the new epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (uint40 lastEpoch2, int256 tickAccumulator2, uint256 totalTokensSold2,, uint256 totalTokensSoldLastEpoch2,) = hook.state(); @@ -838,7 +733,7 @@ contract RebalanceTest is BaseTest { // to either case and should only validate that the slugs are placed correctly. // Lower slug upper tick must not be greater than the currentTick - assertLe(lowerSlug.tickUpper, currentTick); + isToken0 ? assertLe(lowerSlug.tickUpper, currentTick) : assertGe(lowerSlug.tickUpper, currentTick); // Upper and price discovery slugs must be inline and continuous for (uint256 i; i < priceDiscoverySlugs.length; ++i) { @@ -867,12 +762,8 @@ contract RebalanceTest is BaseTest { } function test_rebalance_CollectsFeeFromAllSlugs() public { - PoolKey memory poolKey = key; - vm.warp(hook.getStartingTime()); - bool isToken0 = hook.getIsToken0(); - (,, uint24 protocolFee, uint24 lpFee) = manager.getSlot0(key.toId()); (,,,,, BalanceDelta feesAccrued) = hook.state(); @@ -889,14 +780,6 @@ contract RebalanceTest is BaseTest { upperSlug.liquidity ) * 9 / 10; - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, -int256(amount1ToSwap), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); uint256 amount0ToSwap = LiquidityAmounts.getAmount0ForLiquidity( TickMath.getSqrtPriceAtTick(upperSlug.tickLower), @@ -904,26 +787,13 @@ contract RebalanceTest is BaseTest { upperSlug.liquidity ) * 9 / 10; - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(isToken0, -int256(amount0ToSwap), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + isToken0 ? buy(-int256(amount1ToSwap)) : buy(-int256(amount0ToSwap)); + isToken0 ? sell(-int256(amount0ToSwap)) : sell(-int256(amount1ToSwap)); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // trigger rebalance to accrue fees - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(isToken0, 1, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1); (,,,,, feesAccrued) = hook.state(); @@ -948,7 +818,6 @@ contract RebalanceTest is BaseTest { function test_rebalance_FullFlow() public { PoolKey memory poolKey = key; - bool isToken0 = hook.getIsToken0(); // Max dutch auction over first few skipped epochs // =============================================== @@ -957,14 +826,8 @@ contract RebalanceTest is BaseTest { vm.warp(hook.getStartingTime() + hook.getEpochLength() * 3); // Swap less then expected amount - to be used checked in the next epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + + buy(1 ether); (uint40 lastEpoch, int256 tickAccumulator, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); @@ -1021,7 +884,7 @@ contract RebalanceTest is BaseTest { // Lower slug should be unset with ticks at the current price assertEq(lowerSlug.tickLower, lowerSlug.tickUpper, "first swap: lowerSlug.tickLower != lowerSlug.tickUpper"); assertEq(lowerSlug.liquidity, 0, "first swap: lowerSlug.liquidity != 0"); - assertEq(lowerSlug.tickUpper, currentTick, "first swap: lowerSlug.tickUpper != currentTick"); + assertApproxEqAbs(lowerSlug.tickUpper, currentTick, 1, "first swap: lowerSlug.tickUpper != currentTick"); // Upper and price discovery slugs must be set assertNotEq(upperSlug.liquidity, 0, "first swap: upperSlug.liquidity != 0"); @@ -1036,16 +899,7 @@ contract RebalanceTest is BaseTest { uint256 expectedAmountSold = hook.getExpectedAmountSoldWithEpochOffset(1); // Trigger the oversold case by selling more than expected - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(expectedAmountSold), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(expectedAmountSold)); (uint40 lastEpoch2, int256 tickAccumulator2, uint256 totalTokensSold2,, uint256 totalTokensSoldLastEpoch2,) = hook.state(); @@ -1116,14 +970,7 @@ contract RebalanceTest is BaseTest { currentTick = hook.getCurrentTick(poolId); // Trigger rebalance - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (uint40 lastEpoch3, int256 tickAccumulator3, uint256 totalTokensSold3,, uint256 totalTokensSoldLastEpoch3,) = hook.state(); @@ -1153,8 +1000,13 @@ contract RebalanceTest is BaseTest { // Get current tick currentTick = hook.getCurrentTick(poolId); - // Lower slug must not be above current tick - assertLe(lowerSlug.tickUpper, currentTick, "third swap: lowerSlug.tickUpper > currentTick"); + if (isToken0) { + // Lower slug must not be above current tick + assertLe(lowerSlug.tickUpper, currentTick, "third swap: lowerSlug.tickUpper > currentTick"); + } else { + // Lower slug must not be below current tick + assertGe(lowerSlug.tickUpper, currentTick, "third swap: lowerSlug.tickUpper < currentTick"); + } // Upper slugs must be inline and continuous for (uint256 i; i < priceDiscoverySlugs.length; ++i) { @@ -1182,14 +1034,7 @@ contract RebalanceTest is BaseTest { assertNotEq(upperSlug.liquidity, 0, "third swap: upperSlug.liquidity != 0"); // Validate that we can swap all tokens back into the curve - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(isToken0, -int256(totalTokensSold3), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sell(-int256(totalTokensSold3)); // Swap in second last epoch // ======================== @@ -1201,14 +1046,7 @@ contract RebalanceTest is BaseTest { ); // Swap some tokens - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (, int256 tickAccumulator4,,,,) = hook.state(); @@ -1225,8 +1063,13 @@ contract RebalanceTest is BaseTest { // Get current tick currentTick = hook.getCurrentTick(poolId); - // Lower slug must not be greater than current tick - assertLe(lowerSlug.tickUpper, currentTick, "fourth swap: lowerSlug.tickUpper > currentTick"); + if (isToken0) { + // Lower slug must not be greater than current tick + assertLe(lowerSlug.tickUpper, currentTick, "fourth swap: lowerSlug.tickUpper > currentTick"); + } else { + // Lower slug must not be less than current tick + assertGe(lowerSlug.tickUpper, currentTick, "fourth swap: lowerSlug.tickUpper < currentTick"); + } // Upper slugs must be inline and continuous // In this case we only have one price discovery slug since we're on the second last epoch @@ -1249,14 +1092,7 @@ contract RebalanceTest is BaseTest { ); // Swap some tokens - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (, int256 tickAccumulator5,,,,) = hook.state(); @@ -1274,12 +1110,20 @@ contract RebalanceTest is BaseTest { currentTick = hook.getCurrentTick(poolId); // Slugs must be inline and continuous - if (currentTick == tickLower) { - assertEq( - tickLower - poolKey.tickSpacing, - lowerSlug.tickLower, - "fifth swap: lowerSlug.tickLower != global tickLower" - ); + if (stdMath.delta(currentTick, tickLower) <= 1) { + if (isToken0) { + assertEq( + tickLower - poolKey.tickSpacing, + lowerSlug.tickLower, + "fifth swap: lowerSlug.tickLower != global tickLower" + ); + } else { + assertEq( + tickLower + poolKey.tickSpacing, + lowerSlug.tickLower, + "fifth swap: lowerSlug.tickUpper != global tickLower" + ); + } } else { assertEq(tickLower, lowerSlug.tickLower, "fifth swap: lowerSlug.tickUpper != global tickLower"); } @@ -1308,16 +1152,7 @@ contract RebalanceTest is BaseTest { (,, uint256 totalTokensSold4,,,) = hook.state(); // Swap all remaining tokens - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams( - !isToken0, int256(numTokensToSell - totalTokensSold4), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(int256(numTokensToSell - totalTokensSold4)); (, int256 tickAccumulator6,,,,) = hook.state(); @@ -1333,15 +1168,11 @@ contract RebalanceTest is BaseTest { // Get current tick currentTick = hook.getCurrentTick(poolId); - - // if (currentTick == tickLower) { - // TODO: figure out why this works, it should in theory be hitting the tickLower == lowerSlug.tickLower case assertEq( - tickLower - poolKey.tickSpacing, lowerSlug.tickLower, "sixth swap: lowerSlug.tickLower != global tickLower" + tickLower + (isToken0 ? -poolKey.tickSpacing : poolKey.tickSpacing), + lowerSlug.tickLower, + "sixth swap: lowerSlug.tickLower != global tickLower" ); - // } else { - // assertEq(tickLower, lowerSlug.tickLower, "sixth swap: lowerSlug.tickUpper != global tickLower"); - // } assertEq(lowerSlug.tickUpper, upperSlug.tickLower, "sixth swap: lowerSlug.tickUpper != upperSlug.tickLower"); // We don't set a priceDiscoverySlug because it's the last epoch diff --git a/test/invariant/DopplerHandler.sol b/test/invariant/DopplerHandler.sol index b574023c..1bf27057 100644 --- a/test/invariant/DopplerHandler.sol +++ b/test/invariant/DopplerHandler.sol @@ -5,18 +5,24 @@ import {Test} from "forge-std/Test.sol"; import {AddressSet, LibAddressSet} from "./AddressSet.sol"; import {DopplerImplementation} from "test/shared/DopplerImplementation.sol"; import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; +import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; import {TestERC20} from "v4-core/src/test/TestERC20.sol"; import {PoolKey} from "v4-core/src/types/PoolKey.sol"; import {Currency} from "v4-core/src/types/Currency.sol"; +import {CustomRouter} from "test/shared/CustomRouter.sol"; contract DopplerHandler is Test { using LibAddressSet for AddressSet; PoolKey public poolKey; DopplerImplementation public hook; - PoolSwapTest public swapRouter; + CustomRouter public router; TestERC20 public token0; TestERC20 public token1; + TestERC20 public numeraire; + TestERC20 public asset; + bool public isToken0; + bool public isUsingEth; uint256 public ghost_reserve0; uint256 public ghost_reserve1; @@ -48,17 +54,43 @@ contract DopplerHandler is Test { _; } - constructor(PoolKey memory poolKey_, DopplerImplementation hook_, PoolSwapTest swapRouter_) { + constructor( + PoolKey memory poolKey_, + DopplerImplementation hook_, + CustomRouter router_, + bool isToken0_, + bool isUsingEth_ + ) { poolKey = poolKey_; hook = hook_; - swapRouter = swapRouter_; + router = router_; + isToken0 = isToken0_; + isUsingEth = isUsingEth_; token0 = TestERC20(Currency.unwrap(poolKey.currency0)); token1 = TestERC20(Currency.unwrap(poolKey.currency1)); + if (isToken0) { + numeraire = token0; + asset = token1; + } else { + numeraire = token1; + asset = token0; + } + ghost_reserve0 = token0.balanceOf(address(hook)); ghost_reserve1 = token1.balanceOf(address(hook)); } - function buyExactAmount(uint256 amount) public createActor countCall(this.buyExactAmount.selector) {} + /// @notice Buys an amount of asset tokens using an exact amount of numeraire tokens + function buyExactAmountIn(uint256 amount) public createActor countCall(this.buyExactAmountIn.selector) { + if (isUsingEth) { + deal(currentActor, amount); + } else { + numeraire.mint(currentActor, amount); + numeraire.approve(address(router), amount); + } + + uint256 bought = router.buyExactIn{value: isUsingEth ? amount : 0}(amount); + } } diff --git a/test/invariant/DopplerInvariants.sol b/test/invariant/DopplerInvariants.sol index ac6f127b..a395023e 100644 --- a/test/invariant/DopplerInvariants.sol +++ b/test/invariant/DopplerInvariants.sol @@ -1,16 +1,32 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; +import {console} from "forge-std/console.sol"; import {BaseTest} from "test/shared/BaseTest.sol"; +import {DopplerHandler} from "test/invariant/DopplerHandler.sol"; contract DopplerInvariantsTest is BaseTest { - function setUp() public override { - super.setUp(); - } + // DopplerHandler public handler; - function afterInvariant() public view {} + // function setUp() public override { + // super.setUp(); + // handler = new DopplerHandler(key, hook, router, isToken0, usingEth); - function invariant_works() public pure { - assertTrue(true); - } + // bytes4[] memory selectors = new bytes4[](1); + // selectors[0] = handler.buyExactAmountIn.selector; + + // targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); + // targetContract(address(handler)); + // } + + // function afterInvariant() public view { + // console.log("Handler address", address(handler)); + // console.log("Calls: ", handler.totalCalls()); + // console.log("buyExactAmountIn: ", handler.calls(handler.buyExactAmountIn.selector)); + // } + + // /// forge-config: default.invariant.fail-on-revert = true + // function invariant_works() public { + // assertTrue(true); + // } } diff --git a/test/shared/BaseTest.sol b/test/shared/BaseTest.sol index c3464984..4da29862 100644 --- a/test/shared/BaseTest.sol +++ b/test/shared/BaseTest.sol @@ -4,22 +4,33 @@ import {Test} from "forge-std/Test.sol"; import {Vm} from "forge-std/Vm.sol"; import {Deployers} from "v4-core/test/utils/Deployers.sol"; +import {MAX_SWAP_FEE} from "src/Doppler.sol"; import {TestERC20} from "v4-core/src/test/TestERC20.sol"; import {PoolId, PoolIdLibrary} from "v4-periphery/lib/v4-core/src/types/PoolId.sol"; +import {StateLibrary} from "v4-periphery/lib/v4-core/src/libraries/StateLibrary.sol"; import {PoolKey} from "v4-periphery/lib/v4-core/src/types/PoolKey.sol"; -import {PoolManager} from "v4-core/src/PoolManager.sol"; +import {PoolManager, IPoolManager} from "v4-core/src/PoolManager.sol"; import {Hooks} from "v4-core/src/libraries/Hooks.sol"; import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; import {Currency} from "v4-periphery/lib/v4-core/src/types/Currency.sol"; import {TickMath} from "v4-core/src/libraries/TickMath.sol"; import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol"; +import {Quoter, IQuoter} from "v4-periphery/src/lens/Quoter.sol"; +import {BalanceDelta, BalanceDeltaLibrary} from "v4-core/src/types/BalanceDelta.sol"; +import {CustomRouter} from "test/shared/CustomRouter.sol"; +import {ProtocolFeeLibrary} from "v4-periphery/lib/v4-core/src/libraries/ProtocolFeeLibrary.sol"; +import {FullMath} from "v4-periphery/lib/v4-core/src/libraries/FullMath.sol"; import {DopplerImplementation} from "./DopplerImplementation.sol"; +import "forge-std/console.sol"; using PoolIdLibrary for PoolKey; +using StateLibrary for IPoolManager; contract BaseTest is Test, Deployers { + using ProtocolFeeLibrary for *; + // TODO: Maybe add the start and end ticks to the config? struct DopplerConfig { uint256 numTokensToSell; @@ -73,21 +84,24 @@ contract BaseTest is Test, Deployers { // Context DopplerImplementation hook = DopplerImplementation( - address( - uint160( - Hooks.BEFORE_ADD_LIQUIDITY_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG - | Hooks.AFTER_INITIALIZE_FLAG - ) ^ (0x4444 << 144) + payable( + address( + uint160( + Hooks.BEFORE_ADD_LIQUIDITY_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG + | Hooks.AFTER_INITIALIZE_FLAG + ) ^ (0x4444 << 144) + ) ) ); - TestERC20 asset; - TestERC20 numeraire; - TestERC20 token0; - TestERC20 token1; + address asset; + address numeraire; + address token0; + address token1; PoolId poolId; bool isToken0; + bool usingEth; int24 startTick; int24 endTick; @@ -96,6 +110,11 @@ contract BaseTest is Test, Deployers { address alice = address(0xa71c3); address bob = address(0xb0b); + // Contracts + + Quoter quoter; + CustomRouter router; + // Deploy functions /// @dev Deploys a new pair of asset and numeraire tokens and the related Doppler hook @@ -107,7 +126,7 @@ contract BaseTest is Test, Deployers { /// @dev Reuses an existing pair of asset and numeraire tokens and deploys the related /// Doppler hook with the default configuration. - function _deploy(TestERC20 asset_, TestERC20 numeraire_) public { + function _deploy(address asset_, address numeraire_) public { asset = asset_; numeraire = numeraire_; _deployDoppler(); @@ -122,7 +141,7 @@ contract BaseTest is Test, Deployers { /// @dev Reuses an existing pair of asset and numeraire tokens and deploys the related Doppler /// hook with a given configuration. - function _deploy(TestERC20 asset_, TestERC20 numeraire_, DopplerConfig memory config) public { + function _deploy(address asset_, address numeraire_, DopplerConfig memory config) public { asset = asset_; numeraire = numeraire_; _deployDoppler(config); @@ -131,10 +150,25 @@ contract BaseTest is Test, Deployers { /// @dev Deploys a new pair of asset and numeraire tokens. function _deployTokens() public { isToken0 = vm.envOr("IS_TOKEN_0", true); - deployCodeTo("TestERC20.sol:TestERC20", abi.encode(2 ** 128), isToken0 ? address(TOKEN_A) : address(TOKEN_B)); - deployCodeTo("TestERC20.sol:TestERC20", abi.encode(2 ** 128), isToken0 ? address(TOKEN_B) : address(TOKEN_A)); - asset = TestERC20(isToken0 ? address(TOKEN_A) : address(TOKEN_B)); - numeraire = TestERC20(isToken0 ? address(TOKEN_B) : address(TOKEN_A)); + usingEth = vm.envOr("USING_ETH", false); + + if (usingEth) { + isToken0 = false; + deployCodeTo("TestERC20.sol:TestERC20", abi.encode(2 ** 128), address(TOKEN_B)); + token0 = address(0); + token1 = address(TOKEN_B); + numeraire = token0; + asset = token1; + } else { + deployCodeTo( + "TestERC20.sol:TestERC20", abi.encode(2 ** 128), isToken0 ? address(TOKEN_A) : address(TOKEN_B) + ); + deployCodeTo( + "TestERC20.sol:TestERC20", abi.encode(2 ** 128), isToken0 ? address(TOKEN_B) : address(TOKEN_A) + ); + asset = isToken0 ? TOKEN_A : TOKEN_B; + numeraire = isToken0 ? TOKEN_B : TOKEN_A; + } } /// @dev Deploys a new Doppler hook with the default configuration. @@ -148,12 +182,15 @@ contract BaseTest is Test, Deployers { vm.label(address(token0), "Token0"); vm.label(address(token1), "Token1"); - (isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); + TestERC20(asset).transfer(address(hook), config.numTokensToSell); // isToken0 ? startTick > endTick : endTick > startTick // In both cases, price(startTick) > price(endTick) - startTick = isToken0 ? int24(vm.envOr("START_TICK", DEFAULT_START_TICK)) : int24(vm.envOr("START_TICK", -DEFAULT_START_TICK)); - endTick = isToken0 ? int24(vm.envOr("END_TICK", -DEFAULT_END_TICK)) : int24(vm.envOr("END_TICK", DEFAULT_END_TICK)); + startTick = isToken0 + ? int24(vm.envOr("START_TICK", DEFAULT_START_TICK)) + : int24(vm.envOr("START_TICK", -DEFAULT_START_TICK)); + endTick = + isToken0 ? int24(vm.envOr("END_TICK", -DEFAULT_END_TICK)) : int24(vm.envOr("END_TICK", DEFAULT_END_TICK)); // Default to feeless case because it's easier to reason about config.fee = uint24(vm.envOr("FEE", uint24(0))); @@ -213,10 +250,178 @@ contract BaseTest is Test, Deployers { // Note: Only used to validate that liquidity can't be manually modified modifyLiquidityRouter = new PoolModifyLiquidityTest(manager); - // Approve the router to spend tokens on behalf of the test contract - token0.approve(address(swapRouter), type(uint256).max); - token1.approve(address(swapRouter), type(uint256).max); - token0.approve(address(modifyLiquidityRouter), type(uint256).max); - token1.approve(address(modifyLiquidityRouter), type(uint256).max); + if (token0 != address(0)) { + // Approve the router to spend tokens on behalf of the test contract + TestERC20(token0).approve(address(swapRouter), type(uint256).max); + TestERC20(token0).approve(address(modifyLiquidityRouter), type(uint256).max); + } + TestERC20(token1).approve(address(swapRouter), type(uint256).max); + TestERC20(token1).approve(address(modifyLiquidityRouter), type(uint256).max); + + quoter = new Quoter(manager); + + router = new CustomRouter(swapRouter, quoter, key, isToken0, usingEth); + } + + function computeBuyExactOut(uint256 amountOut) public returns (uint256) { + (int128[] memory deltaAmounts,,) = quoter.quoteExactOutputSingle( + IQuoter.QuoteExactSingleParams({ + poolKey: key, + zeroForOne: !isToken0, + exactAmount: uint128(amountOut), + sqrtPriceLimitX96: !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT, + hookData: "" + }) + ); + + return uint256(uint128(deltaAmounts[0])); + } + + function computeSellExactOut(uint256 amountOut) public returns (uint256) { + (int128[] memory deltaAmounts,,) = quoter.quoteExactOutputSingle( + IQuoter.QuoteExactSingleParams({ + poolKey: key, + zeroForOne: isToken0, + exactAmount: uint128(amountOut), + sqrtPriceLimitX96: isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT, + hookData: "" + }) + ); + + return uint256(uint128(deltaAmounts[0])); + } + + function buyExactIn(uint256 amount) public { + buy(-int256(amount)); + } + + function buyExactOut(uint256 amount) public { + buy(int256(amount)); + } + + function sellExactIn(uint256 amount) public { + sell(-int256(amount)); + } + + function sellExactOut(uint256 amount) public { + sell(int256(amount)); + } + + /// @dev Buys a given amount of asset tokens. + /// @param amount A negative value specificies the amount of numeraire tokens to spend, + /// a positive value specifies the amount of asset tokens to buy. + /// @return Amount of asset tokens bought. + /// @return Amount of numeraire tokens used. + function buy(int256 amount) public returns (uint256, uint256) { + // Negative means exactIn, positive means exactOut. + uint256 mintAmount = amount < 0 ? uint256(-amount) : computeBuyExactOut(uint256(amount)); + + if (usingEth) { + deal(address(this), uint256(mintAmount)); + } else { + TestERC20(numeraire).mint(address(this), uint256(mintAmount)); + TestERC20(numeraire).approve(address(swapRouter), uint256(mintAmount)); + } + + BalanceDelta delta = swapRouter.swap{value: usingEth ? mintAmount : 0}( + key, + IPoolManager.SwapParams(!isToken0, amount, isToken0 ? MAX_PRICE_LIMIT : MIN_PRICE_LIMIT), + PoolSwapTest.TestSettings(false, false), + "" + ); + + uint256 delta0 = uint256(int256(delta.amount0() < 0 ? -delta.amount0() : delta.amount0())); + uint256 delta1 = uint256(int256(delta.amount1() < 0 ? -delta.amount1() : delta.amount1())); + + return isToken0 ? (delta0, delta1) : (delta1, delta0); + } + + /// @dev Sells a given amount of asset tokens. + /// @param amount A negative value specificies the amount of asset tokens to sell, a positive value + /// specifies the amount of numeraire tokens to receive. + /// @return Amount of asset tokens sold. + /// @return Amount of numeraire tokens received. + function sell(int256 amount) public returns (uint256, uint256) { + // Negative means exactIn, positive means exactOut. + uint256 approveAmount = amount < 0 ? uint256(-amount) : computeSellExactOut(uint256(amount)); + TestERC20(asset).approve(address(swapRouter), uint256(approveAmount)); + + BalanceDelta delta = swapRouter.swap( + key, + IPoolManager.SwapParams(isToken0, amount, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(false, false), + "" + ); + + uint256 delta0 = uint256(int256(delta.amount0() < 0 ? -delta.amount0() : delta.amount0())); + uint256 delta1 = uint256(int256(delta.amount1() < 0 ? -delta.amount1() : delta.amount1())); + + return isToken0 ? (delta0, delta1) : (delta1, delta0); + } + + function sellExpectRevert(int256 amount, bytes4 selector) public { + // Negative means exactIn, positive means exactOut. + if (amount > 0) { + revert UnexpectedPositiveAmount(); + } + uint256 approveAmount = uint256(-amount); + TestERC20(asset).approve(address(swapRouter), approveAmount); + vm.expectRevert(abi.encodeWithSelector( + Hooks.Wrap__FailedHookCall.selector, hook, abi.encodeWithSelector(selector) + ) + ); + swapRouter.swap( + key, + IPoolManager.SwapParams(isToken0, amount, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); + + } + + function buyExpectRevert(int256 amount, bytes4 selector) public { + // Negative means exactIn, positive means exactOut. + if (amount > 0) { + revert UnexpectedPositiveAmount(); + } + uint256 mintAmount = uint256(-amount); + + if (usingEth) { + deal(address(this), uint256(mintAmount)); + } else { + TestERC20(numeraire).mint(address(this), uint256(mintAmount)); + TestERC20(numeraire).approve(address(swapRouter), uint256(mintAmount)); + } + + vm.expectRevert(abi.encodeWithSelector( + Hooks.Wrap__FailedHookCall.selector, hook, abi.encodeWithSelector(selector) + ) + ); + swapRouter.swap{value: usingEth ? mintAmount : 0}( + key, + IPoolManager.SwapParams(!isToken0, amount, isToken0 ? MAX_PRICE_LIMIT : MIN_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); + } + + function computeFees(uint256 amount0, uint256 amount1) public view returns (uint256, uint256) { + (,, uint24 protocolFee, uint24 lpFee) = manager.getSlot0(key.toId()); + + uint256 amount0ExpectedFee; + uint256 amount1ExpectedFee; + if (protocolFee > 0) { + uint24 amount0SwapFee = protocolFee.getZeroForOneFee().calculateSwapFee(lpFee); + uint24 amount1SwapFee = protocolFee.getOneForZeroFee().calculateSwapFee(lpFee); + amount0ExpectedFee = FullMath.mulDiv(amount0, amount0SwapFee, MAX_SWAP_FEE); + amount1ExpectedFee = FullMath.mulDiv(amount1, amount1SwapFee, MAX_SWAP_FEE); + } else { + amount0ExpectedFee = FullMath.mulDiv(amount0, lpFee, MAX_SWAP_FEE); + amount1ExpectedFee = FullMath.mulDiv(amount1, lpFee, MAX_SWAP_FEE); + } + + return (amount0ExpectedFee, amount1ExpectedFee); } } + +error UnexpectedPositiveAmount(); diff --git a/test/shared/CustomRouter.sol b/test/shared/CustomRouter.sol new file mode 100644 index 00000000..b2167bb9 --- /dev/null +++ b/test/shared/CustomRouter.sol @@ -0,0 +1,191 @@ +/// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {TestERC20} from "v4-core/src/test/TestERC20.sol"; +import {PoolKey} from "v4-periphery/lib/v4-core/src/types/PoolKey.sol"; +import {IPoolManager} from "v4-core/src/PoolManager.sol"; +import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; +import {Quoter, IQuoter} from "v4-periphery/src/lens/Quoter.sol"; +import {BalanceDelta, BalanceDeltaLibrary} from "v4-core/src/types/BalanceDelta.sol"; +import {TickMath} from "v4-core/src/libraries/TickMath.sol"; +import {Currency} from "v4-core/src/types/Currency.sol"; + +uint160 constant MIN_PRICE_LIMIT = TickMath.MIN_SQRT_PRICE + 1; +uint160 constant MAX_PRICE_LIMIT = TickMath.MAX_SQRT_PRICE - 1; + +/// @notice Just a custom router contract for testing purposes, I wanted to have +/// a way to reuse the same functions in the BaseTest contract and the DopplerHandler. +contract CustomRouter is Test { + using BalanceDeltaLibrary for BalanceDelta; + + PoolSwapTest public swapRouter; + Quoter public quoter; + PoolKey public key; + bool public isToken0; + bool public isUsingEth; + address public numeraire; + address public asset; + + constructor(PoolSwapTest swapRouter_, Quoter quoter_, PoolKey memory key_, bool isToken0_, bool isUsingEth_) { + swapRouter = swapRouter_; + quoter = quoter_; + key = key_; + isToken0 = isToken0_; + isUsingEth = isUsingEth_; + + asset = isToken0 ? Currency.unwrap(key.currency0) : Currency.unwrap(key.currency1); + numeraire = isToken0 ? Currency.unwrap(key.currency1) : Currency.unwrap(key.currency0); + } + + function computeBuyExactOut(uint256 amountOut) public returns (uint256) { + (int128[] memory deltaAmounts,,) = quoter.quoteExactOutputSingle( + IQuoter.QuoteExactSingleParams({ + poolKey: key, + zeroForOne: !isToken0, + exactAmount: uint128(amountOut), + sqrtPriceLimitX96: !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT, + hookData: "" + }) + ); + + return uint256(uint128(deltaAmounts[0])); + } + + function computeSellExactOut(uint256 amountOut) public returns (uint256) { + (int128[] memory deltaAmounts,,) = quoter.quoteExactOutputSingle( + IQuoter.QuoteExactSingleParams({ + poolKey: key, + zeroForOne: isToken0, + exactAmount: uint128(amountOut), + sqrtPriceLimitX96: isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT, + hookData: "" + }) + ); + + return uint256(uint128(deltaAmounts[0])); + } + + /// @notice Buys asset tokens using an exact amount of numeraire tokens. + /// @return bought Amount of asset tokens bought. + function buyExactIn(uint256 amount) public payable returns (uint256 bought) { + (bought,) = buy(-int256(amount)); + } + + /// @notice Buys an exact amount of asset tokens using numeraire tokens. + function buyExactOut(uint256 amount) public payable returns (uint256 spent) { + (, spent) = buy(int256(amount)); + } + + /// @notice Sells an exact amount of asset tokens for numeraire tokens. + /// @return received Amount of numeraire tokens received. + function sellExactIn(uint256 amount) public returns (uint256 received) { + (, received) = sell(-int256(amount)); + } + + /// @notice Sells asset tokens for an exact amount of numeraire tokens. + /// @return sold Amount of asset tokens sold. + function sellExactOut(uint256 amount) public returns (uint256 sold) { + (sold,) = sell(int256(amount)); + } + + /// @dev Buys a given amount of asset tokens. + /// @param amount A negative value specificies the amount of numeraire tokens to spend, + /// a positive value specifies the amount of asset tokens to buy. + /// @return Amount of asset tokens bought. + /// @return Amount of numeraire tokens used. + function mintAndBuy(int256 amount) public returns (uint256, uint256) { + // Negative means exactIn, positive means exactOut. + uint256 mintAmount = amount < 0 ? uint256(-amount) : computeBuyExactOut(uint256(amount)); + + // TODO: Not sure if minting should be done in here, it might be better to mint in the tests. + if (isUsingEth) { + deal(address(this), uint256(mintAmount)); + } else { + TestERC20(numeraire).mint(address(this), uint256(mintAmount)); + TestERC20(numeraire).approve(address(swapRouter), uint256(mintAmount)); + } + + BalanceDelta delta = swapRouter.swap{value: isUsingEth ? mintAmount : 0}( + key, + IPoolManager.SwapParams(!isToken0, amount, isToken0 ? MAX_PRICE_LIMIT : MIN_PRICE_LIMIT), + PoolSwapTest.TestSettings(false, false), + "" + ); + + uint256 delta0 = uint256(int256(delta.amount0() < 0 ? -delta.amount0() : delta.amount0())); + uint256 delta1 = uint256(int256(delta.amount1() < 0 ? -delta.amount1() : delta.amount1())); + + uint256 bought = isToken0 ? delta0 : delta1; + uint256 spent = isToken0 ? delta1 : delta0; + + TestERC20(asset).transfer(msg.sender, bought); + + return (bought, spent); + } + + /// @dev Buys a given amount of asset tokens. + /// @param amount A negative value specificies the amount of numeraire tokens to spend, + /// a positive value specifies the amount of asset tokens to buy. + /// @return Amount of asset tokens bought. + /// @return Amount of numeraire tokens used. + function buy(int256 amount) public payable returns (uint256, uint256) { + // Negative means exactIn, positive means exactOut. + uint256 transferAmount = amount < 0 ? uint256(-amount) : computeBuyExactOut(uint256(amount)); + + if (isUsingEth) { + require(msg.value == transferAmount, "Incorrect amount of ETH sent"); + } else { + TestERC20(numeraire).transferFrom(msg.sender, address(this), transferAmount); + TestERC20(numeraire).approve(address(swapRouter), transferAmount); + } + + BalanceDelta delta = swapRouter.swap{value: isUsingEth ? transferAmount : 0}( + key, + IPoolManager.SwapParams(!isToken0, amount, isToken0 ? MAX_PRICE_LIMIT : MIN_PRICE_LIMIT), + PoolSwapTest.TestSettings(false, false), + "" + ); + + uint256 delta0 = uint256(int256(delta.amount0() < 0 ? -delta.amount0() : delta.amount0())); + uint256 delta1 = uint256(int256(delta.amount1() < 0 ? -delta.amount1() : delta.amount1())); + + uint256 bought = isToken0 ? delta0 : delta1; + uint256 spent = isToken0 ? delta1 : delta0; + + TestERC20(asset).transfer(msg.sender, bought); + + return (bought, spent); + } + + /// @dev Sells a given amount of asset tokens. + /// @param amount A negative value specificies the amount of asset tokens to sell, a positive value + /// specifies the amount of numeraire tokens to receive. + /// @return Amount of asset tokens sold. + /// @return Amount of numeraire tokens received. + function sell(int256 amount) public returns (uint256, uint256) { + uint256 approveAmount = amount < 0 ? uint256(-amount) : computeSellExactOut(uint256(amount)); + TestERC20(asset).approve(address(swapRouter), uint256(approveAmount)); + + BalanceDelta delta = swapRouter.swap( + key, + IPoolManager.SwapParams(isToken0, amount, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(false, false), + "" + ); + + uint256 delta0 = uint256(int256(delta.amount0() < 0 ? -delta.amount0() : delta.amount0())); + uint256 delta1 = uint256(int256(delta.amount1() < 0 ? -delta.amount1() : delta.amount1())); + + uint256 sold = isToken0 ? delta0 : delta1; + uint256 received = isToken0 ? delta1 : delta0; + + if (isUsingEth) { + payable(address(msg.sender)).transfer(received); + } else { + TestERC20(numeraire).transfer(msg.sender, received); + } + + return (sold, received); + } +} diff --git a/test/unit/Constructor.t.sol b/test/unit/Constructor.t.sol index 7dc53f70..6bd8d441 100644 --- a/test/unit/Constructor.t.sol +++ b/test/unit/Constructor.t.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.26; import {BaseTest} from "test/shared/BaseTest.sol"; import {DopplerImplementation} from "test/shared/DopplerImplementation.sol"; +import {TestERC20} from "test/shared/BaseTest.sol"; import { MAX_TICK_SPACING, MAX_PRICE_DISCOVERY_SLUGS, @@ -40,7 +41,7 @@ contract ConstructorTest is BaseTest { isToken0 = _isToken0; (token0, token1) = isToken0 ? (asset, numeraire) : (numeraire, asset); - (isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); + TestERC20(isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); vm.label(address(token0), "Token0"); vm.label(address(token1), "Token1"); @@ -202,10 +203,9 @@ contract ConstructorTest is BaseTest { } function testConstructor_Succeeds_WithValidParameters() public { - bool _isToken0 = true; - DopplerConfig memory config = DEFAULT_DOPPLER_CONFIG; + bool _isToken0 = true; - deployDoppler(0, config, 0, 0, _isToken0); + deployDoppler(0, config, 0, 0, asset < numeraire); } } diff --git a/test/unit/EarlyExit.t.sol b/test/unit/EarlyExit.t.sol index 773867da..c21c5a32 100644 --- a/test/unit/EarlyExit.t.sol +++ b/test/unit/EarlyExit.t.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.26; import {Test} from "forge-std/Test.sol"; import {BaseTest} from "test/shared/BaseTest.sol"; +import {TestERC20} from "v4-core/src/test/TestERC20.sol"; import {DopplerImplementation} from "test/shared/DopplerImplementation.sol"; import {IPoolManager} from "v4-periphery/lib/v4-core/src/interfaces/IPoolManager.sol"; import {PoolId, PoolIdLibrary} from "v4-periphery/lib/v4-core/src/types/PoolId.sol"; @@ -15,6 +16,8 @@ import {PoolManager} from "v4-core/src/PoolManager.sol"; import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; import {MaximumProceedsReached} from "src/Doppler.sol"; import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol"; +import {Quoter, IQuoter} from "v4-periphery/src/lens/Quoter.sol"; +import {CustomRouter} from "test/shared/CustomRouter.sol"; import "forge-std/console.sol"; using PoolIdLibrary for PoolKey; @@ -27,7 +30,7 @@ contract EarlyExitTest is BaseTest { function deployDoppler(DopplerConfig memory config) internal { (token0, token1) = isToken0 ? (asset, numeraire) : (numeraire, asset); - (isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); + TestERC20(isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); vm.label(address(token0), "Token0"); vm.label(address(token1), "Token1"); @@ -70,11 +73,17 @@ contract EarlyExitTest is BaseTest { // Note: Only used to validate that liquidity can't be manually modified modifyLiquidityRouter = new PoolModifyLiquidityTest(manager); - // Approve the router to spend tokens on behalf of the test contract - token0.approve(address(swapRouter), type(uint256).max); - token1.approve(address(swapRouter), type(uint256).max); - token0.approve(address(modifyLiquidityRouter), type(uint256).max); - token1.approve(address(modifyLiquidityRouter), type(uint256).max); + if (token0 != address(0)) { + // Approve the router to spend tokens on behalf of the test contract + TestERC20(token0).approve(address(swapRouter), type(uint256).max); + TestERC20(token0).approve(address(modifyLiquidityRouter), type(uint256).max); + } + TestERC20(token1).approve(address(swapRouter), type(uint256).max); + TestERC20(token1).approve(address(modifyLiquidityRouter), type(uint256).max); + + quoter = new Quoter(manager); + + router = new CustomRouter(swapRouter, quoter, key, isToken0, usingEth); } function test_swap_RevertsIfMaximumProceedsReached() public { @@ -87,25 +96,9 @@ contract EarlyExitTest is BaseTest { int256 maximumProceeds = int256(hook.getMaximumProceeds()); - swapRouter.swap( - key, - IPoolManager.SwapParams(!isToken0, -maximumProceeds, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(-maximumProceeds); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch - - vm.expectRevert( - abi.encodeWithSelector( - Hooks.Wrap__FailedHookCall.selector, hook, abi.encodeWithSelector(MaximumProceedsReached.selector) - ) - ); - swapRouter.swap( - key, - IPoolManager.SwapParams(isToken0, -1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sellExpectRevert(-1 ether, MaximumProceedsReached.selector); } } diff --git a/test/unit/Receive.t.sol b/test/unit/Receive.t.sol new file mode 100644 index 00000000..9fd26deb --- /dev/null +++ b/test/unit/Receive.t.sol @@ -0,0 +1,21 @@ +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; + +import {IPoolManager} from "v4-periphery/lib/v4-core/src/interfaces/IPoolManager.sol"; +import {Hooks} from "v4-core/src/libraries/Hooks.sol"; +import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; +import {Hooks} from "v4-core/src/libraries/Hooks.sol"; +import {PoolId, PoolIdLibrary} from "v4-periphery/lib/v4-core/src/types/PoolId.sol"; +import {PoolKey} from "v4-periphery/lib/v4-core/src/types/PoolKey.sol"; +import {StateLibrary} from "v4-periphery/lib/v4-core/src/libraries/StateLibrary.sol"; + +import {InvalidTime, SwapBelowRange} from "src/Doppler.sol"; +import {BaseTest} from "test/shared/BaseTest.sol"; +import {Position} from "../../src/Doppler.sol"; + +contract ReceiveTest is BaseTest { + function test_receive() public { + payable(address(hook)).transfer(1 ether); + } +} diff --git a/test/unit/SlugVis.t.sol b/test/unit/SlugVis.t.sol index b17f8587..e48820de 100644 --- a/test/unit/SlugVis.t.sol +++ b/test/unit/SlugVis.t.sol @@ -19,16 +19,8 @@ contract SlugVisTest is BaseTest { vm.warp(hook.getStartingTime()); PoolKey memory poolKey = key; - bool isToken0 = hook.getIsToken0(); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); SlugVis.visualizeSlugs(hook, poolKey.toId(), "test", block.timestamp); } @@ -37,16 +29,8 @@ contract SlugVisTest is BaseTest { vm.warp(hook.getStartingTime()); PoolKey memory poolKey = key; - bool isToken0 = hook.getIsToken0(); - - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - poolKey, - IPoolManager.SwapParams(!isToken0, 1, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + + buy(1); SlugVis.visualizeSlugs(hook, poolKey.toId(), "test", block.timestamp); } diff --git a/test/unit/Swap.sol b/test/unit/Swap.sol index 7fed29fd..30ab00d2 100644 --- a/test/unit/Swap.sol +++ b/test/unit/Swap.sol @@ -1,15 +1,14 @@ pragma solidity 0.8.26; -import {Test} from "forge-std/Test.sol"; - import {MAX_SWAP_FEE} from "src/Doppler.sol"; import {IPoolManager} from "v4-periphery/lib/v4-core/src/interfaces/IPoolManager.sol"; import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; import {Hooks} from "v4-core/src/libraries/Hooks.sol"; -import {PoolId, PoolIdLibrary} from "v4-periphery/lib/v4-core/src/types/PoolId.sol"; -import {PoolKey} from "v4-periphery/lib/v4-core/src/types/PoolKey.sol"; import {ProtocolFeeLibrary} from "v4-periphery/lib/v4-core/src/libraries/ProtocolFeeLibrary.sol"; import {StateLibrary} from "v4-periphery/lib/v4-core/src/libraries/StateLibrary.sol"; +import { + BalanceDelta, add, BalanceDeltaLibrary, toBalanceDelta +} from "v4-periphery/lib/v4-core/src/types/BalanceDelta.sol"; import {FullMath} from "v4-periphery/lib/v4-core/src/libraries/FullMath.sol"; import { InvalidTime, @@ -18,29 +17,17 @@ import { InvalidSwapAfterMaturitySufficientProceeds } from "src/Doppler.sol"; import {BaseTest} from "test/shared/BaseTest.sol"; -import {Position} from "../../src/Doppler.sol"; contract SwapTest is BaseTest { - using PoolIdLibrary for PoolKey; using StateLibrary for IPoolManager; using ProtocolFeeLibrary for *; - + // NOTE: when testing conditions where we expect a revert using buy/sellExpectRevert, + // we need to pass in a negative amount to specify an exactIn swap. + // otherwise, the quoter will attempt to calculate an exactOut amount, which will fail. function test_swap_RevertsBeforeStartTime() public { vm.warp(hook.getStartingTime() - 1); // 1 second before the start time - - vm.expectRevert( - abi.encodeWithSelector( - Hooks.Wrap__FailedHookCall.selector, hook, abi.encodeWithSelector(InvalidTime.selector) - ) - ); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + + buyExpectRevert(-1 ether, InvalidTime.selector); } function test_swap_RevertsAfterEndTimeInsufficientProceedsAssetBuy() public { @@ -48,31 +35,11 @@ contract SwapTest is BaseTest { int256 minimumProceeds = int256(hook.getMinimumProceeds()); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, -minimumProceeds / 2, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(-minimumProceeds / 2); + vm.warp(hook.getEndingTime() + 1); // 1 second after the end time - vm.expectRevert( - abi.encodeWithSelector( - Hooks.Wrap__FailedHookCall.selector, - hook, - abi.encodeWithSelector(InvalidSwapAfterMaturityInsufficientProceeds.selector) - ) - ); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buyExpectRevert(-1 ether, InvalidSwapAfterMaturityInsufficientProceeds.selector); } function test_swap_CanRepurchaseNumeraireAfterEndTimeInsufficientProceeds() public { @@ -80,14 +47,7 @@ contract SwapTest is BaseTest { int256 minimumProceeds = int256(hook.getMinimumProceeds()); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, -minimumProceeds / 2, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(-minimumProceeds / 2); vm.warp(hook.getEndingTime() + 1); // 1 second after the end time @@ -96,20 +56,16 @@ contract SwapTest is BaseTest { assertGt(totalTokensSold, 0); // assert that we can sell back all tokens - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(isToken0, -int256(totalTokensSold), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sell(-int256(totalTokensSold)); (,, uint256 totalTokensSold2, uint256 totalProceeds2,,) = hook.state(); // assert that we get the totalProceeds near 0 - assertApproxEqAbs(totalProceeds2, 0, 1e18); - assertEq(totalTokensSold2, 0); + (uint256 amount0ExpectedFee, uint256 amount1ExpectedFee) = isToken0 + ? computeFees(uint256(totalTokensSold), uint256(minimumProceeds / 2)) + : computeFees(uint256(minimumProceeds / 2), uint256(totalTokensSold)); + assertGe(totalProceeds2, isToken0 ? amount1ExpectedFee : amount0ExpectedFee); + assertApproxEqAbs(totalTokensSold2, isToken0 ? amount0ExpectedFee : amount1ExpectedFee, 1); } function test_swap_RevertsAfterEndTimeSufficientProceeds() public { @@ -117,59 +73,22 @@ contract SwapTest is BaseTest { int256 minimumProceeds = int256(hook.getMinimumProceeds()); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams( - !isToken0, -minimumProceeds * 11 / 10, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT - ), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(-minimumProceeds * 11 / 10); vm.warp(hook.getEndingTime() + 1); // 1 second after the end time - vm.expectRevert( - abi.encodeWithSelector( - Hooks.Wrap__FailedHookCall.selector, - hook, - abi.encodeWithSelector(InvalidSwapAfterMaturitySufficientProceeds.selector) - ) - ); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buyExpectRevert(-1 ether, InvalidSwapAfterMaturitySufficientProceeds.selector); } function test_swap_DoesNotRebalanceTwiceInSameEpoch() public { vm.warp(hook.getStartingTime()); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (uint40 lastEpoch, int256 tickAccumulator, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (uint40 lastEpoch2, int256 tickAccumulator2, uint256 totalTokensSold2,, uint256 totalTokensSoldLastEpoch2,) = hook.state(); @@ -186,14 +105,7 @@ contract SwapTest is BaseTest { function test_swap_UpdatesLastEpoch() public { vm.warp(hook.getStartingTime()); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (uint40 lastEpoch,,,,,) = hook.state(); @@ -201,14 +113,7 @@ contract SwapTest is BaseTest { vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (lastEpoch,,,,,) = hook.state(); @@ -218,25 +123,11 @@ contract SwapTest is BaseTest { function test_swap_UpdatesTotalTokensSoldLastEpoch() public { vm.warp(hook.getStartingTime()); - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); (,, uint256 totalTokensSold,, uint256 totalTokensSoldLastEpoch,) = hook.state(); @@ -253,12 +144,7 @@ contract SwapTest is BaseTest { uint256 amountInLessFee = FullMath.mulDiv(uint256(amountIn), MAX_SWAP_FEE - swapFee, MAX_SWAP_FEE); - swapRouter.swap( - key, - IPoolManager.SwapParams(!isToken0, -amountIn, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(-amountIn); (,, uint256 totalTokensSold, uint256 totalProceeds,,) = hook.state(); @@ -266,12 +152,7 @@ contract SwapTest is BaseTest { amountInLessFee = FullMath.mulDiv(uint256(totalTokensSold), MAX_SWAP_FEE - swapFee, MAX_SWAP_FEE); - swapRouter.swap( - key, - IPoolManager.SwapParams(isToken0, -int256(totalTokensSold), isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sell(-int256(totalTokensSold)); (,, uint256 totalTokensSold2,,,) = hook.state(); @@ -281,61 +162,20 @@ contract SwapTest is BaseTest { function test_swap_CannotSwapBelowLowerSlug_AfterInitialization() public { vm.warp(hook.getStartingTime()); - vm.expectRevert( - abi.encodeWithSelector( - Hooks.Wrap__FailedHookCall.selector, hook, abi.encodeWithSelector(SwapBelowRange.selector) - ) - ); - // Attempt 0 amount swap below lower slug - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(isToken0, 1, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sellExpectRevert(-1 ether, SwapBelowRange.selector); } function test_swap_CannotSwapBelowLowerSlug_AfterSoldAndUnsold() public { vm.warp(hook.getStartingTime()); - // Sell some tokens - swapRouter.swap( - // Swap numeraire to asset - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(!isToken0, 1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + buy(1 ether); vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch // Swap to trigger lower slug being created // Unsell half of sold tokens - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(isToken0, -0.5 ether, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); - - vm.expectRevert( - abi.encodeWithSelector( - Hooks.Wrap__FailedHookCall.selector, hook, abi.encodeWithSelector(SwapBelowRange.selector) - ) - ); - // Unsell beyond remaining tokens, moving price below lower slug - swapRouter.swap( - // Swap asset to numeraire - // If zeroForOne, we use max price limit (else vice versa) - key, - IPoolManager.SwapParams(isToken0, -0.6 ether, isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), - PoolSwapTest.TestSettings(true, false), - "" - ); + sell(-0.5 ether); + + sellExpectRevert(-0.6 ether, SwapBelowRange.selector); } } From 8158fe56a087b097fe3e0d5d7261614bc0133db5 Mon Sep 17 00:00:00 2001 From: Matt Czernik Date: Wed, 23 Oct 2024 15:42:49 -0400 Subject: [PATCH 4/4] Remove bad InvalidGamma check (#153) --- src/Doppler.sol | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Doppler.sol b/src/Doppler.sol index c1c1cd8c..9f3f2ec0 100644 --- a/src/Doppler.sol +++ b/src/Doppler.sol @@ -101,11 +101,6 @@ contract Doppler is BaseHook { if (_startingTick != _endingTick) { if (_isToken0 && _startingTick <= _endingTick) revert InvalidTickRange(); if (!_isToken0 && _startingTick >= _endingTick) revert InvalidTickRange(); - - int24 totalTickDelta = _isToken0 ? _startingTick - _endingTick : _endingTick - _startingTick; - int256 totalEpochs = int256((_endingTime - _startingTime) / _epochLength); - // DA worst case is starting tick - ending tick - if (_gamma * totalEpochs != totalTickDelta) revert InvalidGamma(); } // Enforce maximum tick spacing if (_poolKey.tickSpacing > MAX_TICK_SPACING) revert InvalidTickSpacing();