joinPool()call in Balancer V2. We'll follow the process all the way from the top level call at the Vault to the internal pool hooks in a WeightedPool.
Token Band wants to join a Token A/Token B Balancer Pool.
poolIdof the desired pool, and crafts a join (and exit) call.
joinPool()in the Vault.
exitPool()calls happen through the Vault. The Vault receives and sends all tokens, while asking the pool contract how many tokens or Balancer Pool Tokens to exchange. We're going to start out with a
ERC20s. Finally, we get to
_validateTokensAndGetBalances(). This function ultimately calls
_getPoolTokens()for our given pool and verifies that the tokens we're attempting to join with match those registered to the pool.
_getPoolTokens()is defined in PoolTokens.sol. For this example we'll follow the join for a WeightedPool, which is of specialization type
_getMinimalSwapInfoPoolTokens()brings us to
_minimalSwapInfoPoolsTokensdefined here, and then we use those to fill the
tokens. Similarly, we grab the balances from
_minimalSwapInfoPoolBalancesdefined here, and shove them into
balances. From here, we jump back up the call stack with our
poolId. Next (lines 4-22 in this above code block, and lines 177-195 in the contract) we call the pool's
onJoinPool()hook after the ternary operator indicates that our
PoolBalanceChangeKindis of type
0s. When the protocol fee is activated, however, we calculate the due amounts in
WeightedMath.sol. Here, we pass
_maxWeightTokenIndexbecause the WeightedPool collects fees denominated in the token with the highest weight. This is done to have the protocol fee collection create the smallest price impact while collecting an underlying token. There are techniques for creation no price impact whatsoever, but that's outside the scope of this explanation.
_calcDueTokenProtocolSwapFeeAmount(). The comments at the top of the function give a math explanation of what we're calculating. By dividing the
currentInvariantand scaling that value by the
weight, what we're really calculating is the value by which the pool has grown denominated in
_maxWeightTokendue to swap fees between the last join/exit. The protocol fee is a percentage of this growth, and rounds down (in favor of the pool and its liquidity providers).
dueProtocolFeeAmounts, we do the Vault's bookkeeping to remove those from the pool. We then call the
_doJoin()hook with the
JoinKindand either exact input amounts or exact output BPT amount encoded in
EXACT_TOKENS_IN_FOR_BPT_OUT, which tells you how many BPT you get for given input tokens. We therefore move to
balanceRatiosWithFeefor all input tokens. We also accumulate
invariantRatioWithFeesby adding the amount by which each token increases the invariant. Notice how if all inputs are proportional to the tokens already in the pool, the
balanceRatiosWithFeewill all be equivalent, and will also be equivalent with the
nonTaxableAmounts, which are the token input amounts that maintain the pool's balance, and the
taxableAmounts, which unbalance the pool. We calculate each token's
amountInWithoutFee, which is
nonTaxableAmount + taxableAmount*(1-swapFee)(for a balanced join, this just reduces down to
nonTaxableAmount). As we accumulate the weighted product of these
invariantRatio, what we're effectively doing is determining the proportion of new BPT to mint while charging a swap fee denominated in BPT of the amounts that are unbalancing the pool.
_joinExactTokensInForBPTOut()in WeightedPool.sol, and then return those from
onJoinPool()call. We calculate and store the current invariant so that we can determine due protocol fees during the next join/exit.
_invariantAfterJoin()calculates the invariant accounting for the tokens deposited in this join. We now return the BPT due to the depositor, the amounts of tokens they need to supply, and the amounts of tokens that will be collected by the protocol.
onJoinPool()in BasePool.sol, where we now mint the
bptAmountOutthat we just calculated.
dueProtocolFeeAmounts) by their respective decimals. For tokens entering/exiting the pool, we round up/down respectively to ensure that the pool is not susceptible to a rounding error attack. As such,
amountsInrounds up and
_receiveAsset()is a relatively straightforward function that handles users sending tokens to the Vault; it wraps any ETH into WETH, accepts ERC20 tokens, and accepts partial and full internal balances. If the join does pull tokens from internal user balances, this will call
minof the requested amount and the internal balance. This allows for joins being able to be sourced entirely from internal balance or partially and supplemented with standard ERC20 balances.
_payFeeAmount()to collect any protocol fees during the join.
_processJoinPoolTransfers(), we calculate the new balances in the pool. Even though we are adding tokens with the join, we may also be removing tokens with the protocol fee collection; therefore, it's possible that there could be a net decrease in balances. We'll record these updates in the Vault in the next step.
_joinOrExit(), we finally emit the
PoolBalanceChangedevent to announce the changes that our join has created.
joinPool(), where we started. As
_joinOrExit()is the only call in
joinPool(), we've now finished our deposit.
joinPool()call works! I invite you to explore the codebase to see how different pool specializations and pool types behave in their own ways. If you follow the
exitPool()call similarly through the codebase, you'll find many similarities; they also need to convert between pool tokens and BPTs, and protocol fees are collected on both exits and joins.