Using Balancer V3 peripheral contracts to simplify the development and testing of custom Hooks
In my previous post, I discussed the basics of Balancer Hooks and demonstrated how simple it is to create a custom hook with a decaying exit fee. The ease of development on Balancer V3 is greatly aided by the peripheral smart contracts crafted by the Ballerinas (the team behind Balancer’s smart contracts). These contracts serve as helpful tools, simplifying the testing workflow and enhancing the safety and efficiency of projects. In this article, we will explore these in greater detail, showing how they can make developers lives easier.
The Hook That Holds: Enabling Peg Stability through Fee-Based Solutions
In Balancer’s stable pools, maintaining a healthy peg is crucial for yield-bearing assets like stable coins and staking derivatives. However, as market dynamics take over, one token may become significantly overweight, leading to inefficiencies in trading. The mighty ZenDragon has proposed a possible hook design that defends the peg while allowing passive liquidity providers to benefit from this de-pegging behavior (details to be covered in an upcoming post). One possible implementation of this can be seen in this StableSurge hook example which also serves as a good showcase for the simplified development process.
Peripheral Power
The main hook contract inherits three useful building blocks and uses the custom FixedPoint
maths library:
contract StableSurgeHookExample is BaseHooks, VaultGuard, Ownable {...
using FixedPoint for uint256;
BaseHooks
BaseHooks.sol is provided as an abstract contract, with a minimal implementation of a hooks contract. At a high level this contract includes:
- Base implementation: A complete implementation of the IHooks.sol interface, with each implemented function returning false.
- Configuration: A virtual function
getHookFlags
that must be implemented by your hooks contract, defining which hooks your contract supports.
By inheriting this contract a hooks developer can concentrate on implementing the subset of callbacks they are interested in and remain confident the rest of the interface requirement is covered. In the StableSurgeHookExample we override three functions:
getHookFlags
function getHookFlags() public pure override returns (HookFlags memory hookFlags){
hookFlags.shouldCallComputeDynamicSwapFee = true;
}
This is the only mandatory hook and can effectively be thought of as defining the hook config. When a pool is registered, the Vault calls this function to store the configuration. In this example, the shouldCallComputeDynamicSwapFee
flag is set to true, indicating that the contract is configured to calculate the dynamic swap fee.
onRegister
function onRegister(address factory, address pool, TokenConfig[] memory, LiquidityManagement calldata) public override onlyVault returns (bool) {
return factory == _allowedFactory && IBasePoolFactory(factory).isPoolFromFactory(pool);
}
The onRegister function enables developers to implement custom validation logic to ensure the registration is valid. When a new pool is registered, a hook address can be provided to “link” the pool and the hook. At this stage, the onRegister function is invoked by the Vault, and it must return true for the registration to be successful. If the validation fails, the function should return false, preventing the registration from being completed.
In this example we validate that the factory param forwarded from the Vault matches the _allowedFactory
set during the hook deployment, and that the pool was deployed by that factory.
onComputeDynamicSwapFeePercentage
The Vault calls onComputeDynamiceSwapFeePercentage
to retrieve the swap fee value. This is where the big brain logic for the hook is implemented. The actual code is fairly long but the pseudo-code looks like:
function onComputeDynamicSwapFeePercentage(
PoolSwapParams calldata params,
address pool,
uint256 staticSwapFeePercentage
) public view override onlyVault returns (bool, uint256) {
uint256 amountCalculatedScaled18 = StableMath.computeSwapResult(...swapParams);
uint256 weightAfterSwap = getWeightAfterSwap(balancesAfter);
if (weightAfterSwap > thresholdBoundary) {
return (true, getSurgeFee(weightAfterSwap, thresholdBoundary, staticSwapFeePercentage, _surgeCoefficient));
} else {
return (true, staticSwapFeePercentage);
}
}
Essentially the virtual weights of the tokens in the pool after the swap are calculated. If these are above a user defined threshold boundary a fee that is proportional to the weights distance from the threshold is returned. If not the normal static swap fee is used. The reader is encouraged to read the full code and theory to appreciate the implementation 🤓.
VaultGuard
The VaultGuard is a simple contract that shares the modifier onlyVault
. This ensures a function can only be called when the sender is the vault.
modifier onlyVault() {
_ensureOnlyVault();
_;
}
function _ensureOnlyVault() private view {
if (msg.sender != address(_vault)) {
revert IVaultErrors.SenderIsNotVault(msg.sender);
}
}
While it might seem overly cautious, especially for stateless hooks, it serves a crucial purpose. This restriction maintains predictable behavior and simplifies the reasoning about your contract’s state. It’s like having a bouncer at an exclusive club — sure, letting a few extra people in might not hurt, but it’s easier to manage when you stick to the guest list. This approach aligns with the standard lifecycle of Balancer pools, keeping the contract’s behavior consistent and secure. Of course, if the hook has state, permissioned functions, or any functions other than hook overrides, a more relaxed access policy can be appropriate.
Ownable
Ownable is actually an OpenZeppelin contract which “provides a basic access control mechanism, where there is an account (an owner) that can be granted exclusive access to specific functions.”
Here we are leveraging the onlyOwner
to restrict the use of the setThreshold
and setSurgeCoefficient
functions to the owner of the contract. Ownership is set in the constructor to be the contract deployer:
constructor(
IVault vault,
address allowedFactory,
uint256 threshold,
uint256 surgeCoefficient
) VaultGuard(vault) Ownable(msg.sender)
function setThreshold(uint64 newThreshold) external onlyOwner {
_threshold = newThreshold;
}
function setSurgeCoefficient(uint64 newSurgeCoefficient) external onlyOwner {
_surgeCoefficient = newSurgeCoefficient;
}
FixedPoint
FixedPoint is a very useful library that supports 18-decimal fixed point arithmetic. All Vault calculations use this for high and uniform precision. In this example we use it to calculate the swap fee. Some of the commonly used functions are:
- mulDown/Up
- divDown/Up
- powDown/Up which use the LogExpMath library Exponentiation and logarithm functions for 18 decimal fixed point numbers.
Testing
Testing in production is tempting but risky! The Balancer V3 mono-repo contains extensive tests and exposes a particularly useful BaseVaultTest
contract that external teams are already leveraging during their own development. A few of the high level benefits include:
- A default setup (that is also customizable) that handles all V3 related deployments including Vault, Router, Authorizer, etc and all associated approvals
- Deployment of test tokens and initial seeding of balances for test accounts (test tokens take decimals as an argument, so you can construct them with different decimals if needed)
- Easily handle deployment of your custom pools and hooks along with initial pool seeding and LP interactions (including all required approvals for common actions)
- Helpers to get account balances (including pool balances), vault balances and hook balances
The detail of BaseVaultTest could probably be a post in itself so instead we will look at some specific examples of how I leveraged some of the functionality in my tests for the hook, StableSurgeExample.t.sol.
Test Pool And Hook Setup
As mentioned previously the StableSurge hook is configured to only work with a user configured pool factory. In this test, because the hook is expected to be used with Balancer StablePools, I want to make sure we use the StablePoolFactory. To achieve this we can override the createHook
function which is called during the initial BaseVault setup:
function createHook() internal override returns (address) {
stablePoolFactory = new StablePoolFactory(IVault(address(vault)), 365 days, "Factory v1", "Pool v1");
// LP will be the owner of the hook.
vm.prank(lp);
address stableSurgeHook = address(
new StableSurgeHookExample(IVault(address(vault)), address(stablePoolFactory), THRESHOLD, SURGE_COEFFICIENT)
);
vm.label(stableSurgeHook, "Stable Surge Hook");
return stableSurgeHook;
}
Fairly simple to follow, it deploys the StablePoolFactory and uses that address as part of the constructor input when deploying the StableSurgeHookExample. The address of the stableSurgeHook is returned at the end of the function and the BaseVaultTest exposes this via the poolsHookContract
variable so it can be used later. Also interesting to note here is the hook is deployed by the lp
account which will become the hook owner.
Next to override is the _createPool
function which handles the actual pool deployment:
function _createPool(address[] memory tokens, string memory label) internal override returns (address) {
PoolRoleAccounts memory roleAccounts;
address newPool = address(
stablePoolFactory.create(
"Stable Pool Test",
"STABLE-TEST",
vault.buildTokenConfig(tokens.asIERC20()),
AMP_FACTOR,
roleAccounts,
MIN_SWAP_FEE,
poolHooksContract,
false, // Does not allow donations
false, // Do not disable unbalanced add/remove liquidity
ZERO_BYTES32
)
);
vm.label(newPool, label); authorizer.grantRole(vault.getActionId(IVaultAdmin.setStaticSwapFeePercentage.selector), admin);
vm.prank(admin);
vault.setStaticSwapFeePercentage(newPool, SWAP_FEE_PERCENTAGE);
return newPool;
}
The StableSurge hook is expected to be used with Balancer Stable Pools so unlike some other hook tests I need to make sure I’m not testing with the default MockPool. I use the stablePoolFactory
to create a new pool that is configured to use our previously deployed hook, poolHooksContract
. The last part of this process is to use the authorizer to set the pool static swap fee. This will be the expected fee when the pool is not “surging”.
And thats it! Now whenever we run our test (using: $ forge test --match-path test/foundary/StableSurgeExample.t.sol
) the setUp function will be called and everything is deployed, seeded and ready for tests.
Testing Balances
The final helper we’ll check out is getBalances
which can be found here. This function extracts and returns a collection of essential balances, encompassing test user pool and token balances, hook balances, and vault balances. It’s an invaluable tool for validating correct balance adjustments following operations, streamlining the testing process considerably:
function testSwapBelowThreshold() public {
BaseVaultTest.Balances memory balancesBefore = getBalances(lp);
// Calculate the expected amount out (amount out without fees)
uint256 poolInvariant = StableMath.computeInvariant(
AMP_FACTOR * StableMath.AMP_PRECISION,
balancesBefore.poolTokens
);
uint256 expectedAmountOut = StableMath.computeOutGivenExactIn(
AMP_FACTOR * StableMath.AMP_PRECISION,
balancesBefore.poolTokens,
daiIdx,
usdcIdx,
amountInBelowThreshold,
poolInvariant
);
// Swap with amount that should keep within threshold
vm.prank(bob);
router.swapSingleTokenExactIn(pool, dai, usdc, amountInBelowThreshold, 0, MAX_UINT256, false, bytes(""));
BaseVaultTest.Balances memory balancesAfter = getBalances(lp);
// Check Bob's balances (Bob deposited DAI to receive USDC)
assertEq(
balancesBefore.bobTokens[daiIdx] - balancesAfter.bobTokens[daiIdx],
amountInBelowThreshold,
"Bob DAI balance is wrong"
);
...
There is also an alternative implementation that allows balances to be tracked across user defined tokens/pools.
Conclusion
Hopefully this has given a helpful intro to some of the options available to improve the experience and efficiency while developing on top of Balancer V3. It really is easy and quick to get going so take some time and hack around and please reach out anytime if you have any questions or suggestions!