diff --git a/.forge-snapshots/addLiquidity CA fee.snap b/.forge-snapshots/addLiquidity CA fee.snap index 00c38545c..4fb0138e9 100644 --- a/.forge-snapshots/addLiquidity CA fee.snap +++ b/.forge-snapshots/addLiquidity CA fee.snap @@ -1 +1 @@ -331831 \ No newline at end of file +331641 \ No newline at end of file diff --git a/.forge-snapshots/addLiquidity with empty hook.snap b/.forge-snapshots/addLiquidity with empty hook.snap index b7a0159fe..43ee3ebd8 100644 --- a/.forge-snapshots/addLiquidity with empty hook.snap +++ b/.forge-snapshots/addLiquidity with empty hook.snap @@ -1 +1 @@ -286606 \ No newline at end of file +286634 \ No newline at end of file diff --git a/.forge-snapshots/poolManager bytecode size.snap b/.forge-snapshots/poolManager bytecode size.snap index 7c6b7bfe5..92cecb45e 100644 --- a/.forge-snapshots/poolManager bytecode size.snap +++ b/.forge-snapshots/poolManager bytecode size.snap @@ -1 +1 @@ -22099 \ No newline at end of file +22121 \ No newline at end of file diff --git a/.forge-snapshots/removeLiquidity CA fee.snap b/.forge-snapshots/removeLiquidity CA fee.snap index 65e625418..cbba100bf 100644 --- a/.forge-snapshots/removeLiquidity CA fee.snap +++ b/.forge-snapshots/removeLiquidity CA fee.snap @@ -1 +1 @@ -187273 \ No newline at end of file +187083 \ No newline at end of file diff --git a/.forge-snapshots/simple swap with native.snap b/.forge-snapshots/simple swap with native.snap index 28df0d4b6..2ad268097 100644 --- a/.forge-snapshots/simple swap with native.snap +++ b/.forge-snapshots/simple swap with native.snap @@ -1 +1 @@ -117304 \ No newline at end of file +117431 \ No newline at end of file diff --git a/.forge-snapshots/simple swap.snap b/.forge-snapshots/simple swap.snap index 8de1894a3..37bd6d94c 100644 --- a/.forge-snapshots/simple swap.snap +++ b/.forge-snapshots/simple swap.snap @@ -1 +1 @@ -132465 \ No newline at end of file +132592 \ No newline at end of file diff --git a/.forge-snapshots/swap CA custom curve + swap noop.snap b/.forge-snapshots/swap CA custom curve + swap noop.snap index 7e712bbc3..b90101cb9 100644 --- a/.forge-snapshots/swap CA custom curve + swap noop.snap +++ b/.forge-snapshots/swap CA custom curve + swap noop.snap @@ -1 +1 @@ -134799 \ No newline at end of file +134711 \ No newline at end of file diff --git a/.forge-snapshots/swap CA fee on unspecified.snap b/.forge-snapshots/swap CA fee on unspecified.snap index 6ff32412a..b4673db0a 100644 --- a/.forge-snapshots/swap CA fee on unspecified.snap +++ b/.forge-snapshots/swap CA fee on unspecified.snap @@ -1 +1 @@ -183793 \ No newline at end of file +183730 \ No newline at end of file diff --git a/.forge-snapshots/swap against liquidity with native token.snap b/.forge-snapshots/swap against liquidity with native token.snap index 5c66927fb..976368665 100644 --- a/.forge-snapshots/swap against liquidity with native token.snap +++ b/.forge-snapshots/swap against liquidity with native token.snap @@ -1 +1 @@ -112929 \ No newline at end of file +113056 \ No newline at end of file diff --git a/.forge-snapshots/swap against liquidity.snap b/.forge-snapshots/swap against liquidity.snap index b3ea785f4..c7dc9c39b 100644 --- a/.forge-snapshots/swap against liquidity.snap +++ b/.forge-snapshots/swap against liquidity.snap @@ -1 +1 @@ -124272 \ No newline at end of file +124399 \ No newline at end of file diff --git a/.forge-snapshots/swap burn 6909 for input.snap b/.forge-snapshots/swap burn 6909 for input.snap index 082779ca5..dd0a4cda7 100644 --- a/.forge-snapshots/swap burn 6909 for input.snap +++ b/.forge-snapshots/swap burn 6909 for input.snap @@ -1 +1 @@ -136325 \ No newline at end of file +136452 \ No newline at end of file diff --git a/.forge-snapshots/swap burn native 6909 for input.snap b/.forge-snapshots/swap burn native 6909 for input.snap index d84033810..a3b40bd26 100644 --- a/.forge-snapshots/swap burn native 6909 for input.snap +++ b/.forge-snapshots/swap burn native 6909 for input.snap @@ -1 +1 @@ -125453 \ No newline at end of file +125580 \ No newline at end of file diff --git a/.forge-snapshots/swap mint native output as 6909.snap b/.forge-snapshots/swap mint native output as 6909.snap index 0f96f3667..eae2906eb 100644 --- a/.forge-snapshots/swap mint native output as 6909.snap +++ b/.forge-snapshots/swap mint native output as 6909.snap @@ -1 +1 @@ -147514 \ No newline at end of file +147641 \ No newline at end of file diff --git a/.forge-snapshots/swap mint output as 6909.snap b/.forge-snapshots/swap mint output as 6909.snap index b11435347..9b81ca131 100644 --- a/.forge-snapshots/swap mint output as 6909.snap +++ b/.forge-snapshots/swap mint output as 6909.snap @@ -1 +1 @@ -164319 \ No newline at end of file +164446 \ No newline at end of file diff --git a/.forge-snapshots/swap skips hook call if hook is caller.snap b/.forge-snapshots/swap skips hook call if hook is caller.snap index 82a6702da..d8c43d6d2 100644 --- a/.forge-snapshots/swap skips hook call if hook is caller.snap +++ b/.forge-snapshots/swap skips hook call if hook is caller.snap @@ -1 +1 @@ -223093 \ No newline at end of file +223312 \ No newline at end of file diff --git a/.forge-snapshots/swap with dynamic fee.snap b/.forge-snapshots/swap with dynamic fee.snap index 67215f470..fc2465906 100644 --- a/.forge-snapshots/swap with dynamic fee.snap +++ b/.forge-snapshots/swap with dynamic fee.snap @@ -1 +1 @@ -148520 \ No newline at end of file +148647 \ No newline at end of file diff --git a/.forge-snapshots/swap with hooks.snap b/.forge-snapshots/swap with hooks.snap index c7c0a3673..ca9e7dbe2 100644 --- a/.forge-snapshots/swap with hooks.snap +++ b/.forge-snapshots/swap with hooks.snap @@ -1 +1 @@ -124284 \ No newline at end of file +124411 \ No newline at end of file diff --git a/.forge-snapshots/swap with lp fee and protocol fee.snap b/.forge-snapshots/swap with lp fee and protocol fee.snap index bfe68b518..c0266ee6c 100644 --- a/.forge-snapshots/swap with lp fee and protocol fee.snap +++ b/.forge-snapshots/swap with lp fee and protocol fee.snap @@ -1 +1 @@ -180775 \ No newline at end of file +180884 \ No newline at end of file diff --git a/.forge-snapshots/swap with return dynamic fee.snap b/.forge-snapshots/swap with return dynamic fee.snap new file mode 100644 index 000000000..b5ec628a7 --- /dev/null +++ b/.forge-snapshots/swap with return dynamic fee.snap @@ -0,0 +1 @@ +156517 \ No newline at end of file diff --git a/.forge-snapshots/update dynamic fee in before swap.snap b/.forge-snapshots/update dynamic fee in before swap.snap index 93a958ceb..7453e1ef0 100644 --- a/.forge-snapshots/update dynamic fee in before swap.snap +++ b/.forge-snapshots/update dynamic fee in before swap.snap @@ -1 +1 @@ -159006 \ No newline at end of file +159121 \ No newline at end of file diff --git a/src/PoolManager.sol b/src/PoolManager.sol index 131e5a451..52e6969ab 100644 --- a/src/PoolManager.sol +++ b/src/PoolManager.sol @@ -227,19 +227,25 @@ contract PoolManager is IPoolManager, ProtocolFees, NoDelegateCall, ERC6909Claim PoolId id = key.toId(); _checkPoolInitialized(id); - (int256 amountToSwap, BeforeSwapDelta beforeSwapDelta) = key.hooks.beforeSwap(key, params, hookData); - - // execute swap, account protocol fees, and emit swap event - swapDelta = _swap( - id, - Pool.SwapParams({ - tickSpacing: key.tickSpacing, - zeroForOne: params.zeroForOne, - amountSpecified: amountToSwap, - sqrtPriceLimitX96: params.sqrtPriceLimitX96 - }), - params.zeroForOne ? key.currency0 : key.currency1 // input token - ); + BeforeSwapDelta beforeSwapDelta; + { + int256 amountToSwap; + uint24 lpFeeOverride; + (amountToSwap, beforeSwapDelta, lpFeeOverride) = key.hooks.beforeSwap(key, params, hookData); + + // execute swap, account protocol fees, and emit swap event + swapDelta = _swap( + id, + Pool.SwapParams({ + tickSpacing: key.tickSpacing, + zeroForOne: params.zeroForOne, + amountSpecified: amountToSwap, + sqrtPriceLimitX96: params.sqrtPriceLimitX96, + lpFeeOverride: lpFeeOverride + }), + params.zeroForOne ? key.currency0 : key.currency1 // input token + ); + } BalanceDelta hookDelta; (swapDelta, hookDelta) = key.hooks.afterSwap(key, params, swapDelta, hookData, beforeSwapDelta); diff --git a/src/ProtocolFees.sol b/src/ProtocolFees.sol index 8948a20d5..0c4d91af9 100644 --- a/src/ProtocolFees.sol +++ b/src/ProtocolFees.sol @@ -35,7 +35,7 @@ abstract contract ProtocolFees is IProtocolFees, Owned { /// @inheritdoc IProtocolFees function setProtocolFee(PoolKey memory key, uint24 newProtocolFee) external { if (msg.sender != address(protocolFeeController)) revert InvalidCaller(); - if (!newProtocolFee.validate()) revert InvalidProtocolFee(); + if (!newProtocolFee.isValidProtocolFee()) revert InvalidProtocolFee(); PoolId id = key.toId(); _getPool(id).setProtocolFee(newProtocolFee); emit ProtocolFeeUpdated(id, newProtocolFee); @@ -77,7 +77,7 @@ abstract contract ProtocolFees is IProtocolFees, Owned { returnData := mload(add(_data, 0x20)) } // Ensure return data does not overflow a uint24 and that the underlying fees are within bounds. - (success, protocolFees) = (returnData == uint24(returnData)) && uint24(returnData).validate() + (success, protocolFees) = (returnData == uint24(returnData)) && uint24(returnData).isValidProtocolFee() ? (true, uint24(returnData)) : (false, 0); } diff --git a/src/interfaces/IHooks.sol b/src/interfaces/IHooks.sol index a3b3de193..4ceb13c8a 100644 --- a/src/interfaces/IHooks.sol +++ b/src/interfaces/IHooks.sol @@ -99,12 +99,13 @@ interface IHooks { /// @param hookData Arbitrary data handed into the PoolManager by the swapper to be be passed on to the hook /// @return bytes4 The function selector for the hook /// @return BeforeSwapDelta The hook's delta in specified and unspecified currencies. Positive: the hook is owed/took currency, negative: the hook owes/sent currency + /// @return uint24 Optionally override the lp fee, only used if three conditions are met: 1) the Pool has a dynamic fee, 2) the value's leading bit is set to 1 (24th bit, 0x800000), 3) the value is less than or equal to the maximum fee (1 million) function beforeSwap( address sender, PoolKey calldata key, IPoolManager.SwapParams calldata params, bytes calldata hookData - ) external returns (bytes4, BeforeSwapDelta); + ) external returns (bytes4, BeforeSwapDelta, uint24); /// @notice The hook called after a swap /// @param sender The initial msg.sender for the swap call diff --git a/src/libraries/Hooks.sol b/src/libraries/Hooks.sol index 5c6d9dff2..31bb83d55 100644 --- a/src/libraries/Hooks.sol +++ b/src/libraries/Hooks.sol @@ -8,6 +8,7 @@ import {LPFeeLibrary} from "./LPFeeLibrary.sol"; import {BalanceDelta, toBalanceDelta, BalanceDeltaLibrary} from "../types/BalanceDelta.sol"; import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "../types/BeforeSwapDelta.sol"; import {IPoolManager} from "../interfaces/IPoolManager.sol"; +import {ParseBytes} from "../libraries/ParseBytes.sol"; /// @notice V4 decides whether to invoke specific hooks by inspecting the leading bits of the address that /// the hooks contract is deployed to. @@ -18,6 +19,7 @@ library Hooks { using Hooks for IHooks; using SafeCast for int256; using BeforeSwapDeltaLibrary for BeforeSwapDelta; + using ParseBytes for bytes; uint256 internal constant BEFORE_INITIALIZE_FLAG = 1 << 159; uint256 internal constant AFTER_INITIALIZE_FLAG = 1 << 158; @@ -125,14 +127,8 @@ library Hooks { (success, result) = address(self).call(data); if (!success) _revert(result); - bytes4 expectedSelector; - bytes4 selector; - assembly { - expectedSelector := mload(add(data, 0x20)) - selector := mload(add(result, 0x20)) - } - - if (selector != expectedSelector) revert InvalidHookResponse(); + // Check expected selector and returned selector match. + if (result.parseSelector() != data.parseSelector()) revert InvalidHookResponse(); } /// @notice performs a hook call using the given calldata on the given hook @@ -145,7 +141,7 @@ library Hooks { // If this hook wasnt meant to return something, default to 0 delta if (!parseReturn) return 0; - (, delta) = abi.decode(result, (bytes4, int256)); + return result.parseReturnDelta(); } /// @notice modifier to prevent calling a hook if they initiated the action @@ -236,22 +232,22 @@ library Hooks { /// @notice calls beforeSwap hook if permissioned and validates return value function beforeSwap(IHooks self, PoolKey memory key, IPoolManager.SwapParams memory params, bytes calldata hookData) internal - returns (int256 amountToSwap, BeforeSwapDelta hookReturn) + returns (int256 amountToSwap, BeforeSwapDelta hookReturn, uint24 lpFeeOverride) { amountToSwap = params.amountSpecified; - if (msg.sender == address(self)) return (amountToSwap, BeforeSwapDeltaLibrary.ZERO_DELTA); + if (msg.sender == address(self)) return (amountToSwap, BeforeSwapDeltaLibrary.ZERO_DELTA, lpFeeOverride); if (self.hasPermission(BEFORE_SWAP_FLAG)) { - bool canReturnDelta = self.hasPermission(BEFORE_SWAP_RETURNS_DELTA_FLAG); - hookReturn = BeforeSwapDelta.wrap( - self.callHookWithReturnDelta( - abi.encodeWithSelector(IHooks.beforeSwap.selector, msg.sender, key, params, hookData), - canReturnDelta - ) - ); + bytes memory result = + callHook(self, abi.encodeWithSelector(IHooks.beforeSwap.selector, msg.sender, key, params, hookData)); + + // dynamic fee pools that do not want to override the cache fee, return 0 otherwise they return a valid fee with the override flag + if (key.fee.isDynamicFee()) lpFeeOverride = result.parseFee(); // skip this logic for the case where the hook return is 0 - if (canReturnDelta) { + if (self.hasPermission(BEFORE_SWAP_RETURNS_DELTA_FLAG)) { + hookReturn = BeforeSwapDelta.wrap(result.parseReturnDelta()); + // any return in unspecified is passed to the afterSwap hook for handling int128 hookDeltaSpecified = hookReturn.getSpecifiedDelta(); diff --git a/src/libraries/LPFeeLibrary.sol b/src/libraries/LPFeeLibrary.sol index ac9ab507a..83c7cfe8a 100644 --- a/src/libraries/LPFeeLibrary.sol +++ b/src/libraries/LPFeeLibrary.sol @@ -9,9 +9,16 @@ library LPFeeLibrary { /// @notice Thrown when the static or dynamic fee on a pool exceeds 100%. error FeeTooLarge(); - uint24 public constant STATIC_FEE_MASK = 0x7FFFFF; + uint24 public constant FEE_MASK = 0x7FFFFF; + uint24 public constant OVERRIDE_MASK = 0xBFFFFF; + + // the top bit of the fee in a PoolKey is used to signal if a Pool's LP fee is dynamic uint24 public constant DYNAMIC_FEE_FLAG = 0x800000; + // the second bit of the fee returned by beforeSwap is used to signal if the stored LP fee should be overridden in this swap + // only dynamic-fee pools can return a fee via the beforeSwap hook + uint24 public constant OVERRIDE_FEE_FLAG = 0x400000; + // the lp fee is represented in hundredths of a bip, so the max is 100% uint24 public constant MAX_LP_FEE = 1000000; @@ -19,14 +26,35 @@ library LPFeeLibrary { return self & DYNAMIC_FEE_FLAG != 0; } + function isValid(uint24 self) internal pure returns (bool) { + return self <= MAX_LP_FEE; + } + function validate(uint24 self) internal pure { - if (self > MAX_LP_FEE) revert FeeTooLarge(); + if (!self.isValid()) revert FeeTooLarge(); } function getInitialLPFee(uint24 self) internal pure returns (uint24 lpFee) { // the initial fee for a dynamic fee pool is 0 if (self.isDynamicFee()) return 0; - lpFee = self & STATIC_FEE_MASK; + lpFee = self & FEE_MASK; lpFee.validate(); } + + /// @notice returns true if the fee has the override flag set (top bit of the uint24) + function isOverride(uint24 self) internal pure returns (bool) { + return self & OVERRIDE_FEE_FLAG != 0; + } + + /// @notice returns a fee with the override flag removed + function removeOverrideFlag(uint24 self) internal pure returns (uint24) { + return self & OVERRIDE_MASK; + } + + /// @notice Removes the override flag and validates the fee (reverts if the fee is too large) + function removeOverrideAndValidate(uint24 self) internal pure returns (uint24) { + uint24 fee = self.removeOverrideFlag(); + fee.validate(); + return fee; + } } diff --git a/src/libraries/ParseBytes.sol b/src/libraries/ParseBytes.sol new file mode 100644 index 000000000..2d53b9694 --- /dev/null +++ b/src/libraries/ParseBytes.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.20; + +/// @notice Parses bytes returned from hooks and the byte selector used to check return selectors from hooks. +/// @dev parseSelector also is used to parse the expected selector +/// For parsing hook returns, note that all hooks return either bytes4 or (bytes4, 32-byte-delta) or (bytes4, 32-byte-delta, uint24). +library ParseBytes { + function parseSelector(bytes memory result) internal pure returns (bytes4 selector) { + // equivalent: (selector,) = abi.decode(result, (bytes4, int256)); + assembly { + selector := mload(add(result, 0x20)) + } + } + + function parseFee(bytes memory result) internal pure returns (uint24 lpFee) { + // equivalent: (,, lpFee) = abi.decode(result, (bytes4, int256, uint24)); + assembly { + lpFee := mload(add(result, 0x60)) + } + } + + function parseReturnDelta(bytes memory result) internal pure returns (int256 hookReturn) { + // equivalent: (, hookReturnDelta) = abi.decode(result, (bytes4, int256)); + assembly { + hookReturn := mload(add(result, 0x40)) + } + } +} diff --git a/src/libraries/Pool.sol b/src/libraries/Pool.sol index 567fc3272..c8ed43b3f 100644 --- a/src/libraries/Pool.sol +++ b/src/libraries/Pool.sol @@ -21,6 +21,7 @@ library Pool { using Position for Position.Info; using Pool for State; using ProtocolFeeLibrary for uint24; + using LPFeeLibrary for uint24; /// @notice Thrown when tickLower is not below tickUpper /// @param tickLower The invalid tickLower @@ -294,6 +295,7 @@ library Pool { bool zeroForOne; int256 amountSpecified; uint160 sqrtPriceLimitX96; + uint24 lpFeeOverride; } /// @notice Executes a swap against the state, and returns the amount deltas of the pool @@ -317,8 +319,11 @@ library Pool { state.feeGrowthGlobalX128 = zeroForOne ? self.feeGrowthGlobal0X128 : self.feeGrowthGlobal1X128; state.liquidity = cache.liquidityStart; - swapFee = - cache.protocolFee == 0 ? slot0Start.lpFee : uint24(cache.protocolFee).calculateSwapFee(slot0Start.lpFee); + // if the beforeSwap hook returned a valid fee override, use that as the LP fee, otherwise load from storage + uint24 lpFee = + params.lpFeeOverride.isOverride() ? params.lpFeeOverride.removeOverrideAndValidate() : slot0Start.lpFee; + + swapFee = cache.protocolFee == 0 ? lpFee : uint24(cache.protocolFee).calculateSwapFee(lpFee); bool exactInput = params.amountSpecified < 0; diff --git a/src/libraries/ProtocolFeeLibrary.sol b/src/libraries/ProtocolFeeLibrary.sol index c70755d84..6415a82b3 100644 --- a/src/libraries/ProtocolFeeLibrary.sol +++ b/src/libraries/ProtocolFeeLibrary.sol @@ -18,7 +18,7 @@ library ProtocolFeeLibrary { return uint16(self >> 12); } - function validate(uint24 self) internal pure returns (bool) { + function isValidProtocolFee(uint24 self) internal pure returns (bool) { if (self != 0) { uint16 fee0 = getZeroForOneFee(self); uint16 fee1 = getOneForZeroFee(self); diff --git a/src/test/BaseTestHooks.sol b/src/test/BaseTestHooks.sol index 061887dc2..84aaa535e 100644 --- a/src/test/BaseTestHooks.sol +++ b/src/test/BaseTestHooks.sol @@ -72,7 +72,7 @@ contract BaseTestHooks is IHooks { PoolKey calldata, /* key **/ IPoolManager.SwapParams calldata, /* params **/ bytes calldata /* hookData **/ - ) external virtual returns (bytes4, BeforeSwapDelta) { + ) external virtual returns (bytes4, BeforeSwapDelta, uint24) { revert HookNotImplemented(); } diff --git a/src/test/CustomCurveHook.sol b/src/test/CustomCurveHook.sol index 1135e380d..8f5915dad 100644 --- a/src/test/CustomCurveHook.sol +++ b/src/test/CustomCurveHook.sol @@ -37,7 +37,7 @@ contract CustomCurveHook is BaseTestHooks { PoolKey calldata key, IPoolManager.SwapParams calldata params, bytes calldata /* hookData **/ - ) external override onlyPoolManager returns (bytes4, BeforeSwapDelta) { + ) external override onlyPoolManager returns (bytes4, BeforeSwapDelta, uint24) { (Currency inputCurrency, Currency outputCurrency, uint256 amount) = _getInputOutputAndAmount(key, params); // this "custom curve" is a line, 1-1 @@ -47,7 +47,7 @@ contract CustomCurveHook is BaseTestHooks { // return -amountSpecified as specified to no-op the concentrated liquidity swap BeforeSwapDelta hookDelta = toBeforeSwapDelta(int128(-params.amountSpecified), int128(params.amountSpecified)); - return (IHooks.beforeSwap.selector, hookDelta); + return (IHooks.beforeSwap.selector, hookDelta, 0); } function afterAddLiquidity( diff --git a/src/test/DeltaReturningHook.sol b/src/test/DeltaReturningHook.sol index d8b7e2d9e..82314301e 100644 --- a/src/test/DeltaReturningHook.sol +++ b/src/test/DeltaReturningHook.sol @@ -51,7 +51,7 @@ contract DeltaReturningHook is BaseTestHooks { PoolKey calldata key, IPoolManager.SwapParams calldata params, bytes calldata /* hookData **/ - ) external override onlyPoolManager returns (bytes4, BeforeSwapDelta) { + ) external override onlyPoolManager returns (bytes4, BeforeSwapDelta, uint24) { (Currency specifiedCurrency, Currency unspecifiedCurrency) = _sortCurrencies(key, params); if (deltaSpecified != 0) _settleOrTake(specifiedCurrency, deltaSpecified); @@ -59,7 +59,7 @@ contract DeltaReturningHook is BaseTestHooks { BeforeSwapDelta beforeSwapDelta = toBeforeSwapDelta(deltaSpecified, deltaUnspecifiedBeforeSwap); - return (IHooks.beforeSwap.selector, beforeSwapDelta); + return (IHooks.beforeSwap.selector, beforeSwapDelta, 0); } function afterSwap( diff --git a/src/test/DynamicFeesTestHook.sol b/src/test/DynamicFeesTestHook.sol index f0fc00f0e..cdf2e0a0c 100644 --- a/src/test/DynamicFeesTestHook.sol +++ b/src/test/DynamicFeesTestHook.sol @@ -6,6 +6,7 @@ import {PoolKey} from "../types/PoolKey.sol"; import {IPoolManager} from "../interfaces/IPoolManager.sol"; import {IHooks} from "../interfaces/IHooks.sol"; import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "../types/BeforeSwapDelta.sol"; +import {LPFeeLibrary} from "../libraries/LPFeeLibrary.sol"; contract DynamicFeesTestHook is BaseTestHooks { uint24 internal fee; @@ -31,10 +32,10 @@ contract DynamicFeesTestHook is BaseTestHooks { function beforeSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata, bytes calldata) external override - returns (bytes4, BeforeSwapDelta) + returns (bytes4, BeforeSwapDelta, uint24) { manager.updateDynamicLPFee(key, fee); - return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA); + return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); } function forcePoolFeeUpdate(PoolKey calldata _key, uint24 _fee) external { diff --git a/src/test/DynamicReturnFeeTestHook.sol b/src/test/DynamicReturnFeeTestHook.sol new file mode 100644 index 000000000..9fd91ad5b --- /dev/null +++ b/src/test/DynamicReturnFeeTestHook.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {BaseTestHooks} from "./BaseTestHooks.sol"; +import {PoolKey} from "../types/PoolKey.sol"; +import {IPoolManager} from "../interfaces/IPoolManager.sol"; +import {IHooks} from "../interfaces/IHooks.sol"; +import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "../types/BeforeSwapDelta.sol"; +import {LPFeeLibrary} from "../libraries/LPFeeLibrary.sol"; + +contract DynamicReturnFeeTestHook is BaseTestHooks { + using LPFeeLibrary for uint24; + + uint24 internal fee; + IPoolManager manager; + + function setManager(IPoolManager _manager) external { + manager = _manager; + } + + function setFee(uint24 _fee) external { + fee = _fee; + } + + function beforeSwap(address, PoolKey calldata, IPoolManager.SwapParams calldata, bytes calldata) + external + override + returns (bytes4, BeforeSwapDelta, uint24) + { + // attach the fee flag to `fee` to enable overriding the pool's stored fee + return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, fee | LPFeeLibrary.OVERRIDE_FEE_FLAG); + } + + function forcePoolFeeUpdate(PoolKey calldata _key, uint24 _fee) external { + manager.updateDynamicLPFee(_key, _fee); + } +} diff --git a/src/test/EmptyTestHooks.sol b/src/test/EmptyTestHooks.sol index 9f49042e2..57f2e128e 100644 --- a/src/test/EmptyTestHooks.sol +++ b/src/test/EmptyTestHooks.sol @@ -92,9 +92,9 @@ contract EmptyTestHooks is IHooks { external pure override - returns (bytes4, BeforeSwapDelta) + returns (bytes4, BeforeSwapDelta, uint24) { - return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA); + return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); } function afterSwap(address, PoolKey calldata, IPoolManager.SwapParams calldata, BalanceDelta, bytes calldata) diff --git a/src/test/MockHooks.sol b/src/test/MockHooks.sol index 07f5af50d..0db2dbe13 100644 --- a/src/test/MockHooks.sol +++ b/src/test/MockHooks.sol @@ -97,12 +97,15 @@ contract MockHooks is IHooks { function beforeSwap(address, PoolKey calldata, IPoolManager.SwapParams calldata, bytes calldata hookData) external override - returns (bytes4, BeforeSwapDelta) + returns (bytes4, BeforeSwapDelta, uint24) { beforeSwapData = hookData; bytes4 selector = MockHooks.beforeSwap.selector; - return - (returnValues[selector] == bytes4(0) ? selector : returnValues[selector], BeforeSwapDeltaLibrary.ZERO_DELTA); + return ( + returnValues[selector] == bytes4(0) ? selector : returnValues[selector], + BeforeSwapDeltaLibrary.ZERO_DELTA, + 0 + ); } function afterSwap( diff --git a/src/test/SkipCallsTestHook.sol b/src/test/SkipCallsTestHook.sol index cc42cdeb8..93269d31f 100644 --- a/src/test/SkipCallsTestHook.sol +++ b/src/test/SkipCallsTestHook.sol @@ -101,11 +101,11 @@ contract SkipCallsTestHook is BaseTestHooks, Test { function beforeSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata params, bytes calldata hookData) external override - returns (bytes4, BeforeSwapDelta) + returns (bytes4, BeforeSwapDelta, uint24) { counter++; _swap(key, params, hookData); - return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA); + return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); } function afterSwap( diff --git a/test/DynamicReturnFees.t.sol b/test/DynamicReturnFees.t.sol new file mode 100644 index 000000000..2ea8d579e --- /dev/null +++ b/test/DynamicReturnFees.t.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {PoolId, PoolIdLibrary} from "../src/types/PoolId.sol"; +import {Hooks} from "../src/libraries/Hooks.sol"; +import {LPFeeLibrary} from "../src/libraries/LPFeeLibrary.sol"; +import {IPoolManager} from "../src/interfaces/IPoolManager.sol"; +import {IProtocolFees} from "../src/interfaces/IProtocolFees.sol"; +import {IHooks} from "../src/interfaces/IHooks.sol"; +import {PoolKey} from "../src/types/PoolKey.sol"; +import {PoolManager} from "../src/PoolManager.sol"; +import {PoolSwapTest} from "../src/test/PoolSwapTest.sol"; +import {Deployers} from "./utils/Deployers.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {DynamicReturnFeeTestHook} from "../src/test/DynamicReturnFeeTestHook.sol"; +import {Currency, CurrencyLibrary} from "../src/types/Currency.sol"; +import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {FullMath} from "../src/libraries/FullMath.sol"; +import {BalanceDelta} from "../src/types/BalanceDelta.sol"; +import {StateLibrary} from "../src/libraries/StateLibrary.sol"; + +contract TestDynamicReturnFees is Test, Deployers, GasSnapshot { + using PoolIdLibrary for PoolKey; + using StateLibrary for IPoolManager; + using LPFeeLibrary for uint24; + + DynamicReturnFeeTestHook dynamicReturnFeesHook = DynamicReturnFeeTestHook( + address(uint160(uint256(type(uint160).max) & clearAllHookPermisssionsMask | Hooks.BEFORE_SWAP_FLAG)) + ); + + event Swap( + PoolId indexed poolId, + address sender, + int128 amount0, + int128 amount1, + uint160 sqrtPriceX96, + uint128 liquidity, + int24 tick, + uint24 fee + ); + + function setUp() public { + DynamicReturnFeeTestHook impl = new DynamicReturnFeeTestHook(); + vm.etch(address(dynamicReturnFeesHook), address(impl).code); + + deployFreshManagerAndRouters(); + dynamicReturnFeesHook.setManager(IPoolManager(manager)); + + (currency0, currency1) = deployMintAndApprove2Currencies(); + (key,) = initPoolAndAddLiquidity( + currency0, + currency1, + IHooks(address(dynamicReturnFeesHook)), + LPFeeLibrary.DYNAMIC_FEE_FLAG, + SQRT_PRICE_1_1, + ZERO_BYTES + ); + } + + function test_fuzz_dynamicReturnSwapFee(uint24 fee) public { + // hook will handle adding the override flag + dynamicReturnFeesHook.setFee(fee); + + uint24 actualFee = fee.removeOverrideFlag(); + + int256 amountSpecified = -10000; + BalanceDelta result; + if (actualFee > LPFeeLibrary.MAX_LP_FEE) { + vm.expectRevert(LPFeeLibrary.FeeTooLarge.selector); + result = swap(key, true, amountSpecified, ZERO_BYTES); + return; + } else { + result = swap(key, true, amountSpecified, ZERO_BYTES); + } + // BalanceDelta result = swap(key, true, amountSpecified, ZERO_BYTES); + assertEq(result.amount0(), amountSpecified); + + if (actualFee > LPFeeLibrary.MAX_LP_FEE) { + // if the fee is too large, the fee from beforeSwap is not used (and remains at 0 -- the default value) + assertApproxEqAbs(uint256(int256(result.amount1())), uint256(int256(-result.amount0())), 1 wei); + } else { + assertApproxEqAbs( + uint256(int256(result.amount1())), + FullMath.mulDiv(uint256(-amountSpecified), (1e6 - actualFee), 1e6), + 1 wei + ); + } + } + + function test_returnDynamicSwapFee_beforeSwap_succeeds_gas() public { + assertEq(_fetchPoolSwapFee(key), 0); + + dynamicReturnFeesHook.setFee(123); + + IPoolManager.SwapParams memory params = + IPoolManager.SwapParams({zeroForOne: true, amountSpecified: -100, sqrtPriceLimitX96: SQRT_PRICE_1_2}); + PoolSwapTest.TestSettings memory testSettings = + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}); + + vm.expectEmit(true, true, true, true, address(manager)); + emit Swap(key.toId(), address(swapRouter), -100, 98, 79228162514264329749955861424, 1e18, -1, 123); + + swapRouter.swap(key, params, testSettings, ZERO_BYTES); + snapLastCall("swap with return dynamic fee"); + + assertEq(_fetchPoolSwapFee(key), 0); + } + + function test_dynamicReturnSwapFee_initializeZeroSwapFee() public { + key.tickSpacing = 30; + manager.initialize(key, SQRT_PRICE_1_1, ZERO_BYTES); + assertEq(_fetchPoolSwapFee(key), 0); + } + + function test_dynamicReturnSwapFee_notUsedIfPoolIsStaticFee() public { + key.fee = 3000; // static fee + dynamicReturnFeesHook.setFee(1000); // 0.10% fee is NOT used because the pool has a static fee + + initPoolAndAddLiquidity( + currency0, currency1, IHooks(address(dynamicReturnFeesHook)), 3000, SQRT_PRICE_1_1, ZERO_BYTES + ); + assertEq(_fetchPoolSwapFee(key), 3000); + + // despite returning a valid swap fee (1000), the static fee is used + int256 amountSpecified = -10000; + BalanceDelta result = swap(key, true, amountSpecified, ZERO_BYTES); + + // after swapping ~1:1, the amount out (amount1) should be approximately 0.30% less than the amount specified + assertEq(result.amount0(), amountSpecified); + assertApproxEqAbs( + uint256(int256(result.amount1())), FullMath.mulDiv(uint256(-amountSpecified), (1e6 - 3000), 1e6), 1 wei + ); + } + + function test_dynamicReturnSwapFee_notStored() public { + // fees returned by beforeSwap are not written to storage + + // create a new pool with an initial fee of 123 + key.tickSpacing = 30; + manager.initialize(key, SQRT_PRICE_1_1, ZERO_BYTES); + modifyLiquidityRouter.modifyLiquidity(key, LIQUIDITY_PARAMS, ZERO_BYTES); + uint24 initialFee = 123; + dynamicReturnFeesHook.forcePoolFeeUpdate(key, initialFee); + assertEq(_fetchPoolSwapFee(key), initialFee); + + // swap with a different fee + uint24 newFee = 3000; + dynamicReturnFeesHook.setFee(newFee); + + int256 amountSpecified = -10000; + BalanceDelta result = swap(key, true, amountSpecified, ZERO_BYTES); + assertApproxEqAbs( + uint256(int256(result.amount1())), FullMath.mulDiv(uint256(-amountSpecified), (1e6 - newFee), 1e6), 1 wei + ); + + // the fee from beforeSwap is not stored + assertEq(_fetchPoolSwapFee(key), initialFee); + } + + function test_dynamicReturnSwapFee_revertIfFeeTooLarge() public { + assertEq(_fetchPoolSwapFee(key), 0); + + // hook adds the override flag + dynamicReturnFeesHook.setFee(1000001); + + // a large fee is not used + int256 amountSpecified = -10000; + vm.expectRevert(LPFeeLibrary.FeeTooLarge.selector); + swap(key, true, amountSpecified, ZERO_BYTES); + } + + function _fetchPoolSwapFee(PoolKey memory _key) internal view returns (uint256 swapFee) { + PoolId id = _key.toId(); + (,,, swapFee) = manager.getSlot0(id); + } +} diff --git a/test/PoolManagerInitialize.t.sol b/test/PoolManagerInitialize.t.sol index b2ac350f6..750e491f7 100644 --- a/test/PoolManagerInitialize.t.sol +++ b/test/PoolManagerInitialize.t.sol @@ -72,9 +72,7 @@ contract PoolManagerInitializeTest is Test, Deployers, GasSnapshot { } else if (!key0.hooks.isValidHookAddress(key0.fee)) { vm.expectRevert(abi.encodeWithSelector(Hooks.HookAddressNotValid.selector, address(key0.hooks))); manager.initialize(key0, sqrtPriceX96, ZERO_BYTES); - } else if ( - (key0.fee & LPFeeLibrary.DYNAMIC_FEE_FLAG == 0) && (key0.fee & LPFeeLibrary.STATIC_FEE_MASK > 1000000) - ) { + } else if ((key0.fee & LPFeeLibrary.DYNAMIC_FEE_FLAG == 0) && (key0.fee & LPFeeLibrary.FEE_MASK > 1000000)) { vm.expectRevert(abi.encodeWithSelector(LPFeeLibrary.FeeTooLarge.selector)); manager.initialize(key0, sqrtPriceX96, ZERO_BYTES); } else { diff --git a/test/libraries/Pool.t.sol b/test/libraries/Pool.t.sol index 6ce4e4ed9..3375e44fd 100644 --- a/test/libraries/Pool.t.sol +++ b/test/libraries/Pool.t.sol @@ -17,6 +17,8 @@ import {LPFeeLibrary} from "src/libraries/LPFeeLibrary.sol"; contract PoolTest is Test { using Pool for Pool.State; + using LPFeeLibrary for uint24; + using ProtocolFeeLibrary for uint24; Pool.State state; @@ -117,9 +119,15 @@ contract PoolTest is Test { ); Pool.Slot0 memory slot0 = state.slot0; - if (params.amountSpecified > 0 && lpFee == MAX_LP_FEE) { + uint24 _lpFee = params.lpFeeOverride.isOverride() ? params.lpFeeOverride.removeOverrideFlag() : lpFee; + uint24 swapFee = protocolFee == 0 ? _lpFee : uint24(protocolFee).calculateSwapFee(_lpFee); + + if (params.amountSpecified >= 0 && swapFee == MAX_LP_FEE) { vm.expectRevert(Pool.InvalidFeeForExactOut.selector); state.swap(params); + } else if (!swapFee.isValid()) { + vm.expectRevert(LPFeeLibrary.FeeTooLarge.selector); + state.swap(params); } else if (params.zeroForOne && params.amountSpecified != 0) { if (params.sqrtPriceLimitX96 >= slot0.sqrtPriceX96) { vm.expectRevert( diff --git a/test/libraries/ProtocolFeeLibrary.t.sol b/test/libraries/ProtocolFeeLibrary.t.sol index 22784f021..e3a86a493 100644 --- a/test/libraries/ProtocolFeeLibrary.t.sol +++ b/test/libraries/ProtocolFeeLibrary.t.sol @@ -21,24 +21,24 @@ contract ProtocolFeeLibraryTest is Test { (fee >> 12 > ProtocolFeeLibrary.MAX_PROTOCOL_FEE) || (fee & (4096 - 1) > ProtocolFeeLibrary.MAX_PROTOCOL_FEE) ) { - assertFalse(ProtocolFeeLibrary.validate(fee)); + assertFalse(ProtocolFeeLibrary.isValidProtocolFee(fee)); } else { - assertTrue(ProtocolFeeLibrary.validate(fee)); + assertTrue(ProtocolFeeLibrary.isValidProtocolFee(fee)); } } function test_validate() public pure { uint24 fee = uint24(ProtocolFeeLibrary.MAX_PROTOCOL_FEE + 1) << 12 | ProtocolFeeLibrary.MAX_PROTOCOL_FEE; - assertFalse(ProtocolFeeLibrary.validate(fee)); + assertFalse(ProtocolFeeLibrary.isValidProtocolFee(fee)); fee = uint24(ProtocolFeeLibrary.MAX_PROTOCOL_FEE) << 12 | (ProtocolFeeLibrary.MAX_PROTOCOL_FEE + 1); - assertFalse(ProtocolFeeLibrary.validate(fee)); + assertFalse(ProtocolFeeLibrary.isValidProtocolFee(fee)); fee = uint24(ProtocolFeeLibrary.MAX_PROTOCOL_FEE + 1) << 12 | (ProtocolFeeLibrary.MAX_PROTOCOL_FEE + 1); - assertFalse(ProtocolFeeLibrary.validate(fee)); + assertFalse(ProtocolFeeLibrary.isValidProtocolFee(fee)); fee = uint24(ProtocolFeeLibrary.MAX_PROTOCOL_FEE) << 12 | ProtocolFeeLibrary.MAX_PROTOCOL_FEE; - assertTrue(ProtocolFeeLibrary.validate(fee)); + assertTrue(ProtocolFeeLibrary.isValidProtocolFee(fee)); } function test_fuzz_calculateSwapFeeDoesNotOverflow(uint24 self, uint24 lpFee) public pure {