I am going to do something different today. This post is a collaboration with my online fren Andrew (FOLLOW DIS CHAD PLS), whom I met in the Bankless Discord server. I will be doing my usual thing, dissecting DeFi protocols, and Andrew will do his data analytics magic on the protocol in another article to be published later.
Balancer protocol is an automated portfolio manager, liquidity provider and price sensor. Liquidity providers who want to earn trading fees and rebalance their portfolios can deposit their assets into a balancer pool. Arbitrageurs help liquidity providers to rebalance their portfolios by trading against the pools’ assets.
Contrary to Uniswap, which only holds two tokens in each pool, a balancer pool can hold up to eight tokens. A pool’s underlying token weights and trading fees are set when it is initialized. The pool’s weights are rebalanced when a trade happens to ensure each token maintains a proportional value to the rest of the pool. The protocol executes each trade by finding the most profitable route (Smart Order Routing), taking into account the amount, fees and gas costs.
Balancer V2 can be a cheaper protocol for traders because it is designed in a way that makes each trade consume less gas. Even if a trader’s input and output tokens belong to different pools, which in a typical decentralized exchange requires ERC-20 transfers to hop along the token path, Balancer V2 does not require the ERC-20 hops in the middle. Its vault keeps track of each pool’s virtual balance and only makes the final ERC-20 transfers at the very end. Traders can even deposit their tokens into the vault and trade without the tokens leaving the vault, thus saving even more on gas.
The main differences between Balancer’s V2 and V1 protocols are the following:
All interactions with Balancer V2 are done through a single vault.
The vault holds all underlying assets instead of the pool. The pool only handles the AMM logic and it is customizable.
Improved gas efficiency. Trading against multiple pools will not increase the gas used by a lot because only the net amounts are transferred. On top of that, trading through internal balances will cost even less as it involves no ERC-20 transfers.
Asset managers can decide what they want to do with the tokens, such as lending to another protocol for yield. This improves capital efficiency by putting it to work.
The use of sandwich attack resistant oracles that use price accumulators.
Trading/Withdrawal/Flash Loan fees can be set through governance.
For more details, check out Balancer’s blog post.
Currently there are three types of pools, weighted (constant weight), stable (good for tokens that are soft pegged to each other) and smart (configurable parameters).
On top of the three pool types, Balancer V2's custom AMM logic allows anyone to build new pool types with customized price curve.
We will focus on the weighted pools.
The Vault
The vault is inherited from a list of contracts, divided by functionalities for maintainability.
AssetManagers - functions for asset managers to manage pool funds
Fees - functions to manage protocol fees
FlashLoans - functions to execute flash loans
PoolBalances - functions to join and exit pools
PoolRegistry - functions to manage pools
PoolTokens - functions to manage pool tokens
Swaps - functions to swap tokens
UserBalance - functions to manage user balances
VaultAuthorization - access control, relayers and signature validation
Creating a pool
A pool can be created through a pool factory. The function WeightedPoolFactory#create
creates a weighted pool and registers the pool. It has the following properties:
name
symbol
tokens
normalized weights
swap fee percentage
pause window duration - allows for a contract to be paused during an initial period after deployment (max 90 days)
buffer period duration - if the contract is paused when the pause window finishes, it will remain in the paused state through an additional buffer period, after which it will be automatically unpaused forever (max 30 days)
Each token’s minimum normalized weight is 1%.
uint256 internal constant _MIN_WEIGHT = 0.01e18;
/* Validates each token's normalized weight */
uint256 normalizedWeight = normalizedWeights[i];
_require(normalizedWeight >= _MIN_WEIGHT, Errors.MIN_WEIGHT);
It loops through the tokens in the pool and finds the token with the maximum normalized weight.
uint256 normalizedSum = 0;
uint256 maxWeightTokenIndex = 0;
uint256 maxNormalizedWeight = 0;
for (uint8 i = 0; i < numTokens; i++) {
uint256 normalizedWeight = normalizedWeights[i];
_require(normalizedWeight >= _MIN_WEIGHT, Errors.MIN_WEIGHT);
normalizedSum = normalizedSum.add(normalizedWeight);
if (normalizedWeight > maxNormalizedWeight) {
maxWeightTokenIndex = i;
maxNormalizedWeight = normalizedWeight;
}
}
The sum of normalized weight must be 100%.
_require(normalizedSum == FixedPoint.ONE, Errors.NORMALIZED_WEIGHT_INVARIANT);
It then sets each token’s normalized weight.
/* X ranges from 0 to 7, since there can be 8 tokens in the pool */
_normalizedWeightX = normalizedWeights.length > X ? normalizedWeights[X] : 0;
The pools are stored in PoolRegistry
, which maintains a mapping _isPoolRegistered
. The pool’s ID is created by packing the pool’s nonce, specialization and address. It is created running bitwise OR on the address, specialization and nonce.
bytes32 serialized;
serialized |= bytes32(uint256(nonce));
serialized |= bytes32(uint256(specialization)) << (10 * 8);
serialized |= bytes32(uint256(pool)) << (12 * 8);
The reason to create a pool ID through bitwise OR is to save gas, as it does not access the contract storage when the pool’s address is needed.
function _getPoolAddress(bytes32 poolId) internal pure returns (address) {
// 12 byte logical shift left to remove the nonce and specialization setting. We don't need to mask,
// since the logical shift already sets the upper bits to zero.
return address(uint256(poolId) >> (12 * 8));
}
The SLOAD opcode (state access) costs 2100 gas while the SHR opcode (logical shift right) costs 3 gas.
A pool’s specialization can be GENERAL, MINIMAL_SWAP_INFO (saves gas by only passing the balance of the two tokens involved in the swap) or TWO_TOKEN (only allows two tokens to be registered).
Joining a pool
A user (or a relayer approved by the user) can join a pool by calling PoolBalances#joinPool
. It accepts the argument JoinPoolRequest
, which looks like:
struct JoinPoolRequest {
IAsset[] assets;
uint256[] maxAmountsIn;
bytes userData;
bool fromInternalBalance;
}
If fromInternalBalance
is set to true, the caller’s internal balance will be preferred and ERC-20 transfers will be made as an internal accounting change instead of an actual token transfer.
It converts the assets into IERC20 interfaces, and if an asset is ETH, converts it to WETH.
IERC20[] memory tokens = _translateToIERC20(change.assets);
It gets the pool’s tokens and their balances. It makes sure the tokens expected and the actual tokens in the pool are the same.
bytes32[] memory balances = _validateTokensAndGetBalances(poolId, tokens);
/* Validation inside _validateTokensAndGetBalances */
for (uint256 i = 0; i < actualTokens.length; ++i) {
_require(actualTokens[i] == expectedTokens[i], Errors.TOKENS_MISMATCH);
}
/* Pool balances are stored as an enumerable map to save on gas by implicit storage reads for out-of-bounds checks */
mapping(bytes32 => EnumerableMap.IERC20ToBytes32Map) internal _generalPoolsBalances;
/* Getting pool token balances inside _getGeneralPoolTokens */
EnumerableMap.IERC20ToBytes32Map storage poolBalances = _generalPoolsBalances[poolId];
tokens = new IERC20[](poolBalances.length());
balances = new bytes32[](tokens.length);
for (uint256 i = 0; i < tokens.length; ++i) {
// Because the iteration is bounded by `tokens.length`, which matches the EnumerableMap's length, we can use
// `unchecked_at` as we know `i` is a valid token index, saving storage reads.
(tokens[i], balances[i]) = poolBalances.unchecked_at(i);
}
unchecked_at
gets the pool balance entry at index i
and is O(1)
constant. See here for more information.
After fetching the pool’s balances, it updates the pool’s balances by adding the liquidity provider’s tokens.
(
bytes32[] memory finalBalances,
uint256[] memory amountsInOrOut,
uint256[] memory paidProtocolSwapFeeAmounts
) = _callPoolBalanceChange(kind, poolId, sender, recipient, change, balances);
A pool’s token balance consists of cash in vault and managed balance deployed to other vaults by asset managers.
cash(balance) + managed(balance)
balance
is a uint256 variable that contains cash and managed both as a uint112 variable, and the remaining 32 bits are to store the most recent block when the total balance changed (said to implement price oracles that are resilient to ‘sandwich’ attacks).
The pool’s due protocol fee amounts is calculated with the formula
swap fee percentage x token balance x (1 - (previous invariant ÷ current invariant) ** (1 ÷ token weight))
The definition of invariant according to the code comments: Due protocol swap fee amounts are computed by measuring the growth of the invariant between the previous join or exit event and now - the invariant's growth is due exclusively to swap fees.1
The fee amount is paid using the token with the largest weight in the pool to avoid unbalancing the pool.
dueProtocolFeeAmounts[_maxWeightTokenIndex] = WeightedMath._calcDueTokenProtocolSwapFeeAmount(
balances[_maxWeightTokenIndex],
normalizedWeights[_maxWeightTokenIndex],
previousInvariant,
currentInvariant,
protocolSwapFeePercentage
);
/* last invariant is stored as a variable */
uint256 private _lastInvariant;
/* current invariant */
uint256 invariantBeforeJoin = WeightedMath._calculateInvariant(normalizedWeights, balances);
The invariant is the product of each token balance to the power of each token weight.
/* _____
* wi = weight index i | | wi
* bi = balance index i | | bi^ = i
* i = invariant
*/
The due protocol fee amount is subtracted from the token balances.
_mutateAmounts(balances, dueProtocolFeeAmounts, FixedPoint.sub);
The protocol calculates the amount of balance pool tokens to mint (The math is quite complex and the documentation does a very good job explaining it here).
uint256 bptAmountOut = WeightedMath._calcBptOutGivenExactTokensIn(
balances,
normalizedWeights,
amountsIn,
totalSupply(),
_swapFeePercentage
);
The new variant is calculated after adding the deposited amounts to the pool’s token balances.
_mutateAmounts(balances, amountsIn, FixedPoint.add);
_lastInvariant = WeightedMath._calculateInvariant(normalizedWeights, balances);
The balance pool token amount to minted to the recipient.
_mintPoolTokens(recipient, bptAmountOut);
Finally, it processes the pool transfers. If the user wants to transfer his internal token balance to the pool, the vault will deduct his internal token balance up to the transfer amount. In the case where his internal balance cannot cover the transfer amount, the vault will initiates an ERC-20 token transfer for the remaining.
if (fromInternalBalance) {
uint256 deductedBalance = _decreaseInternalBalance(sender, token, amount, true);
amount -= deductedBalance;
}
if (amount > 0) {
token.safeTransferFrom(sender, address(this), amount);
}
It pays the protocol fees collector if there is any due amount.
token.safeTransfer(address(getProtocolFeesCollector()), feeAmount);
The final token balances are adjusted (Cash balance can decrease if the fee > input amount
but the logic is omitted).
finalBalances[i] = balances[i].increaseCash(amountIn - feeAmount);
Pool balances are set to the new final balances.
EnumerableMap.IERC20ToBytes32Map storage poolBalances = _generalPoolsBalances[poolId];
for (uint256 i = 0; i < finalBalances.length; ++i) {
poolBalances.unchecked_setAt(i, finalBalances[i]);
}
Exiting a pool
A user (or a relayer approved by the user) can exit a pool by calling PoolBalances#exitPool
. It shares the logic with joinPool
except it’s a reverse operation.
It calculates the due protocol fee amount (same formula as during join pool) and subtract it from the pool balances.
uint256 invariantBeforeExit = WeightedMath._calculateInvariant(normalizedWeights, balances);
dueProtocolFeeAmounts = _getDueProtocolFeeAmounts(
balances,
normalizedWeights,
_lastInvariant,
invariantBeforeExit,
protocolSwapFeePercentage
);
_mutateAmounts(balances, dueProtocolFeeAmounts, FixedPoint.sub);
Given the amount of balancer pool tokens to burn, the token amounts to be withdrawn is calculated using WeightedMath
. (Again, check the math here)
uint256 bptAmountIn = userData.exactBptInForTokensOut();
uint256[] memory amountsOut = WeightedMath._calcTokensOutGivenExactBptIn(balances, bptAmountIn, totalSupply());
Then it updates the invariant.
_lastInvariant = _invariantAfterExit(balances, amountsOut, normalizedWeights);
Balancer pool tokens are burned.
_burnPoolTokens(sender, bptAmountIn);
It loops through each token to be sent. If the user wants to receive internal balances instead of ERC-20 transfers, the protocol will increase his balance instead.
IERC20 token = _asIERC20(asset);
if (toInternalBalance) {
_increaseInternalBalance(recipient, token, amount);
} else {
token.safeTransfer(recipient, amount);
}
Protocol due fee amount is paid out.
token.safeTransfer(address(getProtocolFeesCollector()), amount);
The token withdrawal amount (and fee) is decreased from the vault’s cash balance.
finalBalances[i] = balances[i].decreaseCash(amountOut.add(feeAmount));
Pool balances are set to the new final balances.
EnumerableMap.IERC20ToBytes32Map storage poolBalances = _generalPoolsBalances[poolId];
for (uint256 i = 0; i < balances.length; ++i) {
poolBalances.unchecked_setAt(i, balances[i]);
}
Relayers vs Asset Managers
In the protocol, a relayer is not the same as an asset manager. A relayer is an address that is authorized by the protocol and users to make calls on behalf of the users. It cannot manage the pool’s assets. On the other hand, an asset manager is, as the name suggests, an address that is authorized to manage the vault’s assets by withdrawing from the vault. It however does not make function calls to the vault on behalf of users.
Vault Authorization
Joining or exiting a pool and swapping tokens can be done by an approved relayer instead of the message sender. Before a relayer is authorized to do so, he has to call VaultAuthorization#setRelayerApproval
.
_approvedRelayers[sender][relayer] = approved;
After having approved
being set to true
, when a relayer wants to call the vault on behalf of the EOA, the modifier VaultAuthorization#authenticateFor
checks if the relayer is authorized.
It first authenticates the caller.
/* msg.sig is the first four bytes of the calldata (i.e. function identifier) */
bytes32 actionId = getActionId(msg.sig);
_require(_canPerform(actionId, msg.sender), Errors.SENDER_NOT_ALLOWED);
Each action ID is a “role” and the relayer (account) needs to belong to the role to be authenticated.
_roles[role].members.contains(account);
The authentication ends if the sender has previously approved the relayer, but the sender can also do a one-time authorization by appending a signature.
if (!_hasApprovedRelayer(user, msg.sender)) {
_validateSignature(user, Errors.USER_DOESNT_ALLOW_RELAYER);
}
Without going into too much details, a signature is consisted of v, r
and s
(standard for ECDSA). The Solidity function ecrecover
can recover the signer’s address and then used to compare with the function’s caller to make sure the signature is valid.
(uint8 v, bytes32 r, bytes32 s) = _signature();
address recoveredAddress = ecrecover(digest, v, r, s);
// ecrecover returns the zero address on recover failure, so we need to handle that explicitly.
return recoveredAddress != address(0) && recoveredAddress == user;
Managing user balances
We have talked a lot about internal balances, but what are internal balances? Internal balances are tokens a user has sent to the vault and they are recorded in the vault’s accounting ledger, instead of transferring the user’s ERC-20 tokens every time when a trade happens. The user can call UserBalance#manageUserBalance
to deposit/withdraw/transfer his tokens.
Each operation has the following properties:
kind (DEPOSIT_INTERNAL, WITHDRAW_INTERNAL, TRANSFER_INTERNAL, TRANSFER_EXTERNAL)
asset
sender
recipient
The vault manages a data structure called the _internalTokenBalance
.
/* user address => ERC-20 address => balance */
mapping(address => mapping(IERC20 => uint256)) private _internalTokenBalance;
When the operation type is DEPOSIT_INTERNAL, it transfers the user’s tokens to the vault and increases the user’s internal balance.
_increaseInternalBalance(recipient, _translateToIERC20(asset), amount);
_receiveAsset(asset, amount, sender, false);
When the operation type is WITHDRAW_INTERNAL, it transfers the tokens back to the user and decreases the user’s internal balance.
_decreaseInternalBalance(sender, _translateToIERC20(asset), amount, false);
_sendAsset(asset, amount, recipient, false);
When the operation type is TRANSFER_INTERNAL, no ERC-20 transfers are conducted, it only decreases the sender’s internal balance and increases the recipient’s internal balance by the transfer amount.
_decreaseInternalBalance(sender, token, amount, false);
_increaseInternalBalance(recipient, token, amount);
When the operation type is TRANSFER_EXTERNAL, it users the vault’s ERC-20 allowances to transfer tokens from the sender to the recipient.
token.safeTransferFrom(sender, recipient, amount);
Swapping tokens
Swapping a single token can be done by calling Swaps#swap
. A swap requires the poolId, kind (GIVEN_IN, GIVEN_OUT), assetIn, assetOut, amount
and userData.
A swap has a deadline
before it is expired, the amount must be greater than 0, the swap assets must be different.
_require(block.timestamp <= deadline, Errors.SWAP_DEADLINE);
_require(singleSwap.amount > 0, Errors.UNKNOWN_AMOUNT_IN_FIRST_SWAP);
IERC20 tokenIn = _translateToIERC20(singleSwap.assetIn);
IERC20 tokenOut = _translateToIERC20(singleSwap.assetOut);
_require(tokenIn != tokenOut, Errors.CANNOT_SWAP_SAME_TOKEN);
The vault calculates the the amount in and amount out for the swap.
(amountCalculated, amountIn, amountOut) = _swapWithPool(poolRequest);
When calculating the amount (amountCalculated
), the vault changes the input and output tokens’ balances.
/* Get the tokens' index in the pool balances */
uint256 indexIn = poolBalances.unchecked_indexOf(request.tokenIn);
uint256 indexOut = poolBalances.unchecked_indexOf(request.tokenOut);
/* Get the input and output tokens' balances */
for (uint256 i = 0; i < tokenAmount; i++) {
bytes32 balance = poolBalances.unchecked_valueAt(i);
if (i == indexIn) {
tokenInBalance = balance;
} else if (i == indexOut) {
tokenOutBalance = balance;
}
}
amountCalculated = pool.onSwap(request, currentBalances, indexIn, indexOut);
(uint256 amountIn, uint256 amountOut) = _getAmounts(request.kind, request.amount, amountCalculated);
/* Update input and output tokens' cash balances */
tokenInBalance = tokenInBalance.increaseCash(amountIn);
tokenOutBalance = tokenOutBalance.decreaseCash(amountOut);
The vault takes a fee.
swapRequest.amount = _subtractSwapFeeAmount(swapRequest.amount);
The output amount is calculated using WeightedMath
(again, check the documentation for reference).
WeightedMath._calcOutGivenIn(
currentBalanceTokenIn,
_normalizedWeight(swapRequest.tokenIn),
currentBalanceTokenOut,
_normalizedWeight(swapRequest.tokenOut),
swapRequest.amount
);
After having the output amount, the actual transfer is being conducted. Once again, if the user wants to use his internal balance, only the accounting ledger is updated and no ERC-20 transfers are conducted.
_receiveAsset(singleSwap.assetIn, amountIn, funds.sender, funds.fromInternalBalance);
_sendAsset(singleSwap.assetOut, amountOut, funds.recipient, funds.toInternalBalance);
Batch swapping tokens can be done by calling Swaps#batchSwap
. It accepts an array of BatchSwapStep
, which is an array of input and output tokens and the corresponding trade amounts. It loops through each swap to update the pools’ cash balances and also update the asset deltas for the final transfer.
For hops in the middle of the swaps, the swap amount can be set to 0 so that the swap will use the last swap’s output amount, as long as the token path is correct.
if (batchSwapStep.amount == 0) {
_require(i > 0, Errors.UNKNOWN_AMOUNT_IN_FIRST_SWAP);
bool usingPreviousToken = previousTokenCalculated == _tokenGiven(kind, tokenIn, tokenOut);
_require(usingPreviousToken, Errors.MALCONSTRUCTED_MULTIHOP_SWAP);
batchSwapStep.amount = previousAmountCalculated;
}
The vault deltas across swaps are accumulated (Increase input token amount and decrease output token amount).
assetDeltas[batchSwapStep.assetInIndex] = assetDeltas[batchSwapStep.assetInIndex].add(amountIn.toInt256());
assetDeltas[batchSwapStep.assetOutIndex] = assetDeltas[batchSwapStep.assetOutIndex].sub(amountOut.toInt256());
After finalizing the asset deltas, the actual transfers are conducted (accounting ledger update or ERC-20 token transfers).
for (uint256 i = 0; i < assets.length; ++i) {
IAsset asset = assets[i];
int256 delta = assetDeltas[i];
_require(delta <= limits[i], Errors.SWAP_LIMIT);
if (delta > 0) {
uint256 toReceive = uint256(delta);
_receiveAsset(asset, toReceive, funds.sender, funds.fromInternalBalance);
if (_isETH(asset)) {
wrappedEth = wrappedEth.add(toReceive);
}
} else if (delta < 0) {
uint256 toSend = uint256(-delta);
_sendAsset(asset, toSend, funds.recipient, funds.toInternalBalance);
}
}
So much gas efficiency! WAGMI!
Managing assets
Balancer pool asset managers can update a pool’s balance, deposit into a pool and withdraw from a pool.
During a deposit, the asset manager moves a pool’s managed balance to its cash balance and then transfers it to the pool.
/* Move amount from managed to cash balance */
_generalPoolManagedToCash(poolId, token, amount);
/* Transfer the token from the asset manager to the pool */
token.safeTransferFrom(msg.sender, address(this), amount);
During a withdrawal, the asset manager moves a pool’s cash balance to its managed balance and then transfers it out.
/* Move amount from cash to managed balance */ _generalPoolCashToManaged(poolId, token, amount);
/* Transfer the token to the asset manager */
token.safeTransfer(msg.sender, amount);
An asset manager can also update a pool’s managed balance directly.
/* Only managed balance is changed, no cash balance update. */
managedDelta = _setGeneralPoolManagedBalance(poolId, token, amount);
cashDelta = 0;
Flash loans
Anyone can do get a flash loan from the vault by calling FlashLoans#flashLoan
. The borrower can borrow multiple assets at the same time.
Before transferring the tokens, the vault caches its pre-loan balances and calculates the fee amounts.
preLoanBalances[i] = token.balanceOf(address(this));
feeAmounts[i] = _calculateFlashLoanFeeAmount(amount);
/* Then transfer the tokens to the recipient */
token.safeTransfer(address(recipient), amount);
Upon receiving the tokens, the recipient can decide to do whatever they want to do with the tokens. The recipient contract needs to have the function receiveFlashLoan
implemented.
recipient.receiveFlashLoan(tokens, amounts, feeAmounts, userData);
The recipient must repay the loan with principals and fees. The fees are transferred to the protocol fees collector.
uint256 postLoanBalance = token.balanceOf(address(this));
_require(postLoanBalance >= preLoanBalance, Errors.INVALID_POST_LOAN_BALANCE);
uint256 receivedFeeAmount = postLoanBalance - preLoanBalance;
_require(receivedFeeAmount >= feeAmounts[i], Errors.INSUFFICIENT_FLASH_LOAN_FEE_AMOUNT);
token.safeTransfer(address(getProtocolFeesCollector()), receivedFeeAmount);
Closing thoughts
Balancer V2 has tremendously improved the protocol’s gas efficiency by minimizing costly ERC-20 transfers and making various gas optimizations. I have not seen so far other protocols matching Balancer’s effort in saving on gas. Balancer will be a serious competition to other decentralized exchanges.
https://github.com/balancer-labs/balancer-core-v2/blob/96297b11060c3961583bfec0ea61d8fdc2ddd22d/contracts/pools/weighted/WeightedPool.sol#L366
thanks this is quite amazing. look forward to your posts