Taking a fee via hook
In this guide, we'll build a hook which takes a fee when user remove liquidity. This guide assume you have read develop-a-hook guide.
Requirements
- The hook needs to take 10% amt0 and amt1 whenever user removes liquidity.
Step by Step guide
Step 1: Implementation idea
In the afterRemoveLiquidity()
callback, we calculate the fee based on the amount the user receives from liquidity removal. This amount can be found in the BalanceDelta delta parameter.
function afterRemoveLiquidity
(address sender,
PoolKey calldata key,
ICLPoolManager.ModifyLiquidityParams calldata params,
BalanceDelta delta,
bytes calldata hookData
) external override poolManagerOnly returns (bytes4, BalanceDelta) {
// calculate how much fee
uint128 amt0Fee = uint128(delta.amount0()) / 10;
uint128 amt1Fee = uint128(delta.amount1()) / 10;
Step 2: Implement the hook
Take note of
- The hook permission which includes
afterRemoveLiquidityReturnsDelta
as the hook modify the delta in afterRemoveLiquidity().
View complete source code here
src/pool-cl/LiquidityRemovalFeeHook.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {PoolKey} from "@pancakeswap/v4-core/src/types/PoolKey.sol";
import {BalanceDelta, toBalanceDelta} from "@pancakeswap/v4-core/src/types/BalanceDelta.sol";
import {PoolId, PoolIdLibrary} from "@pancakeswap/v4-core/src/types/PoolId.sol";
import {Currency} from "@pancakeswap/v4-core/src/types/Currency.sol";
import {ICLPoolManager} from "@pancakeswap/v4-core/src/pool-cl/interfaces/ICLPoolManager.sol";
import {CurrencySettlement} from "@pancakeswap/v4-core/test/helpers/CurrencySettlement.sol";
import {CLBaseHook} from "./CLBaseHook.sol";
/// @notice LiquidityRemovalFeeHook takes 10% fee when user remove liquidity
contract LiquidityRemovalFeeHook is CLBaseHook {
using PoolIdLibrary for PoolKey;
using CurrencySettlement for Currency;
constructor(ICLPoolManager _poolManager) CLBaseHook(_poolManager) {}
function getHooksRegistrationBitmap() external pure override returns (uint16) {
return _hooksRegistrationBitmapFrom(
Permissions({
beforeInitialize: false,
afterInitialize: false,
beforeAddLiquidity: false,
afterAddLiquidity: false,
beforeRemoveLiquidity: false,
afterRemoveLiquidity: true,
beforeSwap: false,
afterSwap: false,
beforeDonate: false,
afterDonate: false,
beforeSwapReturnsDelta: false,
afterSwapReturnsDelta: false,
afterAddLiquidityReturnsDelta: false,
afterRemoveLiquidityReturnsDelta: true
})
);
}
function afterRemoveLiquidity(
address sender,
PoolKey calldata key,
ICLPoolManager.ModifyLiquidityParams calldata params,
BalanceDelta delta,
bytes calldata hookData
) external override poolManagerOnly returns (bytes4, BalanceDelta) {
// delta would be positive here as user is removing liquidity
uint128 amt0Fee = uint128(delta.amount0()) / 10;
uint128 amt1Fee = uint128(delta.amount1()) / 10;
key.currency0.take(vault, address(this), amt0Fee, false);
key.currency1.take(vault, address(this), amt1Fee, false);
// take 10% fee
BalanceDelta feeDelta = toBalanceDelta(int128(amt0Fee), int128(amt1Fee));
return (this.afterRemoveLiquidity.selector, feeDelta);
}
}
Step 3: Write the test
The test is straight-forward, add liquidity first then remove liquidity and verify fee is taken.
View complete source code here
src/pool-cl/LiquidityRemovalFeeHook.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;
import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol";
import {Test} from "forge-std/Test.sol";
import {Constants} from "@pancakeswap/v4-core/test/pool-cl/helpers/Constants.sol";
import {Currency} from "@pancakeswap/v4-core/src/types/Currency.sol";
import {PoolKey} from "@pancakeswap/v4-core/src/types/PoolKey.sol";
import {CLPoolParametersHelper} from "@pancakeswap/v4-core/src/pool-cl/libraries/CLPoolParametersHelper.sol";
import {LiquidityRemovalFeeHook} from "../../src/pool-cl/LiquidityRemovalFeeHook.sol";
import {CLTestUtils} from "./utils/CLTestUtils.sol";
import {PoolIdLibrary} from "@pancakeswap/v4-core/src/types/PoolId.sol";
import {ICLSwapRouterBase} from "@pancakeswap/v4-periphery/src/pool-cl/interfaces/ICLSwapRouterBase.sol";
import {INonfungiblePositionManager} from
"@pancakeswap/v4-periphery/src/pool-cl/interfaces/INonfungiblePositionManager.sol";
contract LiquidityRemovalFeeHookTest is Test, CLTestUtils {
using PoolIdLibrary for PoolKey;
using CLPoolParametersHelper for bytes32;
LiquidityRemovalFeeHook hook;
Currency currency0;
Currency currency1;
PoolKey key;
address alice = makeAddr("alice");
function setUp() public {
(currency0, currency1) = deployContractsWithTokens();
hook = new LiquidityRemovalFeeHook(poolManager);
// create the pool key
key = PoolKey({
currency0: currency0,
currency1: currency1,
hooks: hook,
poolManager: poolManager,
fee: uint24(3000), // 0.3% fee
parameters: bytes32(uint256(hook.getHooksRegistrationBitmap())).setTickSpacing(10)
});
// initialize pool at 1:1 price point and set 3000 as initial lp fee, lpFee is stored in the hook
poolManager.initialize(key, Constants.SQRT_RATIO_1_1, abi.encode(uint24(3000)));
// approve from alice for liquidity ops in the test cases below
vm.startPrank(alice);
MockERC20(Currency.unwrap(currency0)).approve(address(nfp), type(uint256).max);
MockERC20(Currency.unwrap(currency1)).approve(address(nfp), type(uint256).max);
vm.stopPrank();
}
function testRemoveLiquidity() public {
MockERC20(Currency.unwrap(currency0)).mint(address(alice), 10 ether);
MockERC20(Currency.unwrap(currency1)).mint(address(alice), 10 ether);
vm.startPrank(alice);
assertEq(MockERC20(Currency.unwrap(currency0)).balanceOf(alice), 10 ether);
assertEq(MockERC20(Currency.unwrap(currency1)).balanceOf(alice), 10 ether);
// add 10 eth liquidity on each side
(uint256 tokenId, uint128 liquidity) = _addLiquidity(key, 10 ether, 10 ether, -60, 60);
assertEq(MockERC20(Currency.unwrap(currency0)).balanceOf(alice), 0 ether);
assertEq(MockERC20(Currency.unwrap(currency1)).balanceOf(alice), 0 ether);
// remove all liqudiity
_removeLiquidity(tokenId, liquidity);
// verify that only 9 ether received as 10% fee taken
assertEq(MockERC20(Currency.unwrap(currency0)).balanceOf(alice), 9 ether);
assertEq(MockERC20(Currency.unwrap(currency1)).balanceOf(alice), 9 ether);
}
function _addLiquidity(PoolKey memory key, uint256 amount0, uint256 amount1, int24 tickLower, int24 tickUpper)
internal
returns (uint256 tokenId, uint128 liquidity)
{
INonfungiblePositionManager.MintParams memory param = INonfungiblePositionManager.MintParams({
poolKey: key,
tickLower: tickLower,
tickUpper: tickUpper,
salt: bytes32(0),
amount0Desired: amount0,
amount1Desired: amount1,
amount0Min: 0,
amount1Min: 0,
recipient: address(alice),
deadline: block.timestamp
});
(tokenId, liquidity,,) = nfp.mint(param);
}
function _removeLiquidity(uint256 tokenId, uint128 liquidity) internal {
INonfungiblePositionManager.DecreaseLiquidityParams memory param = INonfungiblePositionManager
.DecreaseLiquidityParams({
tokenId: tokenId,
liquidity: liquidity,
amount0Min: 0,
amount1Min: 0,
deadline: block.timestamp
});
nfp.decreaseLiquidity(param);
}
}