Develop a hook
In this guide, we'll develop a hook for concentrated liquidity pool. The same step will apply for liquidity book. We'll start with introducing hook template before the step by step guide section.
Hooks template
Proceed to https://github.com/pancakeswap/pancake-v4-hooks-template for the hook template. Click Use this template
to create a new repository based on the template.
The template requires Foundry. If you don't have Foundry installed, please follow the installation guide.
Once the new repository is cloned to local setup, run the following commands:
> forge install // install dependencies
> forge test // run the existing tests in the repository
Within both src
and test
there are 2 folders: pool-cl
and pool-bin
. If you are developing for concentrated liquidity pool, focus on pool-cl folder, otherwise pool-bin folder for the liquidity book pool type.
BaseHook
BaseHook is a base contract where you will inherit for your hooks. It provides
- helper method:
_hooksRegistrationBitmapFrom
to set up the callback required - callback method: for you to overwrite
// Snippet from CLCounterHook.sol
import {CLBaseHook} from "./CLBaseHook.sol";
contract CLCounterHook is CLBaseHook {
constructor(ICLPoolManager _poolManager) CLBaseHook(_poolManager) {}
// 1. Set up callback required. in this case, 4 callback are required
function getHooksRegistrationBitmap() external pure override returns (uint16) {
return _hooksRegistrationBitmapFrom(
Permissions({
beforeInitialize: false,
afterInitialize: false,
beforeAddLiquidity: true,
afterAddLiquidity: true,
beforeRemoveLiquidity: false,
afterRemoveLiquidity: false,
beforeSwap: true,
afterSwap: true,
beforeDonate: false,
afterDonate: false,
beforeSwapReturnsDelta: false,
afterSwapReturnsDelta: false,
afterAddLiquidityReturnsDelta: false,
afterRemoveLiquidityReturnsDelta: false
})
);
}
// 2. For each callback required, overwrite the method
function beforeAddLiquidity(address,PoolKey calldata key, ICLPoolManager.ModifyLiquidityParams calldata, bytes calldata)
external override poolManagerOnly returns (bytes4) {
// implement hook logic and then return selector
return this.beforeAddLiquidity.selector;
}
}
Step by step guide
We will develop a hook that allows veCake holder to get a 50% swap fee discount when swapping through pool with this hook.
Step 1: Download hook template
- Create a new repository from pancake-v4-hooks-template: Click here
- Clone the repository locally and run
forge install
andforge test
to verify local setup.
Step 2: Implementation idea
The flow will be as follows:
- We need to first store the default swap fee in the hook. We can do during pool initialization, passing the swap fee in the hookData which will eventually flow to the hook. The hook can
abi.decode
the bytes hookData and store the swap fee.
- The default swap fee is stored at hook now. At
beforeSwap()
, hook check if the swapper is veCake holder and return the updated swap fee accordingly throughlpFeeOverride
value.
Step 3: Implement the hook
We'll perform the following:
- Add
afterInitialize
andbeforeSwap
permission - Store default swap fee in
afterInitialize
- Return the swap fee based on whether user is veCake holder in
beforeSwap
Let's go through the implementation step by step
3.1 Add afterInitialize
and beforeSwap
permission
Create a file called at src/pool-cl/VeCakeDiscountHook.sol
and implement the following. The hook contract extends CLBaseHook
.
contract VeCakeDiscountHook is CLBaseHook {
function getHooksRegistrationBitmap() external pure override returns (uint16) {
return _hooksRegistrationBitmapFrom(
Permissions({
beforeInitialize: false,
afterInitialize: true,
beforeAddLiquidity: false,
afterAddLiquidity: false,
beforeRemoveLiquidity: false,
afterRemoveLiquidity: false,
beforeSwap: true,
afterSwap: false,
beforeDonate: false,
afterDonate: false,
noOp: false
})
);
}
}
3.2 Store default swap fee in afterInitialize
We specified afterInitialize
permission in previous step. Thus CLPoolManager will call hook.afterInitialize
after pool is initialized.
Now we'll implement the afterInitialize
method to store the default swap fee.
// mapping to store poolId
mapping(PoolId => uint24) public poolIdToLpFee;
function afterInitialize(address, PoolKey calldata key, uint160, int24, bytes calldata hookData)
external override returns (bytes4)
{
// decode hookdata to get the default swap fee
uint24 swapFee = abi.decode(hookData, (uint24));
poolIdToLpFee[key.toId()] = swapFee;
return this.afterInitialize.selector;
}
3.3 Return the swap fee based on whether user is veCake holder in beforeSwap
beforeSwap
will be called before a swap happens, and the third return value uint24 lpFeeOverride
is where we can override the swap fee.
Note the return value need to include LPFeeLibrary.OVERRIDE_FEE_FLAG
so pool manager knows the intention is to override swap fee.
function beforeSwap(address, PoolKey calldata key, ICLPoolManager.SwapParams calldata, bytes calldata)
external view override poolManagerOnly returns (bytes4, BeforeSwapDelta, uint24)
{
uint24 lpFee = poolIdToLpFee[key.toId()];
if (veCake.balanceOf(tx.origin) >= 1 ether) {
lpFee = lpFee / 2;
}
return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, lpFee | LPFeeLibrary.OVERRIDE_FEE_FLAG);
}
View complete source code here
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {PoolKey} from "@pancakeswap/v4-core/src/types/PoolKey.sol";
import {BalanceDelta, BalanceDeltaLibrary} from "@pancakeswap/v4-core/src/types/BalanceDelta.sol";
import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "@pancakeswap/v4-core/src/types/BeforeSwapDelta.sol";
import {PoolId, PoolIdLibrary} from "@pancakeswap/v4-core/src/types/PoolId.sol";
import {ICLPoolManager} from "@pancakeswap/v4-core/src/pool-cl/interfaces/ICLPoolManager.sol";
import {LPFeeLibrary} from "@pancakeswap/v4-core/src/libraries/LPFeeLibrary.sol";
import {CLBaseHook} from "./CLBaseHook.sol";
interface IVeCake {
function balanceOf(address account) external view returns (uint256 balance);
}
/// @notice VeCakeDiscountHook is a contract that provide 50% swap fee discount to veCake holder
/// @dev note the code is not production ready, it is only to share how a hook looks like
contract VeCakeDiscountHook is CLBaseHook {
using PoolIdLibrary for PoolKey;
IVeCake public veCake;
mapping(PoolId => uint24) public poolIdToLpFee;
constructor(ICLPoolManager _poolManager, address _veCake) CLBaseHook(_poolManager) {
veCake = IVeCake(_veCake);
}
function getHooksRegistrationBitmap() external pure override returns (uint16) {
return _hooksRegistrationBitmapFrom(
Permissions({
beforeInitialize: false,
afterInitialize: true,
beforeAddLiquidity: false,
afterAddLiquidity: false,
beforeRemoveLiquidity: false,
afterRemoveLiquidity: false,
beforeSwap: true,
afterSwap: false,
beforeDonate: false,
afterDonate: false,
beforeSwapReturnsDelta: false,
afterSwapReturnsDelta: false,
afterAddLiquidityReturnsDelta: false,
afterRemoveLiquidityReturnsDelta: false
})
);
}
function afterInitialize(address, PoolKey calldata key, uint160, int24, bytes calldata hookData)
external
override
returns (bytes4)
{
uint24 swapFee = abi.decode(hookData, (uint24));
poolIdToLpFee[key.toId()] = swapFee;
return this.afterInitialize.selector;
}
function beforeSwap(address, PoolKey calldata key, ICLPoolManager.SwapParams calldata, bytes calldata)
external
override
poolManagerOnly
returns (bytes4, BeforeSwapDelta, uint24)
{
uint24 lpFee = poolIdToLpFee[key.toId()];
if (veCake.balanceOf(tx.origin) >= 1 ether) {
lpFee = lpFee / 2;
}
return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, lpFee | LPFeeLibrary.OVERRIDE_FEE_FLAG);
}
}
Step 4: Add Hook test
In the test, we'll test 2 scenarios:
- when swapping as a normal user
- when swapping as a veCake holder
Create a file called at test/pool-cl/VeCakeDiscountHook.t.sol
and copy content from below.
View complete source code here
// 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 {VeCakeDiscountHook} from "../../src/pool-cl/VeCakeDiscountHook.sol";
import {CLTestUtils} from "./utils/CLTestUtils.sol";
import {CLPoolParametersHelper} from "@pancakeswap/v4-core/src/pool-cl/libraries/CLPoolParametersHelper.sol";
import {PoolIdLibrary} from "@pancakeswap/v4-core/src/types/PoolId.sol";
import {ICLSwapRouterBase} from "@pancakeswap/v4-periphery/src/pool-cl/interfaces/ICLSwapRouterBase.sol";
import {LPFeeLibrary} from "@pancakeswap/v4-core/src/libraries/LPFeeLibrary.sol";
contract VeCakeDiscountHookTest is Test, CLTestUtils {
using PoolIdLibrary for PoolKey;
using CLPoolParametersHelper for bytes32;
VeCakeDiscountHook hook;
Currency currency0;
Currency currency1;
PoolKey key;
MockERC20 veCake = new MockERC20("veCake", "veCake", 18);
address alice = makeAddr("alice");
function setUp() public {
(currency0, currency1) = deployContractsWithTokens();
hook = new VeCakeDiscountHook(poolManager, address(veCake));
// create the pool key
key = PoolKey({
currency0: currency0,
currency1: currency1,
hooks: hook,
poolManager: poolManager,
fee: LPFeeLibrary.DYNAMIC_FEE_FLAG,
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)));
// add liquidity so that swap can happen
MockERC20(Currency.unwrap(currency0)).mint(address(this), 100 ether);
MockERC20(Currency.unwrap(currency1)).mint(address(this), 100 ether);
addLiquidity(key, 100 ether, 100 ether, -60, 60);
// approve from alice for swap in the test cases below
vm.startPrank(alice);
MockERC20(Currency.unwrap(currency0)).approve(address(swapRouter), type(uint256).max);
MockERC20(Currency.unwrap(currency1)).approve(address(swapRouter), type(uint256).max);
vm.stopPrank();
// mint alice token for trade later
MockERC20(Currency.unwrap(currency0)).mint(address(alice), 100 ether);
}
function testVeCakeHolder() public {
// mint alice veCake
veCake.mint(address(alice), 1 ether);
uint256 amtOut = _swap();
// amt out should be close to 1 ether minus slippage
assertGe(amtOut, 0.997 ether);
}
function testNonVeCakeHolderXX() public {
uint256 amtOut = _swap();
// amt out be at least 0.3% lesser due to swap fee
assertLe(amtOut, 0.997 ether);
}
function _swap() internal returns (uint256 amtOut) {
// set alice as tx.origin
vm.prank(address(alice), address(alice));
amtOut = swapRouter.exactInputSingle(
ICLSwapRouterBase.V4CLExactInputSingleParams({
poolKey: key,
zeroForOne: true,
recipient: address(alice),
amountIn: 1 ether,
amountOutMinimum: 0,
sqrtPriceLimitX96: 0,
hookData: new bytes(0)
}),
block.timestamp
);
}
}
In order to allow dynamic swap fee, the fee
variable in poolKey must have dynamic flag set.
key = PoolKey({
currency0: currency0,
currency1: currency1,
hooks: hook,
poolManager: poolManager,
fee: LPFeeLibrary.DYNAMIC_FEE_FLAG,
parameters: bytes32(uint256(hook.getHooksRegistrationBitmap())).setTickSpacing(10)
});
Step 5: Verify
Run forge test
to verify test passing.