Inside Balancer Contracts — BasePool
Let’s dive into our base building block when it comes to pools, the abstract BasePool contract.
Full credit for this BasePool deep dive goes to Beethoven X member 0xSkly. Balancer’s strength comes from its ecosystem and those using/ building upon the Balancer technology.
Now that we have talked about pool permissions and the new recovery mode, it’s time to actually start talking about the real deal — pools! This is gonna be a rather long article, and I’m sorry for that! But there is just too much good stuff to uncover, and splitting it up much would require too much context switching to get the full picture.
A DeFi Building Block
Balancer already provides a number of different pools, from Stable pools to Weighted pools and many more. But remember, Balancer tech has to be seen as a DeFi building block. So you won’t be surprised to see that they provide you with all the tools to actually create your own specialized pool. And guess what, that’s what we are gonna do! But not this time; we are not quite there yet. Still gotta get some fundamentals done. And you know me (probably not really), I’m all about fundamentals! So let’s dive into our base building block when it comes to pools, the abstract
Inside the BasePool
BasePool contract provides you with all the key mechanics to build out a pool with the high standards one would expect from a pool coming from Balancer Labs. If you see a pool based on this base contract, you know it’s no joke! To prove my claims, let’s jump right into it.
BasePool contract comes with the following base functionality.
Implementing pools can utilize the
authenticate modifier to protect its functions with the sophisticated authorization mechanisms covered in this article.
BasePool inherits from the
TemporarilyPausable contract, which provides an emergency pause feature within the first 30 days of factory deployment. Once the 30 days are over and a pool is still paused, it remains paused for an additional maximum of 90 days until it gets automatically unpaused. A paused pool cannot take any trades, joins, or exits. It’s literally halted!
Swap Fee management
A rather small but still important feature is management of the swap fee percentage. It makes sure that the swap fee stays between the configured minimum and maximum percentage, which defaults to a range of 0.0001% to 10%.
We have not yet talked about the
Vault contract, which many would rank as one of the most innovative building blocks from Balancer Labs. Unfortunately, it doesn’t make sense to get into the details of it now because it’s just too amazing and deserves its own spotlight. So I’ll have to cover it in a later piece, no way around that! But for now, you have to live with my short summary:
Vault holds all pool tokens and performs the related bookkeeping. It serves as a single entry point for pool interactions like joins, exits, and swaps and delegates to the respective pools. With that in mind, let’s see how the
BasePool integrates with the
We see that the pool is registering itself with the
Vault with its specialization in return for a
poolId. We’ll dig into pool specialization and
poolId generation another time, otherwise we’ll get nowhere. In short, the pool specialization is for gas optimizations when swapping and the
poolId encodes the BPT address with its specialization for more gas optimizations. Now that we are hooked up to the
Vault, it will call our liquidity management hooks,
onExitPool which leads us to our next topic.
BasePool provides some base functionality for adding and removing liquidity. We start with the
onJoinPool hook, which also acts as a pool initializer if it’s the first join.
Oh boy, there is quite some stuff going on here. Let’s try to make some sense of it. So we claimed that everything gets routed through the
Vault right? We see that this is enforced by the
It checks if the
msg.sender is actually the
Vault and if the provided
poolId is actually the
poolId of this pool. Fair enough, rather not mess that up! Knowing that this call is indeed coming from the
Vault we can assume that the arguments require no further validation. See the comments on the arguments for some context. We also check if the pool is not paused
_ensureNotPaused() and with that out of the way, we are ready for some business logic. Right away, we see that there is a different execution branch when
totalSupply == 0 meaning this is the first join ever and, therefore, the pool initialization flow. We shouldn't skip this section as there are some interesting details to uncover!
First, we call the
_onInitializePool(..) handler, which has to be implemented by the implementing pool. The handler has to return
- amount of BPT to mint
- token amounts the pool will receive
Next, we do something interesting, we actually mint the amount returned from
_getMinimumBpt(), which defaults to
1e6 BPT, to the zero address acting as a buffer for rounding errors and prevents the pool from ever getting completely drained. Neat! The remaining BPT is minted to the recipient.
Now we come to a nifty little detail, but it's actually quite an important one that could lead to severe issues when not handled correctly by the implementation — the scaling factors. You might have noticed that we just skipped over the very first statement
uint256 memory scalingFactors = _scalingFactors() where
_scalingFactors() has to be overridden by the implementing contract. So what are those scaling factors? Well, once more, it's about decimals. Not all tokens have the same amount of decimals, so to make the math work, all tokens get normalized to 18 decimals. The
BasePool provides a helper function for that
So now that we know what those scaling factors are, let us look at the last statement of the pool initialization flow
_downscaleUpArray(amountsIn, scalingFactors). As the name suggests, it downscales the
amountsIn by the
scalingFactors where the result is scaled up, therefore
downscaleUp. Now, this call reveals that the
amountsIn returned from the implementing contract have to be upscaled, which is also stated in the NatSpec
The tokens granted to the Pool will be transferred from `sender`. These amounts are considered upscaled and will be downscaled (rounding up) before being returned to the Vault.
Something we have to keep in mind when we create our own pool!
At last, we return the
amountsIn to the
Vault together with an empty array for the due protocol fees.
Ok, so we have covered the initialization flow. Now let’s move on to the
else branch which represents the regular joins.
We start by
_upscaleArray(balances, scalingFactors) The
balances are the total token balances in the
Vault for this pool which are normalized to 18 decimals. We then delegate to the implementation with the
_onJoinPool call, which returns us the amount of BPT to mint as well as the normalized
amountsIn . Same as with the pool initialization flow, we mint the BPT to the recipient and return the downscaled (or denormalized)
amountsIn together with an empty array for the due protocol fees back to the
But wait a second! Why is the due protocol fee hardcoded to an empty array here? I mean, it does make sense for the initialization flow, since nobody traded yet, there is obviously no protocol fee to collect, but on regular joins, we should collect some at some point, right? Guess what? We found another new development from Balancer Labs. Seems they have changed the way protocol fees are collected! Bear with me, we’ll get to it, but first things first, let’s talk about exiting the pool.
A little shorter than the join, but still a lot going on here. The arguments are the same as for the join, so I did not add comments this time.
First, we handle our new feature, dealing with recovery mode exits, which I already dedicated a full article to. Additionally, we make sure that the pool is not paused and start again by normalizing the token balances with
upscaleArray(balances, scalingFactors). Nothing new here, we are starting to feel comfy in here, aren’t we? We then delegate to the implementation, receiving the BPT to burn and the
amountsOut. We downscale the
amountsOut so they are ready to be returned to the
Vault. Finally, we burn the BPT and return the
amountsOut together with an empty array for the due protocol fees to the
Vault. Easy enough! But now, what happened to those protocol fees? Let’s dive into it!
Protocol Fee Management
As you might remember, until now, protocol fees were paid in one of the underlying assets of the pool and transferred to the protocol fee collector by piggybacking on joins and exits. That is why the
Vault accepts a second parameter in the return value of joins and exits, the due protocol fee. But now that we are returning a hardcoded empty array, how are those fees paid? Well, there is a new function available
Nothing too spectacular, but it shows a paradigm shift. Instead of paying the protocol fee in an underlying token, it mints BPT to the protocol fee collector. But who is calling it? It's not being called in the
BasePool, so I looked around, and it's for the implementing pools to figure out a gas-efficient way of keeping track of the due BPT to be paid as protocol fees and call this handler. We’ll see more about that once we dive into specific pool implementations!
This might seem like a rather small change, but it actually opens up a whole lot of possibilities. For example, right now, it’s a chore to figure out which pool paid how much into the protocol fee collector, which makes things like kickbacks much harder to implement, especially on chain. But with this mechanism, this seems quite possible. But exploring the possibilities of this change probably deserves its own article and a bigger financial visionary than I am.
Now that we have covered the core functionality provided by the
BasePool contract, we can safely say that it’s an amazing building block for creating pools utilizing the Balancer ecosystem. There are some pitfalls, and one still needs to understand a good chunk of the Balancer tech to safely integrate a new pool, but once you know the fundamentals, it’s almost easy!
This was quite a long journey, but I hope you still enjoyed it! Soon we’ll be able to try and create our own little pool type, maybe an easy 50/50 pool, who knows. But we are not quite there yet, there are still some missing pieces. Or who wants to provide liquidity to a pool you cannot trade?