Balancer is an automated portfolio manager. It allows anyone to create Balancer pools, each of which implements an automated market maker (AMM) that is a generalization of the constant-product AMM popularized by Uniswap. Each pool can contain a collection of up to 8 ERC20 tokens, value-balanced according to user-defined weights. These pools can be used, for example, to create on-chain index funds without the use of price oracles. You can learn more about Balancer by reading their whitepaper. At the Balancer team’s request, we’ve audit their code and now publish our results.
We audited commit f4ed5d65362a8d6cec21662fb6eae233b0babc1f
of the balancer-core
repo. We audited all files in the /contracts/
directory with the exception of the Migration.sol
file and any files in the /tests/
directory.
New Balancer pools can be created by anyone via the BFactory
contract. Anytime liquidity is removed from a pool, an EXIT_FEE
is charged and sent to the BFactory
contract (at the time of writing the EXIT_FEE
is 0
, and so no exit fee is extracted when liquidity is removed from a pool). The BFactory
contract has a privileged _blabs
role that is capable of withdrawing any ERC20-compliant tokens held by the factory contract. This is how exit fees will be collected if/when the EXIT_FEE
is changed. Other than the ability to withdraw fees from the BFactory
contract, the _blabs
role has no other power.
Neither the BFactory
contract nor the BPool
contracts that it instantiates are upgradable.
Each BPool
contract has a _controller
role which defaults to the address that initiated the creation of the pool.
Before a BPool
is _finalized
, the _controller
is capable of trivially stealing most funds from the pool. This can be accomplished, for example, by binding a new token (which they fully control) to the pool, minting a large number of those tokens for themselves, then draining the remaining tokens from the pool by swapping them out for the newly-minted tokens. So for non-finalized pools, users must fully trust the _controller
of the pool.
Additionally, even a _finalized
pool can be manipulated (e.g.: DoS’d, drained of its value, etc) if any of its bound tokens have contracts that can be controlled by a third party. For example, a finalized pool that has bound the USDC token could be DoSed or drained of its value if Coinbase (who controls and can arbitrarily update the USDC contract) were malicious. For example, it could inflate USDC and use the new tokens to extract most of the remaining tokens from the pool, or it could put the BPool
address on its “blacklist”, which would prevent any USDC from being transferred out of the pool.
So for both finalized and non-finalized pools, the user must fully trust the contracts of the tokens that have been bound to the pool.
As mentioned in the docs:
Bronze does not support ERC20 tokens that do not return bools for
transfer
andtransferFrom
.
This is because the _pullUnderlying
and _pushUnderlying
functions expect the underlying ERC20 tokens to return true
on successful calls to their transfer
and transferFrom
functions. This makes sense because this behavior is required by the ERC20 specification.
It is important to note, however, that there exist tokens that are thought of as “ERC20 tokens” but do not actually follow the specification with regards to returning a bool
on a successful call to transfer
or transferFrom
, and that such non-compliant behavior could result in those tokens being stuck in a BPool
contract.
For example, the popular BNB token from Binance returns true
on a successful transferFrom
, but does not return true
on a successful transfer
. This means that BNB could be transferred into a BPool
contract (because the _pullUnderlying
function would succeed) but could not be transferred out of a BPool
contract (because the _pushUnderlying
function would revert on line 712).
This issue does not affect fully-compliant ERC20 tokens, and so is not technically a break from the specification. However, it may, in practice, result in loss of funds. The developers are aware of this issue and it is mentioned both in the “limitations” section of the docs and in their FAQ.
Furthermore, some popular tokens can extract a fee from the sender and/or the recipient on calls to transfer
and transferFrom
. For example, USDT is capable of extracting a fee from the recipient on calls to transfer
and transferFrom
(though no fee is being extracted at the time of writing). This non-standard behavior can result in serious accounting errors in the BPool
contract that may result in loss of funds.
In particular, for any given token bound to the pool, the BPool
contract tracks its token balance in a Record
struct in the _records
mapping. The balance
of this Record
gets updated (for example, here) before each call to the _pullUnderlying
and _pushUnderlying
functions. However, the code that updates the balance
assumes that the amount sent and received is exactly equal to the amount
parameter used when calling the transfer
or transferFrom
functions — that is, it assumes standard balance-updating behavior of the underlying token. So for non-standard tokens of this type, the BPool
contract may actually hold more or fewer tokens than its corresponding Record
would suggest. This error in accounting may be used to steal value from the pool.
To add support for these two types of non-compliant and non-standard tokens in future versions of Balancer, consider using OpenZeppelin’s SafeERC20
library to perform the underlying token transfers. This library handles the edge cases where otherwise-compliant tokens don’t return a bool
on a call to transfer
or transferFrom
. Also consider using the “before and after balance check” pattern (see Compound’s doTransferIn
function for an example) to handle the case where tokens do non-standard balance updating during a transfer
or transferFrom
. This pattern measures the amount of a given token that has been added or removed from the pool, as opposed to relying on the amount
parameter of the transfer
and transferFrom
functions of the underlying tokens.
Alternatively, consider allowing users to make arbitrary deposits & withdraws from the BPool
contract, then at the end of the function call, check if the token balances of the BPool
contract are acceptable, and revert if not (see UniswapV2’s swap
function for an example). This puts the burden of proper accounting on the user (or intermediary contract), while the BPool
contract itself needs only verify that its own token balances are acceptable — without having to consider any possible non-standard balance-updating behavior of the underlying tokens.
Overall we found the Balancer contract code to be clear and well-written. Its component parts can be easily analyzed and tested in isolation. The whitepaper and documentation were helpful and the repo includes extensive tests.
Here we report our findings and recommendations.
None.
None.
None.
Rounding errors are not always in favor of the pool
Fixed-point arithmetic introduces small errors in the computations used throughout the contracts. The BNum
library makes use of fixed-point algorithms that minimize these errors (by rounding to the nearest smallest unit). However, these errors still exist and, importantly, are not always in favor of the pool. This means it may be possible for users to illegitimately remove dust amounts of from the pool. This can happen both when adding and removing liquidity and when performing swaps. Under extreme conditions it may be profitable to exploit these errors.
As an example, consider the joinPool
function. Suppose the poolTotal
is 100e18
and one of the bound tokens, t
, has a record such that _records[t].balance = 20000005
, which represents a token balance of 200.00005
for a token with 5 decimals. If the user supplies a poolAmountOut
parameter value of 17857142860000000000
(approximately 17.86e18), then the ratio
variable will be 178571428600000000
. If there were no rounding errors, we would expect that the user would have to transfer in 35.71429464857143
units of the token. However, due to the rounding error introduced by bmul
, the user actually transfers in 35.71429
tokens, which is 0.00000464857143
fewer tokens than expected. In other words, the user bought shares of the pool slightly more cheaply than intended. The error introduced by the fixed-point arithmetic, though small, was not in favor of the pool in this case.
Similar situations can arise when exiting the pool, and when interacting with the swapping functions. Computing the maximum error bounds for the swapping functions is more difficult (we did not explicitly compute them during this audit), but the same principles may apply, and the underlying issue is that the errors may not always favor the pool.
Under most normal conditions, these errors are negligible and not profitable to exploit. The cost of gas alone would typically make exploitation cost-prohibitive, and the error must be great enough (and in favor of the attacker) to outweigh any swap fees or exit fees incurred.
In some very extreme cases, however, exploiting these errors may be profitable. For example, if an attacker can illegitimately remove an extra 0.000004
tokens from the pool, and those tokens are worth $1M each, then the attacker could gross $4 per transaction. If it cost less in gas to perform the attack (including the gas cost of flash-borrowing the required capital if necessary) then you could expect attackers to create bots to exploit the rounding errors until the pool was emptied.
All else being equal, these errors become more severe when the tokens in question have fewer decimals, and when the value of the whole units of the token are greater.
Consider refactoring the code to ensure that all arithmetic errors related to moving tokens are in favor of the pool.
For example, in the joinPool
function, consider adding 1
to the output of bdiv
on line 358 to counteract any rounding-down that may have ocurred during the execution of bdiv
, and consider adding 1
to the output of bmul
on line 381 to counteract any rounding-down that may have ocurred during the execution of bmul
. This would ensure that all calls to the joinPool
function have arithmetic errors that are in favor of the pool. While this may slightly increase the absolute size of the errors, it enforces that the direction of the errors favors the security of the pool.
With all arithmetic errors in favor of the pool, there would be no need to worry about attackers splitting/batching transactions in an attempt to exploit arithmetic errors in their favor, even under extreme conditions.
There are a few broken links and some missing content in the official docs. For example under the API section, the getCurrentTokens()->[Ts]
and getFinalTokens->[Ts]
links both point to the getNumTokens
section of the docs, and the content for those functions is missing from the page.
Consider double checking the docs for missing content and broken links.
In their whitepaper, Balancer mentions that the Silver release will focus, in part, on gas improvements. Looking for gas improvement opportunities was not in-scope for this audit. However, we noticed a couple of these opportunities in passing and mention them here for convenience. This is not a complete or thorough list of gas improvement opportunities.
increaseApproval
function could memoize the _allowance[msg.sender][dst]
variable to improve gas performance. E.g.: function increaseApproval(address dst, uint amt) external returns (bool) {
uint oldAllowance = _allowance[msg.sender][dst];
_allowance[msg.sender][dst] = badd(oldAllowance, amt);
emit Approval(msg.sender, dst, oldAllowance);
return true;
}
require
statements on lines 51 and 58 of BToken.sol
are not necessary because the bsub
on the next lines will revert if there is an underflow. Removing these require
statement improves gas performance for non-reverting calls to _burn
and _move
, but reduces gas performance for calls that would underflow. Since the former is likely to be much more common than the latter, this could be a net positive for gas performance.
In some places, pool shares are minted to the BPool
contract and then pushed out to a user. It may be more efficient to mint the shares directly into the user’s account. This would require refactoring the mint
function.
The functions in the BMath
contract are correct implementations of the functions described in the whitepaper and the code comments for the various functions in BMath.sol
are correct, with the following exceptions.
tO
variable in the comments of the calcPoolInGivenSingleOut
function should be a wO
.
The wO
variable (which represents the tokenWeightOut
) is not defined in the comments of the calcSingleOutGivenPoolIn
function.
The wI
variable is defined in the comments of the calcSingleOutGivenPoolIn
function even though it is not used in the function.
There is a b0
in the comments of the calcSingleOutGivenPoolIn
that should be a bO
(that is, it should be the letter “oh” instead of the number zero).
These errors affect only the code comments and are not present in the code itself. Consider correcting these errors to prevent any confusion they may cause readers of the code.
Additionally, the code describing the calcSingleInGivenPoolOut
function does not match the code itself. The numerator is stored in the tokenAmountInAfterFee
variable and the denominator is stored in the zar
variable. So for the code to match the description in the comments and the docs, the return value should be bdiv(tokenAmountInAfterFee, zar)
. But the actual return value is bdiv(tokenAmountInAfterFee, bsub(BONE, zar))
. Some internal code comments explain the discrepancy and suggest that the actual behavior of the code is the intended behavior.
Consider adjusting both the code comments and the docs for the calcSingleInGivenPoolOut
function to make them consistent with the actual behavior of the code for this function.
While the intended behaviors of the functions in BNum.sol
are clear, the contract could benefit from NatSpec comments to all of the functions, specifically identifying which uint
parameters are meant to be interpreted as integers, and which are meant to be interpreted as integer representations of fixed-precision numbers.
There is an inconsistent use of named return variables. For example, every function in BMath.sol
has named return variables. None of the functions in BNum.sol
have named return variables. Some functions in BPool.sol
have named return variable, while others don’t.
Consider removing named return variables from all functions wherever possible. This can help reduce the chances of introducing regressions when refactoring the code in the future.
The bpowApprox
function computes base^exp
using a binomial approximation (specifically, using a Taylor Series approximation about the point zero). This approximation technique is accurate only if the absolute value of x
(on line 134) is less than BONE
. An equivalent condition is 0 < base < 2 * BONE
.
This bpowApprox
function is currently called only via the bpow
function, which ensures that 0 < base < 2 * BONE
, so currently, all calls to the bpowApprox
function have their inputs checked to ensure the function is used only with valid inputs.
However, there may be some room for improvement here.
Consider removing the two require
statements from the bpow
function (because they aren’t strictly needed unless exp
is not a whole number) and instead putting a require(x < BONE)
statement after line 134 of bpowApprox
. Additionally, if the bpowApprox
function is intended to be used only for values of exp
less than BONE
, consider adding a require(exp < BONE)
statement to the beginning of the bpowApprox
function.
This would allow the bpowApprox
function to be used safely, all on its own, independent of how it used by the bpow
function. That is, it would ensure that the necessary limitations for the binomial approximation are always enforced by the bpowApprox
function itself, instead of relying on developers to remember to perform those checks on their own before calling the bpowApprox
function. It would also remove unnecessary limitations on the bpow
function when using it with whole-number exponents.
In general, this would make the BNum
library more flexible and less prone to error during future code refactors.
To favor explicitness, consider declaring all instances of unsigned integers using uint256
instead of the alias uint
.