Unlocking the Power of Balancer V3: Exploring Hooks and Custom Routers
Intro
Balancers latest V3 design offers lots of exciting features such as transient accounting, native support for yield bearing tokens and boosted pools (see intro and docs for more details). Lately I’ve been diving into one of the other new features, hooks, which provide a way for developers to easily extend existing pool types at various key points throughout the pool’s lifecycle.
Hooks are standalone contracts with their own logic and state that can be linked to a pool during registration. Depending on the hook configuration, custom logic can be implemented at specific stages like before/after swapping, adding or removing liquidity (see Hook docs for details).
To explore hooks in more detail, I created a hook that charges a decaying fee for LPs removing liquidity. This is a particularly interesting example as it requires implementing a custom router (which doubles as the hook). This setup enables using NFTs to track both liquidity positions and associated metadata. When users add liquidity, they receive an NFT tied to the LP position’s start time and size. As the owner of this NFT, they can exit their LP position at any time, incurring a fee that diminishes over time since the start.
Are You A Hook Or Are You A Router?
A router is a primary entry point for a user to interact with the Vault and they can contain custom logic. Balancer has developed and deployed official routers that would normally be used for add and remove operations. In this example we need custom logic for adds and removes.
Because a hook is just a standalone contract that must implement the IHooks interface by adding the custom logic to handle adding and removing liquidity we get a single contract that can be both a hook and a router.
Note — in both the official Balancer routers and this implementation Permit2 is used to permission approvals and transfer tokens directly and safely from the user.
Show Me Some Code
For the full code see here. Lets take a look at some of the highlights.
Adding Liquidity
Normally when a user adds liquidity via the Balancer router the BPT minted by the Vault is sent to the msg.sender. In this example the BPT is minted to the router instead. The router mints the msg.sender an NFT with a particular tokenId. This tokenId is also used to keep track of the pool address, the BPT amount and the time the liquidity is added which are all used when removing liquidity.
We can see the router `addLiquidityProportional` function a user would call:
function addLiquidityProportional(
address pool,
uint256[] memory maxAmountsIn,
uint256 exactBptAmountOut,
bool wethIsEth,
bytes memory userData
) external payable saveSender returns (uint256[] memory amountsIn) {
// Do addLiquidity operation - BPT is minted to this contract.
amountsIn = _addLiquidityProportional(
pool,
msg.sender,
address(this),
maxAmountsIn,
exactBptAmountOut,
wethIsEth,
userData
);
uint256 tokenId = _nextTokenId++;
// Store the initial liquidity amount associated with the NFT.
bptAmount[tokenId] = exactBptAmountOut;
// Store the initial start time associated with the NFT.
startTime[tokenId] = block.timestamp;
// Store the pool/bpt address associated with the NFT.
nftPool[tokenId] = pool;
// Mint the associated NFT to sender.
_safeMint(msg.sender, tokenId);
emit LiquidityPositionNftMinted(msg.sender, pool, tokenId);
}
The first step is to call _addLiquidityProportional
which is inherited from MinimalRouter. This code is very similar to the official Balancer Router with the subtle change that allows it to specify a receiver of the BPT instead of automatically setting it to the sender. In this example its set to the router itself, address(this)
The Router will call the Vault addLiquidity
function:
(amountsIn, , ) = abi.decode(
_vault.unlock(
abi.encodeWithSelector(
MinimalRouter.addLiquidityHook.selector,
ExtendedAddLiquidityHookParams({
sender: sender,
receiver: receiver, ------ Note receiver can be set unlike Balancer Router
pool: pool,
maxAmountsIn: maxAmountsIn,
minBptAmountOut: exactBptAmountOut,
kind: AddLiquidityKind.PROPORTIONAL,
wethIsEth: wethIsEth,
userData: userData
})
)
),
(uint256[], uint256, bytes)
);
During an addLiquidity operation the Vault checks if the associated hook has set shouldCallBeforeAddLiquidity and if true the `onBeforeAddLiquidity` hook function is called:
function onBeforeAddLiquidity(
address router,
address,
AddLiquidityKind,
uint256[] memory,
uint256,
uint256[] memory,
bytes memory
) public view override onlySelfRouter(router) returns (bool) {
// We only allow addLiquidity via the Router/Hook itself (as it must custody BPT).
return true;
}
In this example it has the simple job of making sure that liquidity can only be added via the router itself. If a user tries to add via another router `onlySelfRouter` will fail and block the operation. This is done to ensure the decaying exit fee is always taken into account for any pool using the hook.
The final part of the addLiquidity code is where the NFT and meta data magic happen:
uint256 tokenId = _nextTokenId++;
// Store the initial liquidity amount associated with the NFT.
bptAmount[tokenId] = exactBptAmountOut;
// Store the initial start time associated with the NFT.
startTime[tokenId] = block.timestamp;
// Store the pool/bpt address associated with the NFT.
nftPool[tokenId] = pool;
// Mint the associated NFT to sender.
_safeMint(msg.sender, tokenId);
A new `tokenId` is associated with this particular liquidity position. This is then used to store the particular, pool, BPT amount and time associated with the position and finally a new NFT with the tokenId is minted to the user.
Removing Liquidity
To remove liquidity a user must own an NFT linked to a liquidity position. `removeLiquidityProportional` is called using the `tokenId` of the NFT. During the remove operation the fee to applied is calculated using the previously stored time. Once the operation is complete the NFT is burned and the exit tokens are sent to the user.
We can see `removeLiquidityProportional` called by the user looks like:
function removeLiquidityProportional(
uint256 tokenId,
uint256[] memory minAmountsOut,
bool wethIsEth
) external payable saveSender returns (uint256[] memory amountsOut) {
// Ensure the user owns the NFT.
address nftOwner = ownerOf(tokenId);
if (nftOwner != msg.sender) {
revert WithdrawalByNonOwner(msg.sender, nftOwner, tokenId);
}
address pool = nftPool[tokenId];
// Do removeLiquidity operation - tokens sent to msg.sender.
amountsOut = _removeLiquidityProportional(
pool,
address(this),
msg.sender,
bptAmount[tokenId],
minAmountsOut,
wethIsEth,
abi.encode(tokenId) // tokenId is passed to index fee data in hook
);
// Set all associated NFT data to 0.
bptAmount[tokenId] = 0;
startTime[tokenId] = 0;
nftPool[tokenId] = address(0);
// Burn the NFT
_burn(tokenId);
emit LiquidityPositionNftBurned(msg.sender, pool, tokenId);
}
NFT ownership is checked and if the `msg.sender` is not the owner of the NFT with `tokenId` the transaction will revert:
address nftOwner = ownerOf(tokenId);
if (nftOwner != msg.sender) {
revert WithdrawalByNonOwner(msg.sender, nftOwner, tokenId);
}
The pool address associated with the NFT liquidity position is retrieved from the mapping:
address pool = nftPool[tokenId];
which is then used in the `_removeLiquidityProportional`. Similar to the add operation, this is a slightly changed version of the Balancer Router function that allows us set the sender as the router (address(this)) and the receiver as the user (msg.sender). Its also interesting to note here that we pass the encoded tokenId as `userData`. This eventually gets forwarded to the after hook and is used to retrieve the mapped data required to calculate the fee.
During the removeLiquidity operation the Vault checks if the associated hook has set `shouldCallAfterRemoveLiquidity` and if true the `onAfterRemoveLiquidity` hook function is called:
function onAfterRemoveLiquidity(
address router,
address pool,
RemoveLiquidityKind,
uint256,
uint256[] memory,
uint256[] memory amountsOutRaw,
uint256[] memory,
bytes memory userData
) public override onlySelfRouter(router) returns (bool, uint256[] memory hookAdjustedAmountsOutRaw) {
// We only allow removeLiquidity via the Router/Hook itself so that fee is applied correctly.
uint256 tokenId = abi.decode(userData, (uint256));
hookAdjustedAmountsOutRaw = amountsOutRaw;
uint256 currentFee = getCurrentFeePercentage(tokenId);
if (currentFee > 0) {
hookAdjustedAmountsOutRaw = _takeFee(IRouterCommon(router).getSender(), pool, amountsOutRaw, currentFee);
}
return (true, hookAdjustedAmountsOutRaw);
}
This is the main meat of our hook. It first checks `onlySelfRouter(router)` which ensures that remove can only be done via the router itself (for the same reason given above for add). It then retrieves the `tokenId` passed via the userData. This is used to retrieve the fee using `getCurrentFeePercentage`:
function getCurrentFeePercentage(uint256 tokenId) public view returns (uint256 feePercentage) {
// Calculate the number of days that have passed since startTime
uint256 daysPassed = (block.timestamp - startTime[tokenId]) / 1 days;
if (daysPassed < DECAY_PERIOD_DAYS) {
// decreasing fee by 1% per day
feePercentage = INITIAL_FEE_PERCENTAGE - ONE_PERCENT * daysPassed;
}
}
which we can see uses the stored start time to calculate the fee at the current time. The fee is then taken by `_takeFee`:
function _takeFee(
address nftHolder,
address pool,
uint256[] memory amountsOutRaw,
uint256 currentFee
) private returns (uint256[] memory hookAdjustedAmountsOutRaw) {
hookAdjustedAmountsOutRaw = amountsOutRaw;
IERC20[] memory tokens = _vault.getPoolTokens(pool);
uint256[] memory accruedFees = new uint256[](tokens.length);
// Charge fees proportional to the `amountOut` of each token.
for (uint256 i = 0; i < amountsOutRaw.length; i++) {
uint256 exitFee = amountsOutRaw[i].mulDown(currentFee);
accruedFees[i] = exitFee;
hookAdjustedAmountsOutRaw[i] -= exitFee;
// Fees don't need to be transferred to the hook, because donation will redeposit them in the vault.
// In effect, we will transfer a reduced amount of tokensOut to the caller, and leave the remainder
// in the pool balance.
emit ExitFeeCharged(nftHolder, pool, tokens[i], exitFee);
}
// Donates accrued fees back to LPs.
_vault.addLiquidity(
AddLiquidityParams({
pool: pool,
to: msg.sender, // It would mint BPTs to router, but it's a donation so no BPT is minted
maxAmountsIn: accruedFees, // Donate all accrued fees back to the pool (i.e. to the LPs)
minBptAmountOut: 0, // Donation does not return BPTs, any number above 0 will revert
kind: AddLiquidityKind.DONATION,
userData: bytes("") // User data is not used by donation, so we can set it to an empty string
})
);
}
This function uses the computed fee to deduct an exitFee from each output token. This is donated back to pool using the special `DONATION` AddLiquidity kind. The updated amounts out are returned to the Vault via the returned `hookAdjustedAmountsOutRaw` which ensures all the Vault accounting passes.
Conclusion
This is a fairly involved example but it covers a lot including some interesting functionality:
- How a custom router can be used to apply custom logic to pool operations
- Enforcing pool operations to a specific router
- Using an NFT to represent a liquidity position which can also be used to track associated meta data
This really shows the flexibility and power that hooks and custom routers can have. Combining these functionalities opens a really wide design space to explore!