diff --git a/src/fee/BaseDynamicAfterFee.sol b/src/fee/BaseDynamicAfterFee.sol index ed5aea7..6e679a8 100644 --- a/src/fee/BaseDynamicAfterFee.sol +++ b/src/fee/BaseDynamicAfterFee.sol @@ -9,16 +9,17 @@ import {PoolId} from "v4-core/src/types/PoolId.sol"; import {Hooks} from "v4-core/src/libraries/Hooks.sol"; import {PoolKey} from "v4-core/src/types/PoolKey.sol"; import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; +import {Currency} from "v4-core/src/types/Currency.sol"; +import {SafeCast} from "v4-core/src/libraries/SafeCast.sol"; +import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "v4-core/src/types/BeforeSwapDelta.sol"; /** * @dev Base implementation for dynamic fees applied after swaps. * - * In order to use this hook, the inheriting contract must define a target delta for a swap before - * the {afterSwap} function is called. This can be done accurately by using the {beforeSwap} hook - * to define the target delta for the current swap according to arbitrary logic. - * - * IMPORTANT: This hook only supports exact-input swaps. For exact-output swaps, the hook will not apply - * the target delta. + * In order to use this hook, the inheriting contract must define the {_getTargetOutput} and + * {_afterSwapHandler} functions. The {_getTargetOutput} function returns the target output to + * apply to the swap depending on the given apply flag. The {_afterSwapHandler} function is called + * after the target output is applied to the swap and currency amount is received. * * WARNING: This is experimental software and is provided on an "as is" and "as available" basis. We do * not give any warranties and will not be liable for any losses incurred through any use of this code @@ -27,7 +28,16 @@ import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; * _Available since v0.1.0_ */ abstract contract BaseDynamicAfterFee is BaseHook { - mapping(PoolId => BalanceDelta) internal _targetDeltas; + using SafeCast for uint256; + + mapping(PoolId => uint256) private _targetOutput; + + bool private _applyTargetOutput; + + /** + * @dev Target delta exceeds swap amount. + */ + error TargetDeltaExceeds(); /** * @dev Set the `PoolManager` address. @@ -35,21 +45,31 @@ abstract contract BaseDynamicAfterFee is BaseHook { constructor(IPoolManager _poolManager) BaseHook(_poolManager) {} /** - * @dev Calculate the target delta and apply the fee so that the returned delta matches. + * @dev Sets the target output and apply flag to be used in the `afterSwap` hook. * - * Target deltas are only applied for exact-input swaps that meet the minimum delta value - * for either `amount0` or `amount1`. - * - * NOTE: The target delta is reset to 0 after the swap is processed, regardless of the - * swap parameters. Therefore, it is recommended to use the {beforeSwap} hook to set the - * target delta for swaps automatically. + * NOTE: The target output is reset to 0 in the `afterSwap` hook regardless of the apply flag. + */ + function _beforeSwap( + address sender, + PoolKey calldata key, + IPoolManager.SwapParams calldata params, + bytes calldata hookData + ) internal virtual override returns (bytes4, BeforeSwapDelta, uint24) { + // Get the target output and apply flag + (uint256 targetOutput, bool applyTargetOutput) = _getTargetOutput(sender, key, params, hookData); + + // Set the target output and apply flag, overriding any previous values. + _applyTargetOutput = applyTargetOutput; + _targetOutput[key.toId()] = targetOutput; + + return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); + } + + /** + * @dev Apply the target output to the unspecified currency of the swap using fees. * - * IMPORTANT: The fees obtained from netting with the target delta are donated to the pool and - * distributed among in-range liquidity providers. Note that this donation mechanism can be - * exploited by attackers who add just-in-time liquidity to a narrow range around the final - * tick after the swap. Such liquidity would receive a disproportionate share of the donation - * despite not meaningfully participating in the swap. Implementers should carefully consider - * this possibility when using this default hook implementation. + * NOTE: The target output is reset to 0, both when the apply flag is set to `false` + * and when set to `true`. */ function _afterSwap( address, @@ -58,39 +78,80 @@ abstract contract BaseDynamicAfterFee is BaseHook { BalanceDelta delta, bytes calldata ) internal virtual override returns (bytes4, int128) { - int128 feeAmount = 0; - - // Only apply target delta for exact-input swaps - if (params.amountSpecified < 0) { - PoolId poolId = key.toId(); - BalanceDelta targetDelta = _targetDeltas[poolId]; - - // Skip empty/undefined target delta - if (BalanceDelta.unwrap(targetDelta) != 0) { - // Reset storage target delta to 0 and use one stored in memory - _targetDeltas[poolId] = BalanceDelta.wrap(0); - - // Apply target delta on token amount user would receive (amount1) - if (params.zeroForOne && delta.amount1() > targetDelta.amount1()) { - feeAmount = delta.amount1() - targetDelta.amount1(); - - // feeAmount is positive and int128, so we can safely cast to uint128 given that uint128 - // has a larger maximum value. - poolManager.donate(key, 0, uint256(uint128(feeAmount)), ""); - } - - // Apply target delta on token amount user would receive (amount0) - if (!params.zeroForOne && delta.amount0() > targetDelta.amount0()) { - feeAmount = delta.amount0() - targetDelta.amount0(); - - // feeAmount is positive and int128, so we can safely cast to uint128 given that uint128 - // has a larger maximum value. - poolManager.donate(key, uint256(uint128(feeAmount)), 0, ""); - } - } + PoolId poolId = key.toId(); + uint256 targetOutput = _targetOutput[poolId]; + + // Reset storage target output to 0 and use one stored in memory + _targetOutput[poolId] = 0; + + // Skip if target output is not active + if (!_applyTargetOutput) { + return (this.afterSwap.selector, 0); } - return (this.afterSwap.selector, feeAmount); + // Fee defined in the unspecified currency of the swap + (Currency unspecified, int128 unspecifiedAmount) = (params.amountSpecified < 0 == params.zeroForOne) + ? (key.currency1, delta.amount1()) + : (key.currency0, delta.amount0()); + + // If fee is on output, get the absolute output amount + if (unspecifiedAmount < 0) unspecifiedAmount = -unspecifiedAmount; + + // Revert if the target delta exceeds the swap amount + if (targetOutput > uint128(unspecifiedAmount)) revert TargetDeltaExceeds(); + + // Calculate the fee amount, which is the difference between the swap amount and the target output + uint256 feeAmount = uint128(unspecifiedAmount) - targetOutput; + + // Take fee and call handler + poolManager.take(unspecified, address(this), feeAmount); + _afterSwapHandler(key, params, delta, targetOutput, feeAmount); + + return (this.afterSwap.selector, feeAmount.toInt128()); + } + + /** + * @dev Return the target output to be enforced by the `afterSwap` hook using fees. + * + * IMPORTANT: The swap will revert if the target output exceeds the output unspecified amount from the swap. + * In order to consume all of the output from the swap, set the target output to equal the output unspecified + * amount and set the apply flag to `true`. + * + * @return targetOutput The target output, defined in the unspecified currency of the swap. + * @return applyTargetOutput The apply flag, which can be set to `false` to skip applying the target output. + */ + function _getTargetOutput( + address sender, + PoolKey calldata key, + IPoolManager.SwapParams calldata params, + bytes calldata hookData + ) internal virtual returns (uint256 targetOutput, bool applyTargetOutput); + + /** + * @dev Handler called after applying the target output to a swap and receiving the currency amount. + * + * @param key The pool key. + * @param params The swap parameters. + * @param delta The balance delta from the swap. + * @param targetOutput The target output, defined in the unspecified currency of the swap. + * @param feeAmount The amount of the unspecified currency taken from the swap. + */ + function _afterSwapHandler( + PoolKey calldata key, + IPoolManager.SwapParams calldata params, + BalanceDelta delta, + uint256 targetOutput, + uint256 feeAmount + ) internal virtual; + + /** + * @dev Get the target output for a pool. + * + * @param poolId The pool ID. + * @return targetOutput The current target output for the pool. + */ + function _getTargetOutput(PoolId poolId) internal view virtual returns (uint256) { + return _targetOutput[poolId]; } /** @@ -106,7 +167,7 @@ abstract contract BaseDynamicAfterFee is BaseHook { afterAddLiquidity: false, beforeRemoveLiquidity: false, afterRemoveLiquidity: false, - beforeSwap: false, + beforeSwap: true, afterSwap: true, beforeDonate: false, afterDonate: false, diff --git a/test/fee/DynamicAfterFee.t.sol b/test/fee/BaseDynamicAfterFee.t.sol similarity index 52% rename from test/fee/DynamicAfterFee.t.sol rename to test/fee/BaseDynamicAfterFee.t.sol index ce7f51a..f597c2a 100644 --- a/test/fee/DynamicAfterFee.t.sol +++ b/test/fee/BaseDynamicAfterFee.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.26; import "forge-std/Test.sol"; import {Deployers} from "v4-core/test/utils/Deployers.sol"; +import {BaseDynamicAfterFee} from "src/fee/BaseDynamicAfterFee.sol"; import {BaseDynamicAfterFeeMock} from "test/mocks/BaseDynamicAfterFeeMock.sol"; import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; import {Hooks} from "v4-core/src/libraries/Hooks.sol"; @@ -12,9 +13,29 @@ import {Currency} from "v4-core/src/types/Currency.sol"; import {BalanceDelta, toBalanceDelta} from "v4-core/src/types/BalanceDelta.sol"; import {LPFeeLibrary} from "v4-core/src/libraries/LPFeeLibrary.sol"; import {PoolId} from "v4-core/src/types/PoolId.sol"; +import {SafeCast} from "v4-core/src/libraries/SafeCast.sol"; +import {V4Quoter} from "v4-periphery/src/lens/V4Quoter.sol"; +import {Deploy} from "v4-periphery/test/shared/Deploy.sol"; +import {PoolKey} from "v4-core/src/types/PoolKey.sol"; + +interface IV4Quoter { + struct QuoteExactSingleParams { + PoolKey poolKey; + bool zeroForOne; + uint128 exactAmount; + bytes hookData; + } + + function quoteExactInputSingle(QuoteExactSingleParams memory params) + external + returns (uint256 amountOut, uint256 gasEstimate); +} + +contract BaseDynamicAfterFeeTest is Test, Deployers { + using SafeCast for uint256; -contract DynamicAfterFeeTest is Test, Deployers { BaseDynamicAfterFeeMock dynamicFeesHook; + IV4Quoter quoter; event Swap( PoolId indexed poolId, @@ -32,8 +53,9 @@ contract DynamicAfterFeeTest is Test, Deployers { function setUp() public { deployFreshManagerAndRouters(); - dynamicFeesHook = - BaseDynamicAfterFeeMock(address(uint160(Hooks.AFTER_SWAP_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG))); + dynamicFeesHook = BaseDynamicAfterFeeMock( + address(uint160(Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG)) + ); deployCodeTo( "test/mocks/BaseDynamicAfterFeeMock.sol:BaseDynamicAfterFeeMock", abi.encode(manager), @@ -47,57 +69,77 @@ contract DynamicAfterFeeTest is Test, Deployers { vm.label(Currency.unwrap(currency0), "currency0"); vm.label(Currency.unwrap(currency1), "currency1"); + + quoter = IV4Quoter(address(Deploy.v4Quoter(address(manager), ""))); } function test_swap_100PercentLPFeeExactInput_succeeds() public { - assertEq(BalanceDelta.unwrap(dynamicFeesHook.getTargetDelta(key.toId())), 0); + assertEq(dynamicFeesHook.getTargetOutput(key.toId()), 0); - dynamicFeesHook.setTargetDelta(key.toId(), toBalanceDelta(-100, 0)); - BalanceDelta currentDelta = dynamicFeesHook.getTargetDelta(key.toId()); - assertEq(currentDelta.amount0(), -100); - assertEq(currentDelta.amount1(), 0); + dynamicFeesHook.setTargetOutput(key.toId(), 0, true); + uint256 currentOutput = dynamicFeesHook.getTargetOutput(key.toId()); + assertEq(currentOutput, 0); 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, 99, 79228162514264329670727698910, 1e18, -1, 0); - vm.expectEmit(true, true, true, true, address(manager)); - emit Donate(key.toId(), address(dynamicFeesHook), 0, 99); + + uint256 balanceBefore = currency1.balanceOf(address(this)); swapRouter.swap(key, SWAP_PARAMS, testSettings, ZERO_BYTES); - assertEq(BalanceDelta.unwrap(dynamicFeesHook.getTargetDelta(key.toId())), 0); + assertEq(dynamicFeesHook.getTargetOutput(key.toId()), 0); + assertEq(currency1.balanceOf(address(dynamicFeesHook)), 99); + assertEq(currency1.balanceOf(address(this)), balanceBefore); } function test_swap_50PercentLPFeeExactInput_succeeds() public { - assertEq(BalanceDelta.unwrap(dynamicFeesHook.getTargetDelta(key.toId())), 0); + assertEq(dynamicFeesHook.getTargetOutput(key.toId()), 0); - dynamicFeesHook.setTargetDelta(key.toId(), toBalanceDelta(-100, 49)); - BalanceDelta currentDelta = dynamicFeesHook.getTargetDelta(key.toId()); - assertEq(currentDelta.amount0(), -100); - assertEq(currentDelta.amount1(), 49); + dynamicFeesHook.setTargetOutput(key.toId(), 49, true); + uint256 currentOutput = dynamicFeesHook.getTargetOutput(key.toId()); + assertEq(currentOutput, 0); 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, 99, 79228162514264329670727698910, 1e18, -1, 0); - vm.expectEmit(true, true, true, true, address(manager)); - emit Donate(key.toId(), address(dynamicFeesHook), 0, 50); + + uint256 balanceBefore = currency1.balanceOf(address(this)); swapRouter.swap(key, SWAP_PARAMS, testSettings, ZERO_BYTES); - assertEq(BalanceDelta.unwrap(dynamicFeesHook.getTargetDelta(key.toId())), 0); + assertEq(dynamicFeesHook.getTargetOutput(key.toId()), 0); + assertEq(currency1.balanceOf(address(dynamicFeesHook)), 50); + assertEq(currency1.balanceOf(address(this)), balanceBefore + 49); + } + + function test_swap_skipped_succeeds() public { + assertEq(dynamicFeesHook.getTargetOutput(key.toId()), 0); + + dynamicFeesHook.setTargetOutput(key.toId(), 999, false); + uint256 currentOutput = dynamicFeesHook.getTargetOutput(key.toId()); + assertEq(currentOutput, 0); + + 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, 99, 79228162514264329670727698910, 1e18, -1, 0); + swapRouter.swap(key, SWAP_PARAMS, testSettings, ZERO_BYTES); + + assertEq(dynamicFeesHook.getTargetOutput(key.toId()), 0); } function test_swap_50PercentLPFeeExactOutput_succeeds() public { - assertEq(BalanceDelta.unwrap(dynamicFeesHook.getTargetDelta(key.toId())), 0); + assertEq(dynamicFeesHook.getTargetOutput(key.toId()), 0); - dynamicFeesHook.setTargetDelta(key.toId(), toBalanceDelta(-101, 50)); - BalanceDelta currentDelta = dynamicFeesHook.getTargetDelta(key.toId()); - assertEq(currentDelta.amount0(), -101); - assertEq(currentDelta.amount1(), 50); + dynamicFeesHook.setTargetOutput(key.toId(), 50, true); + uint256 currentOutput = dynamicFeesHook.getTargetOutput(key.toId()); + assertEq(currentOutput, 0); IPoolManager.SwapParams memory params = IPoolManager.SwapParams({zeroForOne: true, amountSpecified: 100, sqrtPriceLimitX96: SQRT_PRICE_1_2}); @@ -110,45 +152,47 @@ contract DynamicAfterFeeTest is Test, Deployers { // No fee is applied because this is an exact-output swap swapRouter.swap(key, params, testSettings, ZERO_BYTES); - currentDelta = dynamicFeesHook.getTargetDelta(key.toId()); - assertEq(currentDelta.amount0(), -101); - assertEq(currentDelta.amount1(), 50); + assertEq(dynamicFeesHook.getTargetOutput(key.toId()), 0); } function test_swap_deltaExceeds_succeeds() public { - assertEq(BalanceDelta.unwrap(dynamicFeesHook.getTargetDelta(key.toId())), 0); + assertEq(dynamicFeesHook.getTargetOutput(key.toId()), 0); - dynamicFeesHook.setTargetDelta(key.toId(), toBalanceDelta(-100, 101)); - BalanceDelta currentDelta = dynamicFeesHook.getTargetDelta(key.toId()); - assertEq(currentDelta.amount0(), -100); - assertEq(currentDelta.amount1(), 101); + dynamicFeesHook.setTargetOutput(key.toId(), 101, true); + uint256 currentOutput = dynamicFeesHook.getTargetOutput(key.toId()); + assertEq(currentOutput, 0); 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, 99, 79228162514264329670727698910, 1e18, -1, 0); - - // Target delta has no effect given that target amount1 exceeds the swap delta + vm.expectRevert( + abi.encodeWithSelector( + Hooks.Wrap__FailedHookCall.selector, + address(dynamicFeesHook), + abi.encodeWithSelector(BaseDynamicAfterFee.TargetDeltaExceeds.selector) + ) + ); swapRouter.swap(key, SWAP_PARAMS, testSettings, ZERO_BYTES); - assertEq(BalanceDelta.unwrap(dynamicFeesHook.getTargetDelta(key.toId())), 0); + assertEq(dynamicFeesHook.getTargetOutput(key.toId()), 0); } function test_swap_fuzz_succeeds(bool zeroForOne, uint24 lpFee, uint128 amountSpecified) public { - assertEq(BalanceDelta.unwrap(dynamicFeesHook.getTargetDelta(key.toId())), 0); + assertEq(dynamicFeesHook.getTargetOutput(key.toId()), 0); - lpFee = uint24(bound(lpFee, 10000, 1000000)); + lpFee = uint24(bound(lpFee, 0, 1e6)); amountSpecified = uint128(bound(amountSpecified, 1, 6017734268818166)); - - uint256 deltaFee = (uint256(amountSpecified) * lpFee) / 1000000; - int128 targetAmount1 = deltaFee > 0 ? int128(uint128(amountSpecified - deltaFee)) : int128(0); - - if (zeroForOne) { - dynamicFeesHook.setTargetDelta(key.toId(), toBalanceDelta(-int128(amountSpecified), targetAmount1)); - } else { - dynamicFeesHook.setTargetDelta(key.toId(), toBalanceDelta(targetAmount1, -int128(amountSpecified))); - } + (uint256 amountUnspecified,) = quoter.quoteExactInputSingle( + IV4Quoter.QuoteExactSingleParams({ + poolKey: key, + zeroForOne: zeroForOne, + exactAmount: amountSpecified, + hookData: ZERO_BYTES + }) + ); + uint256 deltaFee = (amountUnspecified * lpFee) / 1e6; + uint256 targetAmount = amountUnspecified - deltaFee; + dynamicFeesHook.setTargetOutput(key.toId(), targetAmount, true); IPoolManager.SwapParams memory params = IPoolManager.SwapParams({ zeroForOne: zeroForOne, @@ -162,9 +206,9 @@ contract DynamicAfterFeeTest is Test, Deployers { if (zeroForOne) { assertEq(delta.amount0(), -int128(amountSpecified)); - assertEq(delta.amount1(), targetAmount1); + assertEq(delta.amount1(), targetAmount.toInt128()); } else { - assertEq(delta.amount0(), targetAmount1); + assertEq(delta.amount0(), targetAmount.toInt128()); assertEq(delta.amount1(), -int128(amountSpecified)); } } diff --git a/test/mocks/BaseDynamicAfterFeeMock.sol b/test/mocks/BaseDynamicAfterFeeMock.sol index 2288284..ae77c1a 100644 --- a/test/mocks/BaseDynamicAfterFeeMock.sol +++ b/test/mocks/BaseDynamicAfterFeeMock.sol @@ -2,18 +2,34 @@ pragma solidity ^0.8.26; import "src/fee/BaseDynamicAfterFee.sol"; -import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "v4-core/src/types/BeforeSwapDelta.sol"; -import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol"; contract BaseDynamicAfterFeeMock is BaseDynamicAfterFee { + mapping(PoolId => uint256) public targetOutput; + bool public applyTargetOutput; + constructor(IPoolManager _poolManager) BaseDynamicAfterFee(_poolManager) {} - function getTargetDelta(PoolId poolId) public view returns (BalanceDelta) { - return _targetDeltas[poolId]; + function getTargetOutput(PoolId poolId) public view returns (uint256) { + return _getTargetOutput(poolId); + } + + function setTargetOutput(PoolId poolId, uint256 output, bool active) public { + targetOutput[poolId] = output; + applyTargetOutput = active; } - function setTargetDelta(PoolId poolId, BalanceDelta delta) public { - _targetDeltas[poolId] = delta; + function _afterSwapHandler(PoolKey calldata, IPoolManager.SwapParams calldata, BalanceDelta, uint256, uint256) + internal + override + {} + + function _getTargetOutput(address, PoolKey calldata key, IPoolManager.SwapParams calldata, bytes calldata) + internal + view + override + returns (uint256, bool) + { + return (targetOutput[key.toId()], applyTargetOutput); } // Exclude from coverage report