diff --git a/.forge-snapshots/BinCustomCurveHookTest#test_Swap_CustomCurve.snap b/.forge-snapshots/BinCustomCurveHookTest#test_Swap_CustomCurve.snap new file mode 100644 index 00000000..e5dd6aa5 --- /dev/null +++ b/.forge-snapshots/BinCustomCurveHookTest#test_Swap_CustomCurve.snap @@ -0,0 +1 @@ +142390 \ No newline at end of file diff --git a/.forge-snapshots/BinHookTest#testBurnSucceedsWithHook.snap b/.forge-snapshots/BinHookTest#testBurnSucceedsWithHook.snap index 2b568eb5..436357a4 100644 --- a/.forge-snapshots/BinHookTest#testBurnSucceedsWithHook.snap +++ b/.forge-snapshots/BinHookTest#testBurnSucceedsWithHook.snap @@ -1 +1 @@ -178172 \ No newline at end of file +178130 \ No newline at end of file diff --git a/.forge-snapshots/BinHookTest#testMintSucceedsWithHook.snap b/.forge-snapshots/BinHookTest#testMintSucceedsWithHook.snap index c3de81a3..607b6b5d 100644 --- a/.forge-snapshots/BinHookTest#testMintSucceedsWithHook.snap +++ b/.forge-snapshots/BinHookTest#testMintSucceedsWithHook.snap @@ -1 +1 @@ -311226 \ No newline at end of file +311254 \ No newline at end of file diff --git a/.forge-snapshots/BinHookTest#testSwapSucceedsWithHook.snap b/.forge-snapshots/BinHookTest#testSwapSucceedsWithHook.snap index f263fba9..09e6636d 100644 --- a/.forge-snapshots/BinHookTest#testSwapSucceedsWithHook.snap +++ b/.forge-snapshots/BinHookTest#testSwapSucceedsWithHook.snap @@ -1 +1 @@ -189430 \ No newline at end of file +189473 \ No newline at end of file diff --git a/.forge-snapshots/BinMintBurnFeeHookTest#test_Burn.snap b/.forge-snapshots/BinMintBurnFeeHookTest#test_Burn.snap new file mode 100644 index 00000000..e9417b02 --- /dev/null +++ b/.forge-snapshots/BinMintBurnFeeHookTest#test_Burn.snap @@ -0,0 +1 @@ +170035 \ No newline at end of file diff --git a/.forge-snapshots/BinMintBurnFeeHookTest#test_Mint.snap b/.forge-snapshots/BinMintBurnFeeHookTest#test_Mint.snap new file mode 100644 index 00000000..4024949d --- /dev/null +++ b/.forge-snapshots/BinMintBurnFeeHookTest#test_Mint.snap @@ -0,0 +1 @@ +410226 \ No newline at end of file diff --git a/.forge-snapshots/BinPoolManagerBytecodeSize.snap b/.forge-snapshots/BinPoolManagerBytecodeSize.snap index 0bbd03da..0c82ed5b 100644 --- a/.forge-snapshots/BinPoolManagerBytecodeSize.snap +++ b/.forge-snapshots/BinPoolManagerBytecodeSize.snap @@ -1 +1 @@ -24175 \ No newline at end of file +23296 \ No newline at end of file diff --git a/.forge-snapshots/BinPoolManagerTest#testBurnNativeCurrency.snap b/.forge-snapshots/BinPoolManagerTest#testBurnNativeCurrency.snap index d39cabe9..f1097bf3 100644 --- a/.forge-snapshots/BinPoolManagerTest#testBurnNativeCurrency.snap +++ b/.forge-snapshots/BinPoolManagerTest#testBurnNativeCurrency.snap @@ -1 +1 @@ -133811 \ No newline at end of file +133857 \ No newline at end of file diff --git a/.forge-snapshots/BinPoolManagerTest#testGasBurnHalfBin.snap b/.forge-snapshots/BinPoolManagerTest#testGasBurnHalfBin.snap index 1afcdd5b..88e501b8 100644 --- a/.forge-snapshots/BinPoolManagerTest#testGasBurnHalfBin.snap +++ b/.forge-snapshots/BinPoolManagerTest#testGasBurnHalfBin.snap @@ -1 +1 @@ -142616 \ No newline at end of file +142673 \ No newline at end of file diff --git a/.forge-snapshots/BinPoolManagerTest#testGasBurnNineBins.snap b/.forge-snapshots/BinPoolManagerTest#testGasBurnNineBins.snap index caeaa75a..90d0f303 100644 --- a/.forge-snapshots/BinPoolManagerTest#testGasBurnNineBins.snap +++ b/.forge-snapshots/BinPoolManagerTest#testGasBurnNineBins.snap @@ -1 +1 @@ -289602 \ No newline at end of file +289648 \ No newline at end of file diff --git a/.forge-snapshots/BinPoolManagerTest#testGasBurnOneBin.snap b/.forge-snapshots/BinPoolManagerTest#testGasBurnOneBin.snap index 25d6f235..26e22478 100644 --- a/.forge-snapshots/BinPoolManagerTest#testGasBurnOneBin.snap +++ b/.forge-snapshots/BinPoolManagerTest#testGasBurnOneBin.snap @@ -1 +1 @@ -126984 \ No newline at end of file +127029 \ No newline at end of file diff --git a/.forge-snapshots/BinPoolManagerTest#testGasDonate.snap b/.forge-snapshots/BinPoolManagerTest#testGasDonate.snap index c263e782..450ad33f 100644 --- a/.forge-snapshots/BinPoolManagerTest#testGasDonate.snap +++ b/.forge-snapshots/BinPoolManagerTest#testGasDonate.snap @@ -1 +1 @@ -118513 \ No newline at end of file +118628 \ No newline at end of file diff --git a/.forge-snapshots/BinPoolManagerTest#testGasMintNneBins-1.snap b/.forge-snapshots/BinPoolManagerTest#testGasMintNneBins-1.snap index 82fe3898..55e7efd3 100644 --- a/.forge-snapshots/BinPoolManagerTest#testGasMintNneBins-1.snap +++ b/.forge-snapshots/BinPoolManagerTest#testGasMintNneBins-1.snap @@ -1 +1 @@ -968244 \ No newline at end of file +968387 \ No newline at end of file diff --git a/.forge-snapshots/BinPoolManagerTest#testGasMintNneBins-2.snap b/.forge-snapshots/BinPoolManagerTest#testGasMintNneBins-2.snap index d9bb8fa3..4756905e 100644 --- a/.forge-snapshots/BinPoolManagerTest#testGasMintNneBins-2.snap +++ b/.forge-snapshots/BinPoolManagerTest#testGasMintNneBins-2.snap @@ -1 +1 @@ -327556 \ No newline at end of file +327699 \ No newline at end of file diff --git a/.forge-snapshots/BinPoolManagerTest#testGasMintOneBin-1.snap b/.forge-snapshots/BinPoolManagerTest#testGasMintOneBin-1.snap index 4b29f52e..81e816a3 100644 --- a/.forge-snapshots/BinPoolManagerTest#testGasMintOneBin-1.snap +++ b/.forge-snapshots/BinPoolManagerTest#testGasMintOneBin-1.snap @@ -1 +1 @@ -337280 \ No newline at end of file +337423 \ No newline at end of file diff --git a/.forge-snapshots/BinPoolManagerTest#testGasMintOneBin-2.snap b/.forge-snapshots/BinPoolManagerTest#testGasMintOneBin-2.snap index 78acc12b..634d7e6d 100644 --- a/.forge-snapshots/BinPoolManagerTest#testGasMintOneBin-2.snap +++ b/.forge-snapshots/BinPoolManagerTest#testGasMintOneBin-2.snap @@ -1 +1 @@ -139831 \ No newline at end of file +139974 \ No newline at end of file diff --git a/.forge-snapshots/BinPoolManagerTest#testGasSwapMultipleBins.snap b/.forge-snapshots/BinPoolManagerTest#testGasSwapMultipleBins.snap index 64d97ebe..eadeb12d 100644 --- a/.forge-snapshots/BinPoolManagerTest#testGasSwapMultipleBins.snap +++ b/.forge-snapshots/BinPoolManagerTest#testGasSwapMultipleBins.snap @@ -1 +1 @@ -172918 \ No newline at end of file +173054 \ No newline at end of file diff --git a/.forge-snapshots/BinPoolManagerTest#testGasSwapOverBigBinIdGate.snap b/.forge-snapshots/BinPoolManagerTest#testGasSwapOverBigBinIdGate.snap index c290063a..13ae85e5 100644 --- a/.forge-snapshots/BinPoolManagerTest#testGasSwapOverBigBinIdGate.snap +++ b/.forge-snapshots/BinPoolManagerTest#testGasSwapOverBigBinIdGate.snap @@ -1 +1 @@ -178946 \ No newline at end of file +179082 \ No newline at end of file diff --git a/.forge-snapshots/BinPoolManagerTest#testGasSwapSingleBin.snap b/.forge-snapshots/BinPoolManagerTest#testGasSwapSingleBin.snap index 94bbfdd8..c08ec2b0 100644 --- a/.forge-snapshots/BinPoolManagerTest#testGasSwapSingleBin.snap +++ b/.forge-snapshots/BinPoolManagerTest#testGasSwapSingleBin.snap @@ -1 +1 @@ -132949 \ No newline at end of file +133085 \ No newline at end of file diff --git a/.forge-snapshots/BinPoolManagerTest#testMintNativeCurrency.snap b/.forge-snapshots/BinPoolManagerTest#testMintNativeCurrency.snap index 2a930d48..42b4b145 100644 --- a/.forge-snapshots/BinPoolManagerTest#testMintNativeCurrency.snap +++ b/.forge-snapshots/BinPoolManagerTest#testMintNativeCurrency.snap @@ -1 +1 @@ -304363 \ No newline at end of file +304484 \ No newline at end of file diff --git a/.forge-snapshots/CLCustomCurveHookTest#test_Swap_CustomCurve.snap b/.forge-snapshots/CLCustomCurveHookTest#test_Swap_CustomCurve.snap new file mode 100644 index 00000000..5a5309c1 --- /dev/null +++ b/.forge-snapshots/CLCustomCurveHookTest#test_Swap_CustomCurve.snap @@ -0,0 +1 @@ +149600 \ No newline at end of file diff --git a/.forge-snapshots/CLMintBurnFeeHookTest#test_Burn.snap b/.forge-snapshots/CLMintBurnFeeHookTest#test_Burn.snap new file mode 100644 index 00000000..23a86b94 --- /dev/null +++ b/.forge-snapshots/CLMintBurnFeeHookTest#test_Burn.snap @@ -0,0 +1 @@ +170881 \ No newline at end of file diff --git a/.forge-snapshots/CLMintBurnFeeHookTest#test_Mint.snap b/.forge-snapshots/CLMintBurnFeeHookTest#test_Mint.snap new file mode 100644 index 00000000..fad1b213 --- /dev/null +++ b/.forge-snapshots/CLMintBurnFeeHookTest#test_Mint.snap @@ -0,0 +1 @@ +421255 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerBytecodeSize.snap b/.forge-snapshots/CLPoolManagerBytecodeSize.snap index 1931df6c..42949adf 100644 --- a/.forge-snapshots/CLPoolManagerBytecodeSize.snap +++ b/.forge-snapshots/CLPoolManagerBytecodeSize.snap @@ -1 +1 @@ -21103 \ No newline at end of file +20745 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerTest#addLiquidity_fromEmpty.snap b/.forge-snapshots/CLPoolManagerTest#addLiquidity_fromEmpty.snap index 40f0c110..72729b22 100644 --- a/.forge-snapshots/CLPoolManagerTest#addLiquidity_fromEmpty.snap +++ b/.forge-snapshots/CLPoolManagerTest#addLiquidity_fromEmpty.snap @@ -1 +1 @@ -347359 \ No newline at end of file +347480 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerTest#addLiquidity_fromNonEmpty.snap b/.forge-snapshots/CLPoolManagerTest#addLiquidity_fromNonEmpty.snap index 3966f9c9..1b70c139 100644 --- a/.forge-snapshots/CLPoolManagerTest#addLiquidity_fromNonEmpty.snap +++ b/.forge-snapshots/CLPoolManagerTest#addLiquidity_fromNonEmpty.snap @@ -1 +1 @@ -162820 \ No newline at end of file +162941 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerTest#addLiquidity_nativeToken.snap b/.forge-snapshots/CLPoolManagerTest#addLiquidity_nativeToken.snap index 96897662..ebd01875 100644 --- a/.forge-snapshots/CLPoolManagerTest#addLiquidity_nativeToken.snap +++ b/.forge-snapshots/CLPoolManagerTest#addLiquidity_nativeToken.snap @@ -1 +1 @@ -238365 \ No newline at end of file +238442 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerTest#donateBothTokens.snap b/.forge-snapshots/CLPoolManagerTest#donateBothTokens.snap index f46f6004..5dd90981 100644 --- a/.forge-snapshots/CLPoolManagerTest#donateBothTokens.snap +++ b/.forge-snapshots/CLPoolManagerTest#donateBothTokens.snap @@ -1 +1 @@ -163100 \ No newline at end of file +163218 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerTest#gasDonateOneToken.snap b/.forge-snapshots/CLPoolManagerTest#gasDonateOneToken.snap index 2927ac50..09466f40 100644 --- a/.forge-snapshots/CLPoolManagerTest#gasDonateOneToken.snap +++ b/.forge-snapshots/CLPoolManagerTest#gasDonateOneToken.snap @@ -1 +1 @@ -108172 \ No newline at end of file +108268 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerTest#initializeWithoutHooks.snap b/.forge-snapshots/CLPoolManagerTest#initializeWithoutHooks.snap index c0ab2e64..fe24249b 100644 --- a/.forge-snapshots/CLPoolManagerTest#initializeWithoutHooks.snap +++ b/.forge-snapshots/CLPoolManagerTest#initializeWithoutHooks.snap @@ -1 +1 @@ -149202 \ No newline at end of file +149205 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerTest#removeLiquidity_toNonEmpty.snap b/.forge-snapshots/CLPoolManagerTest#removeLiquidity_toNonEmpty.snap index 08abc4d2..168aaca1 100644 --- a/.forge-snapshots/CLPoolManagerTest#removeLiquidity_toNonEmpty.snap +++ b/.forge-snapshots/CLPoolManagerTest#removeLiquidity_toNonEmpty.snap @@ -1 +1 @@ -112713 \ No newline at end of file +112784 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerTest#swap_againstLiquidity.snap b/.forge-snapshots/CLPoolManagerTest#swap_againstLiquidity.snap index 42fb48cf..6245a697 100644 --- a/.forge-snapshots/CLPoolManagerTest#swap_againstLiquidity.snap +++ b/.forge-snapshots/CLPoolManagerTest#swap_againstLiquidity.snap @@ -1 +1 @@ -130739 \ No newline at end of file +130830 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerTest#swap_leaveSurplusTokenInVault.snap b/.forge-snapshots/CLPoolManagerTest#swap_leaveSurplusTokenInVault.snap index f46f6004..4b8b3a8a 100644 --- a/.forge-snapshots/CLPoolManagerTest#swap_leaveSurplusTokenInVault.snap +++ b/.forge-snapshots/CLPoolManagerTest#swap_leaveSurplusTokenInVault.snap @@ -1 +1 @@ -163100 \ No newline at end of file +163213 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerTest#swap_runOutOfLiquidity.snap b/.forge-snapshots/CLPoolManagerTest#swap_runOutOfLiquidity.snap index df9ce9e6..c19c3cd0 100644 --- a/.forge-snapshots/CLPoolManagerTest#swap_runOutOfLiquidity.snap +++ b/.forge-snapshots/CLPoolManagerTest#swap_runOutOfLiquidity.snap @@ -1 +1 @@ -148519 \ No newline at end of file +148610 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerTest#swap_simple.snap b/.forge-snapshots/CLPoolManagerTest#swap_simple.snap index c56a2c56..967cbf82 100644 --- a/.forge-snapshots/CLPoolManagerTest#swap_simple.snap +++ b/.forge-snapshots/CLPoolManagerTest#swap_simple.snap @@ -1 +1 @@ -71220 \ No newline at end of file +71289 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerTest#swap_useSurplusTokenAsInput.snap b/.forge-snapshots/CLPoolManagerTest#swap_useSurplusTokenAsInput.snap index 7e74d396..73498ae4 100644 --- a/.forge-snapshots/CLPoolManagerTest#swap_useSurplusTokenAsInput.snap +++ b/.forge-snapshots/CLPoolManagerTest#swap_useSurplusTokenAsInput.snap @@ -1 +1 @@ -143103 \ No newline at end of file +143194 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerTest#swap_withHooks.snap b/.forge-snapshots/CLPoolManagerTest#swap_withHooks.snap index a0fa5b33..6b9fb87d 100644 --- a/.forge-snapshots/CLPoolManagerTest#swap_withHooks.snap +++ b/.forge-snapshots/CLPoolManagerTest#swap_withHooks.snap @@ -1 +1 @@ -87408 \ No newline at end of file +87479 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerTest#swap_withNative.snap b/.forge-snapshots/CLPoolManagerTest#swap_withNative.snap index 255b0f96..973e8137 100644 --- a/.forge-snapshots/CLPoolManagerTest#swap_withNative.snap +++ b/.forge-snapshots/CLPoolManagerTest#swap_withNative.snap @@ -1 +1 @@ -71223 \ No newline at end of file +71292 \ No newline at end of file diff --git a/.forge-snapshots/CLPoolManagerTest#testFuzzUpdateDynamicLPFee.snap b/.forge-snapshots/CLPoolManagerTest#testFuzzUpdateDynamicLPFee.snap index 3119dbe3..8cd097e9 100644 --- a/.forge-snapshots/CLPoolManagerTest#testFuzzUpdateDynamicLPFee.snap +++ b/.forge-snapshots/CLPoolManagerTest#testFuzzUpdateDynamicLPFee.snap @@ -1 +1 @@ -31953 \ No newline at end of file +31956 \ No newline at end of file diff --git a/.forge-snapshots/ExtsloadTest#extsload.snap b/.forge-snapshots/ExtsloadTest#extsload.snap index 4027b7a7..7052d3dc 100644 --- a/.forge-snapshots/ExtsloadTest#extsload.snap +++ b/.forge-snapshots/ExtsloadTest#extsload.snap @@ -1 +1 @@ -7315 \ No newline at end of file +7318 \ No newline at end of file diff --git a/.forge-snapshots/ExtsloadTest#extsloadInBatch.snap b/.forge-snapshots/ExtsloadTest#extsloadInBatch.snap index a7000fdd..06bc2649 100644 --- a/.forge-snapshots/ExtsloadTest#extsloadInBatch.snap +++ b/.forge-snapshots/ExtsloadTest#extsloadInBatch.snap @@ -1 +1 @@ -10977 \ No newline at end of file +10980 \ No newline at end of file diff --git a/.forge-snapshots/VaultBytecodeSize.snap b/.forge-snapshots/VaultBytecodeSize.snap index d25ee827..9ee97474 100644 --- a/.forge-snapshots/VaultBytecodeSize.snap +++ b/.forge-snapshots/VaultBytecodeSize.snap @@ -1 +1 @@ -7792 \ No newline at end of file +8131 \ No newline at end of file diff --git a/.forge-snapshots/VaultTest#testVault_clear_successWithNonZeroExistingDelta.snap b/.forge-snapshots/VaultTest#testVault_clear_successWithNonZeroExistingDelta.snap index 79beb910..ec1ea33a 100644 --- a/.forge-snapshots/VaultTest#testVault_clear_successWithNonZeroExistingDelta.snap +++ b/.forge-snapshots/VaultTest#testVault_clear_successWithNonZeroExistingDelta.snap @@ -1 +1 @@ -2854 \ No newline at end of file +2876 \ No newline at end of file diff --git a/.forge-snapshots/VaultTest#testVault_clear_successWithZeroExistingDelta.snap b/.forge-snapshots/VaultTest#testVault_clear_successWithZeroExistingDelta.snap index 22f5f1cc..df6ce457 100644 --- a/.forge-snapshots/VaultTest#testVault_clear_successWithZeroExistingDelta.snap +++ b/.forge-snapshots/VaultTest#testVault_clear_successWithZeroExistingDelta.snap @@ -1 +1 @@ -1858 \ No newline at end of file +1880 \ No newline at end of file diff --git a/src/Vault.sol b/src/Vault.sol index d3c0502c..28fdd97b 100644 --- a/src/Vault.sol +++ b/src/Vault.sol @@ -72,6 +72,30 @@ contract Vault is IVault, VaultToken, Ownable { SettlementGuard.setLocker(address(0)); } + /// @inheritdoc IVault + function accountAppBalanceDelta( + Currency currency0, + Currency currency1, + BalanceDelta delta, + address settler, + BalanceDelta hookDelta, + address hook + ) external override isLocked onlyRegisteredApp { + (int128 delta0, int128 delta1) = (delta.amount0(), delta.amount1()); + (int128 hookDelta0, int128 hookDelta1) = (hookDelta.amount0(), hookDelta.amount1()); + + /// @dev call _accountDeltaForApp once with both delta/hookDelta to save gas and prevent + /// reservesOfApp from underflow when it deduct before addition + _accountDeltaForApp(currency0, delta0 + hookDelta0); + _accountDeltaForApp(currency1, delta1 + hookDelta1); + + // keep track of the balance on vault level + SettlementGuard.accountDelta(settler, currency0, delta0); + SettlementGuard.accountDelta(settler, currency1, delta1); + SettlementGuard.accountDelta(hook, currency0, hookDelta0); + SettlementGuard.accountDelta(hook, currency1, hookDelta1); + } + /// @inheritdoc IVault function accountAppBalanceDelta(Currency currency0, Currency currency1, BalanceDelta delta, address settler) external diff --git a/src/interfaces/IVault.sol b/src/interfaces/IVault.sol index 58c7de13..2614b889 100644 --- a/src/interfaces/IVault.sol +++ b/src/interfaces/IVault.sol @@ -56,6 +56,24 @@ interface IVault is IVaultToken { /// @return The data returned by the call to `ILockCallback(msg.sender).lockCallback(data)` function lock(bytes calldata data) external returns (bytes memory); + /// @notice Called by registered app to account for a change in the pool balance, + /// convenient for AMM pool manager, typically after modifyLiquidity, swap, donate, + /// include the case where hookDelta is involved + /// @param currency0 The PoolKey currency0 to update + /// @param currency1 The PoolKey currency1 to update + /// @param delta The change in the pool's balance + /// @param settler The address whose delta will be updated + /// @param hookDelta The change in the pool's balance from hook + /// @param hook The address whose hookDelta will be updated + function accountAppBalanceDelta( + Currency currency0, + Currency currency1, + BalanceDelta delta, + address settler, + BalanceDelta hookDelta, + address hook + ) external; + /// @notice Called by registered app to account for a change in the pool balance, /// convenient for AMM pool manager, typically after modifyLiquidity, swap, donate /// @param currency0 The PoolKey currency0 to update diff --git a/src/libraries/VaultAppDeltaSettlement.sol b/src/libraries/VaultAppDeltaSettlement.sol new file mode 100644 index 00000000..445be432 --- /dev/null +++ b/src/libraries/VaultAppDeltaSettlement.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2024 PancakeSwap +pragma solidity ^0.8.0; + +import {BalanceDelta, BalanceDeltaLibrary} from "../types/BalanceDelta.sol"; +import {PoolKey} from "../types/PoolKey.sol"; +import {IVault} from "../interfaces/IVault.sol"; + +/// @notice Library for handling AppDeltaSettlement for the apps (eg. CL, Bin etc..) +library VaultAppDeltaSettlement { + /// @notice helper method to call `vault.accountAppBalanceDelta` + function accountAppDeltaWithHookDelta(IVault vault, PoolKey memory key, BalanceDelta delta, BalanceDelta hookDelta) + internal + { + if (hookDelta == BalanceDeltaLibrary.ZERO_DELTA) { + /// @dev default case when no hook return delta is set + vault.accountAppBalanceDelta(key.currency0, key.currency1, delta, msg.sender); + } else { + vault.accountAppBalanceDelta(key.currency0, key.currency1, delta, msg.sender, hookDelta, address(key.hooks)); + } + } +} diff --git a/src/pool-bin/BinPoolManager.sol b/src/pool-bin/BinPoolManager.sol index 707449df..648d9eac 100644 --- a/src/pool-bin/BinPoolManager.sol +++ b/src/pool-bin/BinPoolManager.sol @@ -23,6 +23,7 @@ import {PriceHelper} from "./libraries/PriceHelper.sol"; import {BeforeSwapDelta} from "../types/BeforeSwapDelta.sol"; import "./interfaces/IBinHooks.sol"; import {BinSlot0} from "./types/BinSlot0.sol"; +import {VaultAppDeltaSettlement} from "../libraries/VaultAppDeltaSettlement.sol"; /// @notice Holds the state for all bin pools contract BinPoolManager is IBinPoolManager, ProtocolFees, Extsload { @@ -32,6 +33,7 @@ contract BinPoolManager is IBinPoolManager, ProtocolFees, Extsload { using LPFeeLibrary for uint24; using PackedUint128Math for bytes32; using Hooks for bytes32; + using VaultAppDeltaSettlement for IVault; /// @inheritdoc IBinPoolManager uint16 public constant override MIN_BIN_STEP = 1; @@ -181,11 +183,7 @@ contract BinPoolManager is IBinPoolManager, ProtocolFees, Extsload { BalanceDelta hookDelta; (delta, hookDelta) = BinHooks.afterSwap(key, swapForY, amountSpecified, delta, hookData, beforeSwapDelta); - if (hookDelta != BalanceDeltaLibrary.ZERO_DELTA) { - vault.accountAppBalanceDelta(key.currency0, key.currency1, hookDelta, address(key.hooks)); - } - - vault.accountAppBalanceDelta(key.currency0, key.currency1, delta, msg.sender); + vault.accountAppDeltaWithHookDelta(key, delta, hookDelta); } /// @inheritdoc IBinPoolManager @@ -229,10 +227,7 @@ contract BinPoolManager is IBinPoolManager, ProtocolFees, Extsload { BalanceDelta hookDelta; (delta, hookDelta) = BinHooks.afterMint(key, params, delta, hookData); - if (hookDelta != BalanceDeltaLibrary.ZERO_DELTA) { - vault.accountAppBalanceDelta(key.currency0, key.currency1, hookDelta, address(key.hooks)); - } - vault.accountAppBalanceDelta(key.currency0, key.currency1, delta, msg.sender); + vault.accountAppDeltaWithHookDelta(key, delta, hookDelta); } /// @inheritdoc IBinPoolManager @@ -264,10 +259,7 @@ contract BinPoolManager is IBinPoolManager, ProtocolFees, Extsload { BalanceDelta hookDelta; (delta, hookDelta) = BinHooks.afterBurn(key, params, delta, hookData); - if (hookDelta != BalanceDeltaLibrary.ZERO_DELTA) { - vault.accountAppBalanceDelta(key.currency0, key.currency1, hookDelta, address(key.hooks)); - } - vault.accountAppBalanceDelta(key.currency0, key.currency1, delta, msg.sender); + vault.accountAppDeltaWithHookDelta(key, delta, hookDelta); } function donate(PoolKey memory key, uint128 amount0, uint128 amount1, bytes calldata hookData) diff --git a/src/pool-cl/CLPoolManager.sol b/src/pool-cl/CLPoolManager.sol index 8211610e..91dde400 100644 --- a/src/pool-cl/CLPoolManager.sol +++ b/src/pool-cl/CLPoolManager.sol @@ -25,6 +25,7 @@ import {BeforeSwapDelta} from "../types/BeforeSwapDelta.sol"; import {Currency} from "../types/Currency.sol"; import {TickMath} from "./libraries/TickMath.sol"; import {CLSlot0} from "./types/CLSlot0.sol"; +import {VaultAppDeltaSettlement} from "../libraries/VaultAppDeltaSettlement.sol"; contract CLPoolManager is ICLPoolManager, ProtocolFees, Extsload { using SafeCast for int256; @@ -34,6 +35,7 @@ contract CLPoolManager is ICLPoolManager, ProtocolFees, Extsload { using CLPool for *; using CLPosition for mapping(bytes32 => CLPosition.Info); using CLPoolGetters for CLPool.State; + using VaultAppDeltaSettlement for IVault; mapping(PoolId id => CLPool.State poolState) private pools; @@ -154,10 +156,7 @@ contract CLPoolManager is ICLPoolManager, ProtocolFees, Extsload { // notice that both generated delta and feeDelta (from lpFee) will both be counted on the user (delta, hookDelta) = CLHooks.afterModifyLiquidity(key, params, delta + feeDelta, feeDelta, hookData); - if (hookDelta != BalanceDeltaLibrary.ZERO_DELTA) { - vault.accountAppBalanceDelta(key.currency0, key.currency1, hookDelta, address(key.hooks)); - } - vault.accountAppBalanceDelta(key.currency0, key.currency1, delta, msg.sender); + vault.accountAppDeltaWithHookDelta(key, delta, hookDelta); } /// @inheritdoc ICLPoolManager @@ -206,15 +205,11 @@ contract CLPoolManager is ICLPoolManager, ProtocolFees, Extsload { ); BalanceDelta hookDelta; - (delta, hookDelta) = CLHooks.afterSwap(key, params, delta, hookData, beforeSwapDelta); - - if (hookDelta != BalanceDeltaLibrary.ZERO_DELTA) { - vault.accountAppBalanceDelta(key.currency0, key.currency1, hookDelta, address(key.hooks)); - } /// @dev delta already includes protocol fee - /// all tokens go into the vault - vault.accountAppBalanceDelta(key.currency0, key.currency1, delta, msg.sender); + (delta, hookDelta) = CLHooks.afterSwap(key, params, delta, hookData, beforeSwapDelta); + + vault.accountAppDeltaWithHookDelta(key, delta, hookDelta); } /// @inheritdoc ICLPoolManager diff --git a/test/pool-bin/BinCustomCurveHook.t.sol b/test/pool-bin/BinCustomCurveHook.t.sol new file mode 100644 index 00000000..dbc8d133 --- /dev/null +++ b/test/pool-bin/BinCustomCurveHook.t.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; +import {IVault} from "../../src/interfaces/IVault.sol"; +import {IPoolManager} from "../../src/interfaces/IPoolManager.sol"; +import {IBinPoolManager} from "../../src/pool-bin/interfaces/IBinPoolManager.sol"; +import {Vault} from "../../src/Vault.sol"; +import {Currency} from "../../src/types/Currency.sol"; +import {PoolKey} from "../../src/types/PoolKey.sol"; +import {BalanceDelta, BalanceDeltaLibrary} from "../../src/types/BalanceDelta.sol"; +import {BinPoolManager} from "../../src/pool-bin/BinPoolManager.sol"; +import {BinPool} from "../../src/pool-bin/libraries/BinPool.sol"; +import {BinPoolParametersHelper} from "../../src/pool-bin/libraries/BinPoolParametersHelper.sol"; +import {BinSwapHelper} from "./helpers/BinSwapHelper.sol"; +import {BinTestHelper} from "./helpers/BinTestHelper.sol"; +import {Hooks} from "../../src/libraries/Hooks.sol"; +import {BinCustomCurveHook} from "./helpers/BinCustomCurveHook.sol"; + +contract BinCustomCurveHookTest is Test, GasSnapshot, BinTestHelper { + using BinPoolParametersHelper for bytes32; + + Vault public vault; + BinPoolManager public poolManager; + BinCustomCurveHook public binCustomCurveHook; + + BinSwapHelper public binSwapHelper; + + uint24 activeId = 2 ** 23; // where token0 and token1 price is the same + + PoolKey key; + bytes32 poolParam; + MockERC20 token0; + MockERC20 token1; + Currency currency0; + Currency currency1; + + function setUp() public { + vault = new Vault(); + poolManager = new BinPoolManager(IVault(address(vault))); + + vault.registerApp(address(poolManager)); + + token0 = new MockERC20("TestA", "A", 18); + token1 = new MockERC20("TestB", "B", 18); + (token0, token1) = token0 < token1 ? (token0, token1) : (token1, token0); + currency0 = Currency.wrap(address(token0)); + currency1 = Currency.wrap(address(token1)); + + IBinPoolManager iBinPoolManager = IBinPoolManager(address(poolManager)); + IVault iVault = IVault(address(vault)); + + binSwapHelper = new BinSwapHelper(iBinPoolManager, iVault); + token0.approve(address(binSwapHelper), 1000 ether); + token1.approve(address(binSwapHelper), 1000 ether); + + binCustomCurveHook = new BinCustomCurveHook(iVault, iBinPoolManager); + token0.approve(address(binCustomCurveHook), 1000 ether); + token1.approve(address(binCustomCurveHook), 1000 ether); + + key = PoolKey({ + currency0: currency0, + currency1: currency1, + hooks: binCustomCurveHook, + poolManager: IPoolManager(address(poolManager)), + fee: uint24(3000), // 3000 = 0.3% + parameters: bytes32(uint256(binCustomCurveHook.getHooksRegistrationBitmap())).setBinStep(10) + }); + + binCustomCurveHook.setPoolKey(key); + poolManager.initialize(key, activeId); + } + + /// @dev only meant for sanity test for the hook example + function test_addLiquidity_removeLiquidity() external { + // pre-req: mint token on this contract + token0.mint(address(this), 10 ether); + token1.mint(address(this), 10 ether); + + assertEq(token0.balanceOf(address(this)), 10 ether); + assertEq(token1.balanceOf(address(this)), 10 ether); + assertEq(token0.balanceOf(address(vault)), 0 ether); + assertEq(token1.balanceOf(address(vault)), 0 ether); + + // add liquidity and verify tokens are in the vault + binCustomCurveHook.addLiquidity(1 ether, 2 ether); + assertEq(token0.balanceOf(address(this)), 9 ether); + assertEq(token1.balanceOf(address(this)), 8 ether); + assertEq(token0.balanceOf(address(vault)), 1 ether); + assertEq(token1.balanceOf(address(vault)), 2 ether); + + // remove liquidity and verify tokens are returned to this contract + binCustomCurveHook.removeLiquidity(1 ether, 1 ether); + assertEq(token0.balanceOf(address(this)), 10 ether); + assertEq(token1.balanceOf(address(this)), 9 ether); + assertEq(token0.balanceOf(address(vault)), 0 ether); + assertEq(token1.balanceOf(address(vault)), 1 ether); + } + + function test_Swap_CustomCurve(uint256 _amtIn) public { + // preq-req: add liqudiity + token0.mint(address(this), 10 ether); + token1.mint(address(this), 10 ether); + binCustomCurveHook.addLiquidity(4 ether, 8 ether); + + // before verify + assertEq(token0.balanceOf(address(this)), 6 ether); + assertEq(token1.balanceOf(address(this)), 2 ether); + assertEq(token0.balanceOf(address(vault)), 4 ether); + assertEq(token1.balanceOf(address(vault)), 8 ether); + + // swap exactIn token0 for token1 + uint128 amtIn = uint128(bound(_amtIn, 0.1 ether, 6 ether)); // 6 as token0.balanceOf(address(this) == 6 ethers + + snapStart("BinCustomCurveHookTest#test_Swap_CustomCurve"); + BalanceDelta delta = binSwapHelper.swap(key, true, -int128(amtIn), BinSwapHelper.TestSettings(true, true), ""); + snapEnd(); + + // verify 1:1 swap + assertEq(delta.amount0(), -int128(amtIn)); + assertEq(delta.amount1(), int128(amtIn)); + + // after verify + assertEq(token0.balanceOf(address(this)), 6 ether - amtIn); + assertEq(token1.balanceOf(address(this)), 2 ether + amtIn); + assertEq(token0.balanceOf(address(vault)), 4 ether + amtIn); + assertEq(token1.balanceOf(address(vault)), 8 ether - amtIn); + } + + receive() external payable {} +} diff --git a/test/pool-bin/BinMintBurnFeeHook.t.sol b/test/pool-bin/BinMintBurnFeeHook.t.sol new file mode 100644 index 00000000..b85548cb --- /dev/null +++ b/test/pool-bin/BinMintBurnFeeHook.t.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; +import {IVault} from "../../src/interfaces/IVault.sol"; +import {IPoolManager} from "../../src/interfaces/IPoolManager.sol"; +import {IBinPoolManager} from "../../src/pool-bin/interfaces/IBinPoolManager.sol"; +import {Vault} from "../../src/Vault.sol"; +import {Currency} from "../../src/types/Currency.sol"; +import {PoolKey} from "../../src/types/PoolKey.sol"; +import {BalanceDelta, BalanceDeltaLibrary} from "../../src/types/BalanceDelta.sol"; +import {BinPoolManager} from "../../src/pool-bin/BinPoolManager.sol"; +import {BinPool} from "../../src/pool-bin/libraries/BinPool.sol"; +import {BinPoolParametersHelper} from "../../src/pool-bin/libraries/BinPoolParametersHelper.sol"; +import {BinLiquidityHelper} from "./helpers/BinLiquidityHelper.sol"; +import {BinTestHelper} from "./helpers/BinTestHelper.sol"; +import {Hooks} from "../../src/libraries/Hooks.sol"; +import {BinMintBurnFeeHook} from "./helpers/BinMintBurnFeeHook.sol"; + +contract BinMintBurnFeeHookTest is Test, GasSnapshot, BinTestHelper { + using BinPoolParametersHelper for bytes32; + + Vault public vault; + BinPoolManager public poolManager; + BinMintBurnFeeHook public binMintBurnFeeHook; + + BinLiquidityHelper public binLiquidityHelper; + + uint24 activeId = 2 ** 23; // where token0 and token1 price is the same + + PoolKey key; + bytes32 poolParam; + MockERC20 token0; + MockERC20 token1; + Currency currency0; + Currency currency1; + + function setUp() public { + vault = new Vault(); + poolManager = new BinPoolManager(IVault(address(vault))); + + vault.registerApp(address(poolManager)); + + token0 = new MockERC20("TestA", "A", 18); + token1 = new MockERC20("TestB", "B", 18); + (token0, token1) = token0 < token1 ? (token0, token1) : (token1, token0); + currency0 = Currency.wrap(address(token0)); + currency1 = Currency.wrap(address(token1)); + + IBinPoolManager iBinPoolManager = IBinPoolManager(address(poolManager)); + IVault iVault = IVault(address(vault)); + + binLiquidityHelper = new BinLiquidityHelper(iBinPoolManager, iVault); + token0.approve(address(binLiquidityHelper), 1000 ether); + token1.approve(address(binLiquidityHelper), 1000 ether); + + binMintBurnFeeHook = new BinMintBurnFeeHook(iVault, iBinPoolManager); + token0.approve(address(binMintBurnFeeHook), 1000 ether); + token1.approve(address(binMintBurnFeeHook), 1000 ether); + + key = PoolKey({ + currency0: currency0, + currency1: currency1, + hooks: binMintBurnFeeHook, + poolManager: IPoolManager(address(poolManager)), + fee: uint24(3000), // 3000 = 0.3% + parameters: bytes32(uint256(binMintBurnFeeHook.getHooksRegistrationBitmap())).setBinStep(10) + }); + + poolManager.initialize(key, activeId); + } + + function test_Mint() external { + token0.mint(address(this), 10 ether); + token1.mint(address(this), 10 ether); + + // before + assertEq(token0.balanceOf(address(this)), 10 ether); + assertEq(token1.balanceOf(address(this)), 10 ether); + assertEq(token0.balanceOf(address(vault)), 0 ether); + assertEq(token1.balanceOf(address(vault)), 0 ether); + assertEq(token0.balanceOf(address(binMintBurnFeeHook)), 0 ether); + assertEq(token1.balanceOf(address(binMintBurnFeeHook)), 0 ether); + + IBinPoolManager.MintParams memory mintParams = _getSingleBinMintParams(activeId, 1 ether, 1 ether); + snapStart("BinMintBurnFeeHookTest#test_Mint"); + BalanceDelta delta = binLiquidityHelper.mint(key, mintParams, abi.encode(0)); + snapEnd(); + + assertEq(token0.balanceOf(address(this)), 7 ether); + assertEq(token1.balanceOf(address(this)), 7 ether); + assertEq(token0.balanceOf(address(vault)), 3 ether); + assertEq(token1.balanceOf(address(vault)), 3 ether); + + // hook mint VaultToken instead of taking token from vault as vault does not have token in this case + assertEq(vault.balanceOf(address(binMintBurnFeeHook), key.currency0), 2 ether); + assertEq(vault.balanceOf(address(binMintBurnFeeHook), key.currency1), 2 ether); + } + + function test_Burn() external { + token0.mint(address(this), 10 ether); + token1.mint(address(this), 10 ether); + + IBinPoolManager.MintParams memory mintParams = _getSingleBinMintParams(activeId, 1 ether, 1 ether); + BalanceDelta delta = binLiquidityHelper.mint(key, mintParams, abi.encode(0)); + + assertEq(token0.balanceOf(address(this)), 7 ether); + assertEq(token1.balanceOf(address(this)), 7 ether); + assertEq(token0.balanceOf(address(vault)), 3 ether); + assertEq(token1.balanceOf(address(vault)), 3 ether); + assertEq(vault.balanceOf(address(binMintBurnFeeHook), key.currency0), 2 ether); + assertEq(vault.balanceOf(address(binMintBurnFeeHook), key.currency1), 2 ether); + + IBinPoolManager.BurnParams memory burnParams = + _getSingleBinBurnLiquidityParams(key, poolManager, activeId, address(binLiquidityHelper), 100); + snapStart("BinMintBurnFeeHookTest#test_Burn"); + binLiquidityHelper.burn(key, burnParams, ""); + snapEnd(); + + // +1 from remove liqudiity, -4 from hook fee + assertEq(token0.balanceOf(address(this)), 7 ether + 1 ether - 4 ether); + assertEq(token1.balanceOf(address(this)), 7 ether + 1 ether - 4 ether); + + // -1 from remove liquidity, +4 from hook calling vault.mint + assertEq(token0.balanceOf(address(vault)), 3 ether - 1 ether + 4 ether); + assertEq(token1.balanceOf(address(vault)), 3 ether - 1 ether + 4 ether); + assertEq(vault.balanceOf(address(binMintBurnFeeHook), key.currency0), 2 ether + 4 ether); + assertEq(vault.balanceOf(address(binMintBurnFeeHook), key.currency1), 2 ether + 4 ether); + } + + receive() external payable {} +} diff --git a/test/pool-bin/helpers/BinCustomCurveHook.sol b/test/pool-bin/helpers/BinCustomCurveHook.sol new file mode 100644 index 00000000..bbd31cd0 --- /dev/null +++ b/test/pool-bin/helpers/BinCustomCurveHook.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {IVault} from "../../../src/interfaces/IVault.sol"; +import {Hooks} from "../../../src/libraries/Hooks.sol"; +import {IBinPoolManager} from "../../../src/pool-bin/interfaces/IBinPoolManager.sol"; +import {PoolKey} from "../../../src/types/PoolKey.sol"; +import {Currency} from "../../../src/types/Currency.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {toBalanceDelta, BalanceDelta, BalanceDeltaLibrary} from "../../../src/types/BalanceDelta.sol"; +import {BeforeSwapDelta, toBeforeSwapDelta} from "../../../src/types/BeforeSwapDelta.sol"; +import {BaseBinTestHook} from "./BaseBinTestHook.sol"; +import {CurrencySettlement} from "../../helpers/CurrencySettlement.sol"; + +contract BinCustomCurveHook is BaseBinTestHook { + error InvalidAction(); + + using CurrencySettlement for Currency; + using Hooks for bytes32; + + IVault public immutable vault; + IBinPoolManager public immutable poolManager; + PoolKey key; + + constructor(IVault _vault, IBinPoolManager _poolManager) { + vault = _vault; + poolManager = _poolManager; + } + + function setPoolKey(PoolKey memory _poolKey) external { + key = _poolKey; + } + + function getHooksRegistrationBitmap() external pure override returns (uint16) { + return _hooksRegistrationBitmapFrom( + Permissions({ + beforeInitialize: false, + afterInitialize: false, + beforeMint: false, + afterMint: false, + beforeBurn: false, + afterBurn: false, + beforeSwap: true, + afterSwap: false, + beforeDonate: false, + afterDonate: false, + beforeSwapReturnsDelta: true, + afterSwapReturnsDelta: false, + afterMintReturnsDelta: false, + afterBurnReturnsDelta: false + }) + ); + } + + /// @dev assume user call hook to add liquidity + function addLiquidity(uint256 amt0, uint256 amt1) public { + // 1. Take input currency and amount from user + IERC20(Currency.unwrap(key.currency0)).transferFrom(msg.sender, address(this), amt0); + IERC20(Currency.unwrap(key.currency1)).transferFrom(msg.sender, address(this), amt1); + + // 2. Mint -- so vault has token balance + vault.lock(abi.encode("mint", abi.encode(amt0, amt1))); + } + + /// @dev assume user call hook to remove liquidity + function removeLiquidity(uint256 amt0, uint256 amt1) public { + // 2. Mint -- so vault has token balance + vault.lock(abi.encode("burn", abi.encode(amt0, amt1))); + + IERC20(Currency.unwrap(key.currency0)).transfer(msg.sender, amt0); + IERC20(Currency.unwrap(key.currency1)).transfer(msg.sender, amt1); + } + + function lockAcquired(bytes calldata callbackData) external returns (bytes memory) { + (bytes memory action, bytes memory rawCallbackData) = abi.decode(callbackData, (bytes, bytes)); + + if (keccak256(action) == keccak256("mint")) { + (uint256 amt0, uint256 amt1) = abi.decode(rawCallbackData, (uint256, uint256)); + + // transfer token to the vault and mint VaultToken + key.currency0.settle(vault, address(this), amt0, false); + key.currency0.take(vault, address(this), amt0, true); + + key.currency1.settle(vault, address(this), amt1, false); + key.currency1.take(vault, address(this), amt1, true); + } else if (keccak256(action) == keccak256("burn")) { + (uint256 amt0, uint256 amt1) = abi.decode(rawCallbackData, (uint256, uint256)); + + // take token from the vault and burn VaultToken + key.currency0.take(vault, address(this), amt0, false); + key.currency0.settle(vault, address(this), amt0, true); + + key.currency1.take(vault, address(this), amt1, false); + key.currency1.settle(vault, address(this), amt1, true); + } + } + + /// @dev 1:1 swap + function beforeSwap(address, PoolKey calldata key, bool swapForY, int128 amountSpecified, bytes calldata) + external + override + returns (bytes4, BeforeSwapDelta, uint24) + { + (Currency inputCurrency, Currency outputCurrency, uint256 amount) = + _getInputOutputAndAmount(key, swapForY, amountSpecified); + + // 1. Take input currency and amount + inputCurrency.take(vault, address(this), amount, true); + + // 2. Give output currency and amount achieving a 1:1 swap + outputCurrency.settle(vault, address(this), amount, true); + + BeforeSwapDelta hookDelta = toBeforeSwapDelta(-amountSpecified, amountSpecified); + return (this.beforeSwap.selector, hookDelta, 0); + } + + /// @notice Get input, output currencies and amount from swap params + function _getInputOutputAndAmount(PoolKey calldata _key, bool swapForY, int128 amountSpecified) + internal + pure + returns (Currency input, Currency output, uint256 amount) + { + (input, output) = swapForY ? (_key.currency0, _key.currency1) : (_key.currency1, _key.currency0); + + amount = amountSpecified < 0 ? uint128(-amountSpecified) : uint128(amountSpecified); + } +} diff --git a/test/pool-bin/helpers/BinMintBurnFeeHook.sol b/test/pool-bin/helpers/BinMintBurnFeeHook.sol new file mode 100644 index 00000000..373a86e9 --- /dev/null +++ b/test/pool-bin/helpers/BinMintBurnFeeHook.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {IVault} from "../../../src/interfaces/IVault.sol"; +import {Hooks} from "../../../src/libraries/Hooks.sol"; +import {IBinPoolManager} from "../../../src/pool-bin/interfaces/IBinPoolManager.sol"; +import {PoolKey} from "../../../src/types/PoolKey.sol"; +import {Currency} from "../../../src/types/Currency.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {toBalanceDelta, BalanceDelta, BalanceDeltaLibrary} from "../../../src/types/BalanceDelta.sol"; +import {BeforeSwapDelta, toBeforeSwapDelta} from "../../../src/types/BeforeSwapDelta.sol"; +import {BaseBinTestHook} from "./BaseBinTestHook.sol"; +import {CurrencySettlement} from "../../helpers/CurrencySettlement.sol"; + +import {console2} from "forge-std/console2.sol"; + +/// @dev A hook which take a fee on every mint/burn +contract BinMintBurnFeeHook is BaseBinTestHook { + error InvalidAction(); + + using CurrencySettlement for Currency; + using Hooks for bytes32; + + IVault public immutable vault; + IBinPoolManager public immutable poolManager; + + constructor(IVault _vault, IBinPoolManager _poolManager) { + vault = _vault; + poolManager = _poolManager; + } + + function getHooksRegistrationBitmap() external pure override returns (uint16) { + return _hooksRegistrationBitmapFrom( + Permissions({ + beforeInitialize: false, + afterInitialize: false, + beforeMint: false, + afterMint: true, + beforeBurn: false, + afterBurn: true, + beforeSwap: false, + afterSwap: false, + beforeDonate: false, + afterDonate: false, + beforeSwapReturnsDelta: false, + afterSwapReturnsDelta: false, + afterMintReturnsDelta: true, + afterBurnReturnsDelta: true + }) + ); + } + + /// @dev take 2x of the mint amount as fee + /// meant for https://github.com/pancakeswap/pancake-v4-core/pull/203 to ensure reserveOfApp underflow won't happen + function afterMint( + address, + PoolKey calldata key, + IBinPoolManager.MintParams calldata, + BalanceDelta delta, + bytes calldata + ) external override returns (bytes4, BalanceDelta) { + // take fee from mint + int128 amt0Fee; + if (delta.amount0() < 0) { + amt0Fee = (-delta.amount0()) * 2; + key.currency0.take(vault, address(this), uint128(amt0Fee), true); + } + int128 amt1Fee = 0; + if (delta.amount1() < 0) { + amt1Fee = (-delta.amount1()) * 2; + key.currency1.take(vault, address(this), uint128(amt1Fee), true); + } + + BalanceDelta hookDelta = toBalanceDelta(amt0Fee, amt1Fee); + return (this.afterMint.selector, hookDelta); + } + + /// @dev take 4x the burn amount as fee + /// meant for https://github.com/pancakeswap/pancake-v4-core/pull/203 to ensure reserveOfApp underflow won't happen + function afterBurn( + address, + PoolKey calldata key, + IBinPoolManager.BurnParams calldata, + BalanceDelta delta, + bytes calldata + ) external override returns (bytes4, BalanceDelta) { + console2.log("afterBurn delta"); + console2.logInt(delta.amount0()); + console2.logInt(delta.amount1()); + + int128 amt0Fee; + if (delta.amount0() > 0) { + amt0Fee = (delta.amount0()) * 4; + key.currency0.take(vault, address(this), uint128(amt0Fee), true); + } + int128 amt1Fee = 0; + if (delta.amount1() > 0) { + amt1Fee = (delta.amount1()) * 4; + key.currency1.take(vault, address(this), uint128(amt1Fee), true); + } + + BalanceDelta hookDelta = toBalanceDelta(amt0Fee, amt1Fee); + return (this.afterBurn.selector, hookDelta); + } +} diff --git a/test/pool-cl/CLCustomCurveHook.t.sol b/test/pool-cl/CLCustomCurveHook.t.sol new file mode 100644 index 00000000..d43e6e55 --- /dev/null +++ b/test/pool-cl/CLCustomCurveHook.t.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; +import {IVault} from "../../src/interfaces/IVault.sol"; +import {Vault} from "../../src/Vault.sol"; +import {IPoolManager} from "../../src/interfaces/IPoolManager.sol"; +import {ICLPoolManager} from "../../src/pool-cl/interfaces/ICLPoolManager.sol"; +import {CLPoolManager} from "../../src/pool-cl/CLPoolManager.sol"; +import {CLPool} from "../../src/pool-cl/libraries/CLPool.sol"; +import {Currency, CurrencyLibrary} from "../../src/types/Currency.sol"; +import {PoolKey} from "../../src/types/PoolKey.sol"; +import {IHooks} from "../../src/interfaces/IHooks.sol"; +import {Hooks} from "../../src/libraries/Hooks.sol"; +import {CLPoolManagerRouter} from "./helpers/CLPoolManagerRouter.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Deployers} from "./helpers/Deployers.sol"; +import {TokenFixture} from "../helpers/TokenFixture.sol"; +import {LPFeeLibrary} from "../../src/libraries/LPFeeLibrary.sol"; +import {CLPoolParametersHelper} from "../../src/pool-cl/libraries/CLPoolParametersHelper.sol"; +import {CLCustomCurveHook} from "./helpers/CLCustomCurveHook.sol"; +import {BalanceDelta} from "../../src/types/BalanceDelta.sol"; +import {TickMath} from "../../src/pool-cl/libraries/TickMath.sol"; + +contract CLCustomCurveHookTest is Test, Deployers, TokenFixture, GasSnapshot { + using CLPoolParametersHelper for bytes32; + using LPFeeLibrary for uint24; + + PoolKey key; + IVault public vault; + CLPoolManager public poolManager; + CLPoolManagerRouter public router; + CLCustomCurveHook public clCustomCurveHook; + + MockERC20 token0; + MockERC20 token1; + + function setUp() public { + initializeTokens(); + token0 = MockERC20(Currency.unwrap(currency0)); + token1 = MockERC20(Currency.unwrap(currency1)); + + // burn all tokens minted via initializeTokens + token0.burn(address(this), token0.balanceOf(address(this))); + token1.burn(address(this), token1.balanceOf(address(this))); + (vault, poolManager) = createFreshManager(); + + router = new CLPoolManagerRouter(vault, poolManager); + clCustomCurveHook = new CLCustomCurveHook(vault, poolManager); + + IERC20(Currency.unwrap(currency0)).approve(address(router), 1000 ether); + IERC20(Currency.unwrap(currency1)).approve(address(router), 1000 ether); + IERC20(Currency.unwrap(currency0)).approve(address(clCustomCurveHook), 1000 ether); + IERC20(Currency.unwrap(currency1)).approve(address(clCustomCurveHook), 1000 ether); + + key = PoolKey({ + currency0: currency0, + currency1: currency1, + hooks: clCustomCurveHook, + poolManager: poolManager, + fee: uint24(3000), + parameters: bytes32(uint256(clCustomCurveHook.getHooksRegistrationBitmap())).setTickSpacing(10) + }); + clCustomCurveHook.setPoolKey(key); + poolManager.initialize(key, SQRT_RATIO_1_1); + } + + /// @dev only meant for sanity test for the hook example + function test_addLiquidity_removeLiquidityXX() external { + // pre-req: mint token on this contract + token0.mint(address(this), 10 ether); + token1.mint(address(this), 10 ether); + + assertEq(token0.balanceOf(address(this)), 10 ether); + assertEq(token1.balanceOf(address(this)), 10 ether); + assertEq(token0.balanceOf(address(vault)), 0 ether); + assertEq(token1.balanceOf(address(vault)), 0 ether); + + // add liquidity and verify tokens are in the vault + clCustomCurveHook.addLiquidity(1 ether, 2 ether); + assertEq(token0.balanceOf(address(this)), 9 ether); + assertEq(token1.balanceOf(address(this)), 8 ether); + assertEq(token0.balanceOf(address(vault)), 1 ether); + assertEq(token1.balanceOf(address(vault)), 2 ether); + + // remove liquidity and verify tokens are returned to this contract + clCustomCurveHook.removeLiquidity(1 ether, 1 ether); + assertEq(token0.balanceOf(address(this)), 10 ether); + assertEq(token1.balanceOf(address(this)), 9 ether); + assertEq(token0.balanceOf(address(vault)), 0 ether); + assertEq(token1.balanceOf(address(vault)), 1 ether); + } + + function test_Swap_CustomCurve(uint256 _amtIn) public { + // preq-req: add liqudiity + token0.mint(address(this), 10 ether); + token1.mint(address(this), 10 ether); + clCustomCurveHook.addLiquidity(4 ether, 8 ether); + + // before verify + assertEq(token0.balanceOf(address(this)), 6 ether); + assertEq(token1.balanceOf(address(this)), 2 ether); + assertEq(token0.balanceOf(address(vault)), 4 ether); + assertEq(token1.balanceOf(address(vault)), 8 ether); + + // swap exactIn token0 for token1 + uint128 amtIn = uint128(bound(_amtIn, 0.1 ether, 6 ether)); // 6 as token0.balanceOf(address(this) == 6 ethers + + snapStart("CLCustomCurveHookTest#test_Swap_CustomCurve"); + BalanceDelta delta = router.swap( + key, + ICLPoolManager.SwapParams({ + zeroForOne: true, + amountSpecified: -int128(amtIn), + sqrtPriceLimitX96: TickMath.MIN_SQRT_RATIO + 1 + }), + CLPoolManagerRouter.SwapTestSettings({withdrawTokens: true, settleUsingTransfer: true}), + "" + ); + snapEnd(); + + // verify 1:1 swap + assertEq(delta.amount0(), -int128(amtIn)); + assertEq(delta.amount1(), int128(amtIn)); + + // after verify + assertEq(token0.balanceOf(address(this)), 6 ether - amtIn); + assertEq(token1.balanceOf(address(this)), 2 ether + amtIn); + assertEq(token0.balanceOf(address(vault)), 4 ether + amtIn); + assertEq(token1.balanceOf(address(vault)), 8 ether - amtIn); + } + + receive() external payable {} +} diff --git a/test/pool-cl/CLMintBurnFeeHook.t.sol b/test/pool-cl/CLMintBurnFeeHook.t.sol new file mode 100644 index 00000000..19818601 --- /dev/null +++ b/test/pool-cl/CLMintBurnFeeHook.t.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; +import {IVault} from "../../src/interfaces/IVault.sol"; +import {Vault} from "../../src/Vault.sol"; +import {IPoolManager} from "../../src/interfaces/IPoolManager.sol"; +import {ICLPoolManager} from "../../src/pool-cl/interfaces/ICLPoolManager.sol"; +import {CLPoolManager} from "../../src/pool-cl/CLPoolManager.sol"; +import {CLPool} from "../../src/pool-cl/libraries/CLPool.sol"; +import {Currency, CurrencyLibrary} from "../../src/types/Currency.sol"; +import {PoolKey} from "../../src/types/PoolKey.sol"; +import {IHooks} from "../../src/interfaces/IHooks.sol"; +import {Hooks} from "../../src/libraries/Hooks.sol"; +import {CLPoolManagerRouter} from "./helpers/CLPoolManagerRouter.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Deployers} from "./helpers/Deployers.sol"; +import {TokenFixture} from "../helpers/TokenFixture.sol"; +import {CLPoolParametersHelper} from "../../src/pool-cl/libraries/CLPoolParametersHelper.sol"; +import {CLMintBurnFeeHook} from "./helpers/CLMintBurnFeeHook.sol"; +import {BalanceDelta} from "../../src/types/BalanceDelta.sol"; + +contract CLMintBurnFeeHookTest is Test, Deployers, TokenFixture, GasSnapshot { + using CLPoolParametersHelper for bytes32; + + PoolKey key; + IVault public vault; + CLPoolManager public poolManager; + CLPoolManagerRouter public router; + CLMintBurnFeeHook public clMintBurnFeeHook; + + MockERC20 token0; + MockERC20 token1; + + function setUp() public { + initializeTokens(); + token0 = MockERC20(Currency.unwrap(currency0)); + token1 = MockERC20(Currency.unwrap(currency1)); + + // burn all tokens minted via initializeTokens + token0.burn(address(this), token0.balanceOf(address(this))); + token1.burn(address(this), token1.balanceOf(address(this))); + (vault, poolManager) = createFreshManager(); + + router = new CLPoolManagerRouter(vault, poolManager); + clMintBurnFeeHook = new CLMintBurnFeeHook(vault, poolManager); + + IERC20(Currency.unwrap(currency0)).approve(address(router), 1000 ether); + IERC20(Currency.unwrap(currency1)).approve(address(router), 1000 ether); + IERC20(Currency.unwrap(currency0)).approve(address(clMintBurnFeeHook), 1000 ether); + IERC20(Currency.unwrap(currency1)).approve(address(clMintBurnFeeHook), 1000 ether); + + key = PoolKey({ + currency0: currency0, + currency1: currency1, + hooks: clMintBurnFeeHook, + poolManager: poolManager, + fee: uint24(3000), + parameters: bytes32(uint256(clMintBurnFeeHook.getHooksRegistrationBitmap())).setTickSpacing(10) + }); + poolManager.initialize(key, SQRT_RATIO_1_1); + } + + /// @dev only meant for sanity test for the hook example + function test_Mint() external { + token0.mint(address(this), 10 ether); + token1.mint(address(this), 10 ether); + + // before + assertEq(token0.balanceOf(address(this)), 10 ether); + assertEq(token1.balanceOf(address(this)), 10 ether); + assertEq(token0.balanceOf(address(vault)), 0 ether); + assertEq(token1.balanceOf(address(vault)), 0 ether); + assertEq(token0.balanceOf(address(clMintBurnFeeHook)), 0 ether); + assertEq(token1.balanceOf(address(clMintBurnFeeHook)), 0 ether); + + // around 0.5 eth token0 / 0.5 eth token1 liquidity added + snapStart("CLMintBurnFeeHookTest#test_Mint"); + (BalanceDelta delta,) = router.modifyPosition( + key, + ICLPoolManager.ModifyLiquidityParams({tickLower: -10, tickUpper: 10, liquidityDelta: 1000 ether, salt: 0}), + "" + ); + snapEnd(); + + assertEq(token0.balanceOf(address(this)), 8500449895020996220); // ~8.5 ether + assertEq(token1.balanceOf(address(this)), 8500449895020996220); // ~8.4 ether + assertEq(token0.balanceOf(address(vault)), 1499550104979003780); // ~1.5 ether + assertEq(token1.balanceOf(address(vault)), 1499550104979003780); // ~1.5 ether + + // hook mint VaultToken instead of taking token from vault as vault does not have token in this case + assertEq(vault.balanceOf(address(clMintBurnFeeHook), key.currency0), 999700069986002520); // ~1 eth + assertEq(vault.balanceOf(address(clMintBurnFeeHook), key.currency1), 999700069986002520); // ~1 eth + } + + function test_Burn() external { + token0.mint(address(this), 10 ether); + token1.mint(address(this), 10 ether); + + // around 0.5 eth token0 / 0.5 eth token1 liquidity added + router.modifyPosition( + key, + ICLPoolManager.ModifyLiquidityParams({tickLower: -10, tickUpper: 10, liquidityDelta: 1000 ether, salt: 0}), + "" + ); + + assertEq(token0.balanceOf(address(this)), 8500449895020996220); // ~8.5 ether + assertEq(token1.balanceOf(address(this)), 8500449895020996220); // ~8.5 ether + assertEq(token0.balanceOf(address(vault)), 1499550104979003780); // ~1.5 ether + assertEq(token1.balanceOf(address(vault)), 1499550104979003780); // ~1.5 ether + assertEq(vault.balanceOf(address(clMintBurnFeeHook), key.currency0), 999700069986002520); // ~1 eth + assertEq(vault.balanceOf(address(clMintBurnFeeHook), key.currency1), 999700069986002520); // ~1 eth + + // remove liquidity + snapStart("CLMintBurnFeeHookTest#test_Burn"); + router.modifyPosition( + key, + ICLPoolManager.ModifyLiquidityParams({tickLower: -10, tickUpper: 10, liquidityDelta: -1000 ether, salt: 0}), + "" + ); + snapEnd(); + + // 8.5 to 7 eth = 1.5 eth diff :: -2 eth was taken by hook for fee and +0.5 was from remove liquidity + assertEq(token0.balanceOf(address(this)), 7000899790041992443); // ~7 eth + assertEq(token1.balanceOf(address(this)), 7000899790041992443); // ~7 eth + + // 1.5 to 3 eth = 1.5 eth diff :: -0.5 eth was returned to user and +2 eth deposited by hook + assertEq(token0.balanceOf(address(vault)), 2999100209958007557); // ~3 eth + assertEq(token1.balanceOf(address(vault)), 2999100209958007557); // ~3 eth + + // 1 to 3 eth = 2 eth diff :: + 2 eth as fee from remove liquidity + assertEq(vault.balanceOf(address(clMintBurnFeeHook), key.currency0), 2999100209958007556); // ~3 eth + assertEq(vault.balanceOf(address(clMintBurnFeeHook), key.currency1), 2999100209958007556); // ~3 eth + } + + receive() external payable {} +} diff --git a/test/pool-cl/helpers/CLCustomCurveHook.sol b/test/pool-cl/helpers/CLCustomCurveHook.sol new file mode 100644 index 00000000..23245b48 --- /dev/null +++ b/test/pool-cl/helpers/CLCustomCurveHook.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {IVault} from "../../../src/interfaces/IVault.sol"; +import {Hooks} from "../../../src/libraries/Hooks.sol"; +import {ICLPoolManager} from "../../../src/pool-cl/interfaces/ICLPoolManager.sol"; +import {PoolKey} from "../../../src/types/PoolKey.sol"; +import {Currency} from "../../../src/types/Currency.sol"; +import {CurrencySettlement} from "../../helpers/CurrencySettlement.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {BalanceDelta, BalanceDeltaLibrary} from "../../../src/types/BalanceDelta.sol"; +import {BaseCLTestHook} from "./BaseCLTestHook.sol"; +import {BeforeSwapDelta, BeforeSwapDeltaLibrary, toBeforeSwapDelta} from "../../../src/types/BeforeSwapDelta.sol"; +import {CurrencySettlement} from "../../helpers/CurrencySettlement.sol"; + +contract CLCustomCurveHook is BaseCLTestHook { + error InvalidAction(); + + using CurrencySettlement for Currency; + using Hooks for bytes32; + + IVault public immutable vault; + ICLPoolManager public immutable poolManager; + PoolKey key; + + constructor(IVault _vault, ICLPoolManager _poolManager) { + vault = _vault; + poolManager = _poolManager; + } + + function setPoolKey(PoolKey memory _poolKey) external { + key = _poolKey; + } + + function getHooksRegistrationBitmap() external pure override returns (uint16) { + return _hooksRegistrationBitmapFrom( + Permissions({ + beforeInitialize: false, + afterInitialize: false, + beforeAddLiquidity: false, + afterAddLiquidity: false, + beforeRemoveLiquidity: false, + afterRemoveLiquidity: false, + beforeSwap: true, + afterSwap: false, + beforeDonate: false, + afterDonate: false, + befreSwapReturnsDelta: true, + afterSwapReturnsDelta: false, + afterAddLiquidityReturnsDelta: false, + afterRemoveLiquidityReturnsDelta: false + }) + ); + } + + /// @dev assume user call hook to add liquidity + function addLiquidity(uint256 amt0, uint256 amt1) public { + // 1. Take input currency and amount from user + IERC20(Currency.unwrap(key.currency0)).transferFrom(msg.sender, address(this), amt0); + IERC20(Currency.unwrap(key.currency1)).transferFrom(msg.sender, address(this), amt1); + + // 2. Mint -- so vault has token balance + vault.lock(abi.encode("mint", abi.encode(amt0, amt1))); + } + + /// @dev assume user call hook to remove liquidity + function removeLiquidity(uint256 amt0, uint256 amt1) public { + // 2. Mint -- so vault has token balance + vault.lock(abi.encode("burn", abi.encode(amt0, amt1))); + + IERC20(Currency.unwrap(key.currency0)).transfer(msg.sender, amt0); + IERC20(Currency.unwrap(key.currency1)).transfer(msg.sender, amt1); + } + + function lockAcquired(bytes calldata callbackData) external returns (bytes memory) { + (bytes memory action, bytes memory rawCallbackData) = abi.decode(callbackData, (bytes, bytes)); + + if (keccak256(action) == keccak256("mint")) { + (uint256 amt0, uint256 amt1) = abi.decode(rawCallbackData, (uint256, uint256)); + + // transfer token to the vault and mint VaultToken + key.currency0.settle(vault, address(this), amt0, false); + key.currency0.take(vault, address(this), amt0, true); + + key.currency1.settle(vault, address(this), amt1, false); + key.currency1.take(vault, address(this), amt1, true); + } else if (keccak256(action) == keccak256("burn")) { + (uint256 amt0, uint256 amt1) = abi.decode(rawCallbackData, (uint256, uint256)); + + // take token from the vault and burn VaultToken + key.currency0.take(vault, address(this), amt0, false); + key.currency0.settle(vault, address(this), amt0, true); + + key.currency1.take(vault, address(this), amt1, false); + key.currency1.settle(vault, address(this), amt1, true); + } + } + + /// @dev 1:1 swap + function beforeSwap(address, PoolKey calldata key, ICLPoolManager.SwapParams calldata param, bytes calldata) + external + override + returns (bytes4, BeforeSwapDelta, uint24) + { + (Currency inputCurrency, Currency outputCurrency, uint256 amount) = + _getInputOutputAndAmount(key, param.zeroForOne, param.amountSpecified); + + // 1. Take input currency and amount + inputCurrency.take(vault, address(this), amount, true); + + // 2. Give output currency and amount achieving a 1:1 swap + outputCurrency.settle(vault, address(this), amount, true); + + BeforeSwapDelta hookDelta = toBeforeSwapDelta(-int128(param.amountSpecified), int128(param.amountSpecified)); + return (this.beforeSwap.selector, hookDelta, 0); + } + + /// @notice Get input, output currencies and amount from swap params + function _getInputOutputAndAmount(PoolKey calldata _key, bool zeroForOne, int256 amountSpecified) + internal + pure + returns (Currency input, Currency output, uint256 amount) + { + (input, output) = zeroForOne ? (_key.currency0, _key.currency1) : (_key.currency1, _key.currency0); + + amount = amountSpecified < 0 ? uint256(-amountSpecified) : uint256(amountSpecified); + } +} diff --git a/test/pool-cl/helpers/CLMintBurnFeeHook.sol b/test/pool-cl/helpers/CLMintBurnFeeHook.sol new file mode 100644 index 00000000..0cbf67b3 --- /dev/null +++ b/test/pool-cl/helpers/CLMintBurnFeeHook.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {IVault} from "../../../src/interfaces/IVault.sol"; +import {Hooks} from "../../../src/libraries/Hooks.sol"; +import {ICLPoolManager} from "../../../src/pool-cl/interfaces/ICLPoolManager.sol"; +import {PoolKey} from "../../../src/types/PoolKey.sol"; +import {Currency} from "../../../src/types/Currency.sol"; +import {CurrencySettlement} from "../../helpers/CurrencySettlement.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {toBalanceDelta, BalanceDelta, BalanceDeltaLibrary} from "../../../src/types/BalanceDelta.sol"; +import {BaseCLTestHook} from "./BaseCLTestHook.sol"; +import {BeforeSwapDelta, BeforeSwapDeltaLibrary, toBeforeSwapDelta} from "../../../src/types/BeforeSwapDelta.sol"; +import {CurrencySettlement} from "../../helpers/CurrencySettlement.sol"; + +contract CLMintBurnFeeHook is BaseCLTestHook { + error InvalidAction(); + + using CurrencySettlement for Currency; + using Hooks for bytes32; + + IVault public immutable vault; + ICLPoolManager public immutable poolManager; + + constructor(IVault _vault, ICLPoolManager _poolManager) { + vault = _vault; + poolManager = _poolManager; + } + + function getHooksRegistrationBitmap() external pure override returns (uint16) { + return _hooksRegistrationBitmapFrom( + Permissions({ + beforeInitialize: false, + afterInitialize: false, + beforeAddLiquidity: false, + afterAddLiquidity: true, + beforeRemoveLiquidity: false, + afterRemoveLiquidity: true, + beforeSwap: false, + afterSwap: false, + beforeDonate: false, + afterDonate: false, + befreSwapReturnsDelta: false, + afterSwapReturnsDelta: false, + afterAddLiquidityReturnsDelta: true, + afterRemoveLiquidityReturnsDelta: true + }) + ); + } + + /// @dev take 2x of the mint amount as fee + /// meant for https://github.com/pancakeswap/pancake-v4-core/pull/203 to ensure reserveOfApp underflow won't happen + function afterAddLiquidity( + address, + PoolKey calldata key, + ICLPoolManager.ModifyLiquidityParams calldata, + BalanceDelta delta, // ignore fee delta for this case + BalanceDelta, + bytes calldata + ) external override returns (bytes4, BalanceDelta) { + // take fee from mint + int128 amt0Fee; + if (delta.amount0() < 0) { + amt0Fee = (-delta.amount0()) * 2; + key.currency0.take(vault, address(this), uint128(amt0Fee), true); + } + int128 amt1Fee = 0; + if (delta.amount1() < 0) { + amt1Fee = (-delta.amount1()) * 2; + key.currency1.take(vault, address(this), uint128(amt1Fee), true); + } + + BalanceDelta hookDelta = toBalanceDelta(amt0Fee, amt1Fee); + return (this.afterAddLiquidity.selector, hookDelta); + } + + /// @dev take 4x the burn amount as fee + /// meant for https://github.com/pancakeswap/pancake-v4-core/pull/203 to ensure reserveOfApp underflow won't happen + function afterRemoveLiquidity( + address, + PoolKey calldata key, + ICLPoolManager.ModifyLiquidityParams calldata, + BalanceDelta delta, + BalanceDelta, // ignore fee delta for this case + bytes calldata + ) external override returns (bytes4, BalanceDelta) { + int128 amt0Fee; + if (delta.amount0() > 0) { + amt0Fee = (delta.amount0()) * 4; + key.currency0.take(vault, address(this), uint128(amt0Fee), true); + } + int128 amt1Fee = 0; + if (delta.amount1() > 0) { + amt1Fee = (delta.amount1()) * 4; + key.currency1.take(vault, address(this), uint128(amt1Fee), true); + } + + BalanceDelta hookDelta = toBalanceDelta(amt0Fee, amt1Fee); + return (this.afterRemoveLiquidity.selector, hookDelta); + } +} diff --git a/test/vault/FakeHook.sol b/test/vault/FakeHook.sol new file mode 100644 index 00000000..a0392f02 --- /dev/null +++ b/test/vault/FakeHook.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {IVault} from "../../src/interfaces/IVault.sol"; +import {BalanceDelta, toBalanceDelta} from "../../src/types/BalanceDelta.sol"; +import {CurrencySettlement} from "../helpers/CurrencySettlement.sol"; +import {Currency} from "../../src/types/Currency.sol"; + +contract FakeHook { + using CurrencySettlement for Currency; + + IVault public vault; + + constructor(IVault _vault) { + vault = _vault; + } + + function take(Currency currency, uint256 amount, bool claims) public { + currency.take(vault, address(this), amount, claims); + } + + function settle(Currency currency, uint256 amount, bool burn) public { + currency.settle(vault, address(this), amount, burn); + } +} diff --git a/test/vault/FakePoolManager.sol b/test/vault/FakePoolManager.sol index ce515ae7..d286476b 100644 --- a/test/vault/FakePoolManager.sol +++ b/test/vault/FakePoolManager.sol @@ -20,5 +20,22 @@ contract FakePoolManager is IPoolManager { vault.accountAppBalanceDelta(poolKey.currency0, poolKey.currency1, toBalanceDelta(delta0, delta1), msg.sender); } + function mockAccountingWithHookDelta( + PoolKey calldata poolKey, + int128 delta0, + int128 delta1, + int128 hookDelta0, + int128 hookDelta1 + ) external { + vault.accountAppBalanceDelta( + poolKey.currency0, + poolKey.currency1, + toBalanceDelta(delta0, delta1), + msg.sender, + toBalanceDelta(hookDelta0, hookDelta1), + address(poolKey.hooks) + ); + } + function updateDynamicLPFee(PoolKey memory key, uint24 newDynamicSwapFee) external override {} } diff --git a/test/vault/Vault.t.sol b/test/vault/Vault.t.sol index 6a573de2..62eb3950 100644 --- a/test/vault/Vault.t.sol +++ b/test/vault/Vault.t.sol @@ -13,6 +13,7 @@ import {Currency, CurrencyLibrary} from "../../src/types/Currency.sol"; import {PoolKey} from "../../src/types/PoolKey.sol"; import {IVault} from "../../src/interfaces/IVault.sol"; import {FakePoolManager} from "./FakePoolManager.sol"; +import {FakeHook} from "./FakeHook.sol"; import {IHooks} from "../../src/interfaces/IHooks.sol"; import {NoIsolate} from "../helpers/NoIsolate.sol"; import {CurrencySettlement} from "../helpers/CurrencySettlement.sol"; @@ -34,6 +35,10 @@ contract VaultTest is Test, NoIsolate, GasSnapshot, TokenFixture { PoolKey public poolKey1; PoolKey public poolKey2; + FakeHook public fakeHook1; + + MockERC20 token0; + MockERC20 token1; function setUp() public { vault = new Vault(); @@ -46,11 +51,17 @@ contract VaultTest is Test, NoIsolate, GasSnapshot, TokenFixture { vault.registerApp(address(poolManager2)); initializeTokens(); + token0 = MockERC20(Currency.unwrap(currency0)); + token1 = MockERC20(Currency.unwrap(currency1)); + + fakeHook1 = new FakeHook(vault); + token0.mint(address(fakeHook1), 1000 ether); + token1.mint(address(fakeHook1), 1000 ether); poolKey1 = PoolKey({ currency0: currency0, currency1: currency1, - hooks: IHooks(address(0)), + hooks: IHooks(address(fakeHook1)), poolManager: poolManager1, fee: 0, parameters: 0x00 @@ -100,6 +111,16 @@ contract VaultTest is Test, NoIsolate, GasSnapshot, TokenFixture { unRegPoolManager.mockAccounting(key, -10 ether, -10 ether); } + function testAccountPoolBalanceDeltaWithHookDeltaFromUnregistedPoolManager() public { + vault.lock(abi.encodeCall(VaultTest._testAccountPoolBalanceDeltaWithHookDeltaFromUnregistedPoolManager, ())); + } + + function _testAccountPoolBalanceDeltaWithHookDeltaFromUnregistedPoolManager() external { + PoolKey memory key = PoolKey(currency0, currency1, IHooks(makeAddr("hook")), unRegPoolManager, 0x0, 0x0); + vm.expectRevert(IVault.AppUnregistered.selector); + unRegPoolManager.mockAccountingWithHookDelta(key, -10 ether, -10 ether, 10 ether, 10 ether); + } + function testAccountPoolBalanceDeltaFromArbitraryAddr() public { vault.lock(abi.encodeCall(VaultTest._testAccountPoolBalanceDeltaFromArbitraryAddr, ())); } @@ -125,6 +146,10 @@ contract VaultTest is Test, NoIsolate, GasSnapshot, TokenFixture { vm.expectRevert(abi.encodeWithSelector(IVault.NoLocker.selector)); vm.prank(address(poolManager1)); vault.accountAppBalanceDelta(key.currency0, key.currency1, delta, address(this)); + + vm.expectRevert(abi.encodeWithSelector(IVault.NoLocker.selector)); + vm.prank(address(poolManager1)); + vault.accountAppBalanceDelta(key.currency0, key.currency1, delta, address(this), delta, makeAddr("hook")); } function testLockNotSettledWithoutPayment() public { @@ -136,6 +161,15 @@ contract VaultTest is Test, NoIsolate, GasSnapshot, TokenFixture { poolManager1.mockAccounting(poolKey1, -10 ether, -10 ether); } + function testLockNotSettledWithoutPayment_HookDelta() public { + vm.expectRevert(IVault.CurrencyNotSettled.selector); + vault.lock(abi.encodeCall(VaultTest._testLockNotSettledWithoutPayment_HookDelta, ())); + } + + function _testLockNotSettledWithoutPayment_HookDelta() external { + poolManager1.mockAccountingWithHookDelta(poolKey1, -10 ether, -10 ether, 0 ether, 0 ether); + } + function testLockNotSettledWithoutFullyPayment() public noIsolate { vm.expectRevert(IVault.CurrencyNotSettled.selector); vault.lock(abi.encodeCall(VaultTest._testLockNotSettledWithoutFullyPayment, ())); @@ -151,6 +185,22 @@ contract VaultTest is Test, NoIsolate, GasSnapshot, TokenFixture { vault.settle(); } + function testLockNotSettledWithoutFullyPayment_HookDelta() public noIsolate { + vm.expectRevert(IVault.CurrencyNotSettled.selector); + vault.lock(abi.encodeCall(VaultTest._testLockNotSettledWithoutFullyPayment, ())); + } + + function _testLockNotSettledWithoutFullyPayment_HookDelta() external { + poolManager1.mockAccountingWithHookDelta(poolKey1, -10 ether, -10 ether, -1 ether, 0 ether); + + currency0.settle(vault, address(this), 10 ether, false); + currency1.settle(vault, address(this), 10 ether, false); + + // didnt actually transfer the currency for hook's 1 ether + vault.sync(currency1); + vault.settle(); + } + function testLockNotSettledAsPayTooMuch() public noIsolate { vm.expectRevert(IVault.CurrencyNotSettled.selector); vault.lock(abi.encodeCall(VaultTest._testLockNotSettledAsPayTooMuch, ())); @@ -162,6 +212,20 @@ contract VaultTest is Test, NoIsolate, GasSnapshot, TokenFixture { currency1.settle(vault, address(this), 12 ether, false); } + function testLockNotSettledAsPayTooMuch_HookDelta() public noIsolate { + vm.expectRevert(IVault.CurrencyNotSettled.selector); + vault.lock(abi.encodeCall(VaultTest._testLockNotSettledAsPayTooMuch_HookDelta, ())); + } + + function _testLockNotSettledAsPayTooMuch_HookDelta() external { + poolManager1.mockAccountingWithHookDelta(poolKey1, -10 ether, -10 ether, -1 ether, 0 ether); + currency0.settle(vault, address(this), 10 ether, false); + currency1.settle(vault, address(this), 10 ether, false); + + // hook overpay + fakeHook1.settle(currency0, 2 ether, false); + } + function testNotCorrectPoolManager() public { // DOUBLE-CHECK: // The tx will complete without revert, is this going to be a problem ? @@ -731,6 +795,151 @@ contract VaultTest is Test, NoIsolate, GasSnapshot, TokenFixture { snapEnd(); } + function testLockSettledWhenAddLiquidity_HookDelta() public noIsolate { + vault.lock(abi.encodeCall(VaultTest._testLockSettledWhenAddLiquidity_HookDelta, ())); + } + + function _testLockSettledWhenAddLiquidity_HookDelta() external { + // adding enough liquidity before swap + currency0.settle(vault, address(this), 10 ether, false); + currency1.settle(vault, address(this), 10 ether, false); + + // hook provide some incentive by giving 2 eth worth of token0 + fakeHook1.settle(currency0, 2 ether, false); + + poolManager1.mockAccountingWithHookDelta(poolKey1, -10 ether, -10 ether, -2 ether, 0 ether); + + uint256 token0Before = currency0.balanceOfSelf(); + uint256 token1Before = currency1.balanceOfSelf(); + uint256 token0BeforeHook = token0.balanceOf(address(poolKey1.hooks)); + uint256 token1BeforeHook = token1.balanceOf(address(poolKey1.hooks)); + + // swap + poolManager1.mockAccountingWithHookDelta(poolKey1, -3 ether, 3 ether, 1 ether, -1 ether); + currency0.settle(vault, address(this), 3 ether, false); + currency1.take(vault, address(this), 3 ether, false); + + // hook take 1 eth of token0 and give 1 eth of token1 + fakeHook1.take(currency0, 1 ether, false); + fakeHook1.settle(currency1, 1 ether, false); + + // user paid 3 token0 and received 3 token1 + assertEq(token0Before - currency0.balanceOfSelf(), 3 ether); + assertEq(currency1.balanceOfSelf() - token1Before, 3 ether); + + // hook take 1 ether of token0 and given 1 ether of token1 incentives + assertEq(token0.balanceOf(address(poolKey1.hooks)) - token0BeforeHook, 1 ether); + assertEq(token1BeforeHook - token1.balanceOf(address(poolKey1.hooks)), 1 ether); + + // token0: add 10 ether, + 3 ether in swap + hook give 2 eth in liquidity - hook take 1 eth in swap + assertEq(IERC20(Currency.unwrap(currency0)).balanceOf(address(vault)), 14 ether); + // token1: add 10 ether, - 3 ether in swap + hook give 1 eth + assertEq(IERC20(Currency.unwrap(currency1)).balanceOf(address(vault)), 8 ether); + assertEq(vault.reservesOfApp(address(poolKey1.poolManager), currency0), 14 ether); + assertEq(vault.reservesOfApp(address(poolKey1.poolManager), currency1), 8 ether); + } + + function testFuzzAccountBalanceDeltaWithHookDelta_OverwriteCurve(uint256 amt0, uint256 amt1) public noIsolate { + amt0 = bound(amt0, 0, 10 ether); + amt1 = bound(amt1, 0, 10 ether); + vault.lock(abi.encodeCall(VaultTest._testFuzzAccountBalanceDeltaWithHookDelta_OverwriteCurve, (amt0, amt1))); + } + + /// @dev assume stableSwap curve 1:1 wihere hookDelta + delta = 0 + function _testFuzzAccountBalanceDeltaWithHookDelta_OverwriteCurve(uint256 amt0, uint256 amt1) external { + int128 amt0Int128 = int128(uint128(amt0)); + int128 amt1Int128 = int128(uint128(amt1)); + + poolManager1.mockAccountingWithHookDelta(poolKey1, -amt0Int128, amt1Int128, amt0Int128, -amt1Int128); + currency0.settle(vault, address(this), amt0, false); + currency1.take(vault, address(this), amt1, true); // mint VaultToken + + fakeHook1.take(currency0, amt0, true); // mint VaultToken + fakeHook1.settle(currency1, amt1, false); + + // reserveOfApp should be 0 as delta/hookDelta balnce out each other + assertEq(vault.reservesOfApp(address(poolKey1.poolManager), currency0), 0); + assertEq(vault.reservesOfApp(address(poolKey1.poolManager), currency1), 0); + } + + /// @dev assume add liqudiity where user add liquidity and hook also take a fee (half of liquidity) + function testFuzzAccountBalanceDeltaWithHookDelta_AddLiquidityHookFee(uint256 amt0, uint256 amt1) + public + noIsolate + { + amt0 = bound(amt0, 0, 10 ether); + amt1 = bound(amt1, 0, 10 ether); + vault.lock( + abi.encodeCall(VaultTest._testFuzzAccountBalanceDeltaWithHookDelta_AddLiquidityHookFee, (amt0, amt1)) + ); + } + + function _testFuzzAccountBalanceDeltaWithHookDelta_AddLiquidityHookFee(uint256 amt0, uint256 amt1) external { + int128 amt0Int128 = int128(uint128(amt0)); + int128 amt1Int128 = int128(uint128(amt1)); + + poolManager1.mockAccountingWithHookDelta(poolKey1, -amt0Int128, -amt1Int128, amt0Int128 / 2, amt1Int128 / 2); + assertEq(vault.currencyDelta(msg.sender, currency0), -amt0Int128); + assertEq(vault.currencyDelta(msg.sender, currency1), -amt1Int128); + assertEq(vault.currencyDelta(address(poolKey1.hooks), currency0), amt0Int128 / 2); + assertEq(vault.currencyDelta(address(poolKey1.hooks), currency1), amt1Int128 / 2); + + // user add liquidity and settle with vault + currency0.settle(vault, address(this), amt0, false); + currency1.settle(vault, address(this), amt1, false); + + // hook take a fee + uint256 fee0 = amt0 / 2; + uint256 fee1 = amt1 / 2; + fakeHook1.take(currency0, fee0, false); + fakeHook1.take(currency1, fee1, false); + + assertEq(vault.reservesOfApp(address(poolKey1.poolManager), currency0), amt0 - fee0); + assertEq(vault.reservesOfApp(address(poolKey1.poolManager), currency1), amt1 - fee1); + } + + /// @dev assume add liqudiity where user remove liquidity and hook also take a fee (half of liquidity) + function testFuzzAccountBalanceDeltaWithHookDelta_RemoveLiquidityHookFee(uint256 amt0, uint256 amt1) + public + noIsolate + { + amt0 = bound(amt0, 0, 10 ether); + amt1 = bound(amt1, 0, 10 ether); + vault.lock( + abi.encodeCall(VaultTest._testFuzzAccountBalanceDeltaWithHookDelta_RemoveLiquidityHookFee, (amt0, amt1)) + ); + } + + function _testFuzzAccountBalanceDeltaWithHookDelta_RemoveLiquidityHookFee(uint256 amt0, uint256 amt1) external { + int128 amt0Int128 = int128(uint128(amt0)); + int128 amt1Int128 = int128(uint128(amt1)); + + /// Assume some liquidity added before. + poolManager1.mockAccountingWithHookDelta(poolKey1, -(amt0Int128 * 2), -(amt1Int128 * 2), 0, 0); + currency0.settle(vault, address(this), amt0 * 2, false); + currency1.settle(vault, address(this), amt1 * 2, false); + uint256 reserve0Before = vault.reservesOfApp(address(poolKey1.poolManager), currency0); + uint256 reserve1Before = vault.reservesOfApp(address(poolKey1.poolManager), currency1); + + // if no liqudiity added above, reserveOfApp will underflow here, as taking more out of app + // in the real world, this will not happen as user will need to pay the hook fee + poolManager1.mockAccountingWithHookDelta(poolKey1, amt0Int128, amt1Int128, amt0Int128 / 2, amt1Int128 / 2); + + // user remove liquidity + currency0.take(vault, address(this), amt0, false); + currency1.take(vault, address(this), amt1, false); + + // hook take a fee + uint256 fee0 = amt0 / 2; + uint256 fee1 = amt1 / 2; + fakeHook1.take(currency0, fee0, false); + fakeHook1.take(currency1, fee1, false); + + // reserveOfApp should be 0 as delta/hookDelta balnce out each other + assertEq(vault.reservesOfApp(address(poolKey1.poolManager), currency0), reserve0Before - amt0 - fee0); + assertEq(vault.reservesOfApp(address(poolKey1.poolManager), currency1), reserve1Before - amt1 - fee1); + } + function lockAcquired(bytes calldata data) external returns (bytes memory result) { // forward the call and bubble up the error if revert bool success;