Modern DEXes,
how they're made: Balancer V3

Author: Sergey Boogerwooger, Artem Ustinov
Security researchers at MixBytes
Intro
To fully understand this article and subsequent ones about DEXes, you should be familiar with the Automated Market Maker (AMM) concept technical design of the Uniswap V2/V3 (described in many places), including Router<->Pool constructions and callback mechanics. Our previous articles about Uniswap V3 and Uniswap V4 are here and here.

Today's object for in-depth analysis is Balancer V3, one of newest decentralized exchange protocols.
Higher level design
The core transaction flow is described in Balancer V3 docs here and can be broken into three main components: Router, Vault and Pool
(a picture from Balancer V3 docs)

The swap logic in Balancer V3 is split into two separate parts: Vault and Pool, where Vault is responsible for the accounting and holding of tokens balances while the Pool part is responsible for the calculation of invariants and swap amounts. This design allows Balancer to have a lot of pool types (described here) and handle different types of logic, utilizing their mathematical models, while the "setllement" layer still the same. Let's proceed to the implementation.
Core
Vault
The core operations in the Vault are the _supplyCredit() and _takeDebt() functions. They track protocol debt and credit for each token and participate in almost every operation. Balancer allows external Routers to perform the sequences of operations, where each operation can take a debt, supply a credit, and use them later in subsequent operations. Each separate operation can take an excess amount of tokens, provide extra tokens to the pool, but, after all operations, the debt/supply balances delta must be zeroed. That's why such an accounting takes place in the protocol.

To achieve it, both _supplyCredit() and _takeDebt() functions use the very important function: _accountDelta(). This function calculates the accumulated delta of a given token's balance (positive or negative) and increments/decrements the counter _nonZeroDeltaCount(). This counter is then used in the transient() modifier which reverts the whole operations pack if at least one token delta is not zero.
[NOTE] This part of the logic should be explained in more detail. Users of DEXes want to perform not only simple token swaps, but also more complicated operations, such as swapping tokens or providing liquidity to the multiple pools in the same transaction. These operations packs, when performed separately, can be very expensive, while combining them into one large atomic operation can save a significant amount of gas (performing single "final" transfer for each token instead of multiple "intermediate" transfers). This allows the Vault to simply track token deltas for each pools and finalize multiple operations at once, checking if the protocol doesn't have non-zero credit or debt.
An additional complication in these scenarios is the requirement to perform operations in different contracts via callbacks and hooks, which makes the usage of common memory variables impossible (due to switching the execution frame), while using storage to track intermediate balance changes is too expensive. However, the EIP-1153 in Dencun upgrade of Ethereum introduces a new type of memory - transient storage. The new TLOAD/TSTORE opcodes are much cheaper than regular SLOAD/SSTORE, storing the data only during the current transaction. This type of memory is extremely useful, especially for DEXes, because all DEXes require reentrancy protection, operate with token allowances and create deterministic pool addresses with complicated constructor parameters. All these cases are well suited for TLOAD/TSTORE.

Such operations are used in Balancer's transient modifier to unlock() the Vault. The unlock() function "wraps" any operation in the Router (which can be found in Router.sol, BatchRouter.sol and other Routers), ensuring that no matter what happens in Router, the final check of "all token deltas are zero" will always be performed. This function operates similarly to a transient reentrancy lock, but it uses transient storage to hold per-token deltaIsZero flags.

An example of using unlock() to unlock the Vault and return control to the Router is here, in swapExactIn(), in BatchRouter.sol. Another important usage of "unlocking" the Vault is that the external protocols deny querying the state of the "unlocked" Vault, making it impossible to manipulate liquidity and prices in reentrancy scenarios. As we recall, the callback-based scheme for token transfers in/out of the protocol introduces a "natural" reentrancy vector. Additionally, Balancer's custom pools can employ external hooks, rate providers, and pool implementations that may reenter the Vault. Such behavior has been exploited in numerous DeFi attacks involving read-only reentrancy and oracle price manipulation.

The next important core functions in Balancer V3 are the settle() and sendTo() functions. They handle the transfers of tokens in/out of the protocol, calculating the balances differences, updating protocol reserves and performing supplyCredit/takeDebt actions, modifying token deltas in the current operations pack.

The settle() function contains an interesting parameter: amountHint, which is used to protect against "donation" attacks, when tokens are sent directly to the Vault, changing the Vault balance to manipulate the credit amount. This is mitigated by the usage of this condition that uses amountHint tokens for credit, no more. Any extra tokens will be simply added to the Vault's balance.

The Vault itself is an ERC20MultiToken, holding balances/allowances of LP tokens of the pools. All token functions accept the address pool as a first parameter, so the Vault tracks the user's balance in each pool in the form of an ERC20 token. Additionally, each pool also is an ERC20 token, as described in BalancePoolToken.sol, but all functions of this pool token are proxied back to Vault's ERC20MultiToken. This enables pools to have fully ERC20-compliant tokens while the Vault maintains full control over the management of balances of multiple pool tokens.

The Vault contains a substantial amount of code that exceeds the maximum contract size allowed on the Ethereum mainnet. As a result, Balancer V3 is composed of three main contracts:
  • Vault.sol, the main contract with the core functions, such as swaps or liquidity providing. This contract also acts as proxy, routing "non-owned" calls to additional extension: VaultExtension:
  • VaultExtension.sol - with additional, permissionless functions, used less frequently (like registering new pools, multi-token interface, read-only queries, etc.). This contract is also a proxy, forwarding calls to the next contract: VaultAdmin
Notably, the ensureVaultDelegateCall() function is used to check that a particular function from an extension is called from the Vault (and only via delegate call).

Another crucial aspect of Balancer's core functionality is its fees system. In Balancer V3 fees are taken directly in swapping tokens (similar to Uniswap V3/V4). In Balancer V3 the logic of fees distribution is separated from the Vault, which only knows global swap and yield fees and simply collects them, making swap and yield operations cheaper.

Collection of the swap fees for LP providers is performed here, increasing tokenIn amounts in case of SwapKind.EXACT_IN, and decreasing the tokenOut otherwise. Collected fees simply stay at the pool's balance, transforming to the yield of LP providers.

The more complicated part of fees is related to yield bearing tokens, as were said, these tokens are always tricky to implement. Balances of these tokens increase with some rate, which equals "1" for regular tokens and controlled by rateProvider for yield bearing tokens. While the balances of these tokens continously increase, each operation that needs actual amount of reserves in pool require to renew these balances according to rates. This is performed by the _computeAndChargeAggregateSwapFees() function, presenting in swap and add/remove liquidity functions.

The distribution of fees between the protocol and the pool creator resides in the ProtocolFeeController.sol contract, which has a public, permissionless function called collectAggregateFees() with a callback hook. This function also uses transient "unlocking" of the Vault, avoiding manipulations with the Vault's token balances during fees operations.

The main fees distribution function for a given pool (here) performs the loop over all pool's tokens (here) and sets corresponding _protocolFeeAmounts[pool][token] and _poolCreatorFeeAmounts[pool][token] values.
Routers
In Balancer, Routers play a similar role to Uniswap's periphery contracts. The router contract serves as the main entry point to the Balancer V3 protocol and is responsible for accepting tokens and swap paths from users, unlocking the Vault and performing swaps and liquidity management.

The key functions of the RouterCommon.sol (a common part of code, used by routers), performing the token transfers in routers are _takeTokenIn() and _sendTokenOut() functions, handling token transfers and the permitBatchAndCall(), performing the pack of operations with user permits.

It's worth noting that there can be numerous variants of router implementations, and developers can create their own custom routers. However, particular pools and hooks can reject unwanted routers, ensuring a level of control and security within the protocol.
Pools
One of the main ideas of Balancer V3 is to separate the pools' logic from token operations. While the "all-in-one" approach works well with a single pool design, it's limiting when you want to allow users to add their own pools, routers, and combine operations with them via Vault. This "layering" approach is essential for flexibility and also means that the pools in V3 are much more lightweight compared to V2. In fact, pools in V3 are simply implementations of the invariant curve. Even the pool tokens are actually an interface to the Vault's ERC20MultiToken.

At the current moment there are two main contracts, implementing pool logic: StablePool and WeightedPool, which can be used to deploy different types of pools, depending on starting parameters. Any Balancer Pool must implement the IBasePool interface, where the key function is onSwap(). The example from StablePool is here and this variant uses StableSwap invariant (from the Curve project) implemented in the StableMath.sol library.

WeightedPool uses the WeightedMath.sol library and allow to create pool with "weighted" distribution of tokens, reducing the impermanent losses for the token with bigger weight (in the same time having a higher slippage because the token with lesser weight has less liquidity).

Add/remove liquidity logic is implemented in the Vault using the StableMath curve. Therefore, if a pool uses a different mathematical model, it must implement the IPoolLiquidity interface with custom logic. This custom logic will be utilized in the Vault within this branch for adding liquidity and this branch for removing liquidity.
Hooks
The hooks interface is a crucial part of the Balancer protocol and in V3, it has numerous possible implications. There are various types of hooks (config with the hooks names can be found here) that can be assigned to newly registering pools (code here). If a corresponding hook is set, it will be called at the appropriate point (example with "before swap").

Hooks enable Balancer V3 to add almost any logic in the protocol. In the same time hooks can reenter the protocol, changing its reserves and rates. Therefore, after each "before" hook it's reqired to recalculate balances and rates (like here or here) immediately after the hook.

In addition to initialization hooks and "before/after" hooks for swaps and liquidity, there is also a hook called onComputeDynamicSwapFeePercentage, which can dynamically modify swap fees based on the input parameters and pool state.

It's essential to note that hooks created by external developers can be "poisoned" or contain bugs, particularly when upgradeable contracts are used. Therefore, a thorough security check of the pools and hooks integrated into Balancer is necessary.
ERC4626 buffers
Pools and their invariants cannot work directly with yield bearing assets, like Aave's ATokens, whose balances change over time. However, it's highly desirable to use these assets in swaps without relying on external lending protocols, thereby saving traders a significant amount of gas. At the same time, it's beneficial to enable the Vault to earn the yield from these tokens.

To achieve this, the Vault operates with tokens that wrap yield-bearing assets (like aDAI, stETH, etc.). This approach is particularly useful in DeFi protocols, as it simplifies interactions with yield-bearing tokens, operating with constant(non yield-bearing) balances, and unwrapping them back into the underlying tokens when needed.

The buffer in Balancer V3 is a component of VaultStorage (described here). This token has its own LP balances and total supply, as well as the Vault's own balances of both LP shares and underlying tokens (_bufferTokenBalances). These additional balances enable the calculation of correct wrap/unwrap amounts when performing swaps.

The addition of wrapped assets is performed with initializeBuffer() function, which saves the underlying token address, stores the balances of wraped and undelying tokens,and issues intial buffer shares (including minimal portion to zero address to avoid inflation attacks). The wrapped tokens can be added to the pool liquidity with addLiquidityToBuffer() function.

In simpler terms, you can use either waDAI or aDAI for a swap, but in the case of aDAI, the Vault will wrap the aDAI tokens because only wrapped ERC4626 waDAI tokens can be used in swap pools. The wrap function is implemented in the Vault here, which ultimately calls _takeDebt(underlyingToken, ...) and _supplyCredit(wrappedToken, ...). Conversely, the unwrap frunction is also implemented here, with mirrored calls to _takeDebt() and _supplyCredit().

There is also a special case, when buffer doesn't have enough liquidity (for example not enough waDAI when wrapping from aDAI, or vice-versa when unwrapping). In this case, in addition to extra deposit/withdraw operations to cover the shortage, the buffer is also rebalanced (for example in this branch for _unwrapWithBuffer()). This rebalance is necessary to maintain 50/50 ratio between wrapped and underlying tokens in the buffer, therbeby avoid extra deposit/withdraw operations with wrapped/underlying tokens in the future swaps.
Swaps
Now, let's take a look at the swap process. As a well-know standard, two main functions are used to perform a swap: "exact in" and "exact out", which set an exact amount of input or output tokens.

In the router, (e.g., BatchRouter), swaps are finalized with the _settlePaths() function, which uses token amounts saved during the entire operation and calls _takeTokenIn() and _sendTokenOut(). This performs the vault settlement and sends actual tokens in and out. In addition, any excess ETH at the end of the swap is returned to the sender by the _returnEth() function.

The Router's primary function in Balancer is to "unwrap" the swap path passed by user, performing on each step:
  • wrapping/unwrapping "buffer" tokens (for example here)
  • adding/removing liquidity if the tokens at the current step are BPT (Balancer Pool Tokens) tokens (e.g., here or here)
  • performing the direct swap in the Vault (e.g., here)

At each stage, the Router combines all balance changes for "in/out" operations in transient slots (like here or here).

Now, let's move to the core of the swap - the swap() function in the Vault, performing the swap in one particular pool. The usage of hooks adds complexity, as they can be reentrant and modify pool reserves and rates. To ensure accuracy, all pool's swap parameters must be updated (see here). Dynamic fees are then calculated (if applicable) and the core _swap() function is called. Finally, the "after swap" hook is handled and final amounts are calculated.

Now, let's examine the main _swap() function, where the first onSwap handler is first invoked in the pool. Here is an example of this handler from StablePool, which uses the pool's invariant in the computeBalance() function to calculate target token amounts for both swap variants. In the "exact in"/"exact out" branches two key values are calculated: amountInRaw and amountOutRaw. These amounts are saved for the current swap step using _takeDebt() and _supplyCredit(). Fees are then calculated, pool balances are updated, and the final swap event is fired.
Liquidity providing
Liquidity providing in Balancer V3 starts with the addLiquidity() function in the Vault. Before any internal pool operations, Balancer reloads the pool's balances and rates to ensure accuracy as the hook beforeAddLiquidity can modify them. The core _addLiquidity() function is then settled.

Liquidity in Balancer V3 can be added using multiple types of logic: balanced, unbalanced, custom pool's logic and others. The calculations for target tokens amounts, corresponding to each type of liquidity providing, are implemented in BasePoolMath.sol (e.g., here, or here). All these functions prepare the amounts for calculating three key values:

  • amountsInScaled18 - amounts of tokens to be supplied (for each token)
  • bptAmountOut - amount of BPT tokens received by the liquidity provider
  • swapFeeAmountsScaled18 - amounts of fees (also for each token)

After calculating the amountsInScaled18, the protocol performs "unscaling" for each token. Then, it takes debt, charges fees, updates all balances (both in-memory and in storage), and mints the target BPT amount to the liquidity provider.

Removing liquidity follows a similar sequence of logic steps, with some adjustments to accommodate the reversal of the process.
Slippage protection
Slippage protection is an important part of any decentralized exchange (DEX). In Balancer V3, this protection is implemented using additional swap parameters in operations vulnerable to MEV (Maximum Extractable Value) frontrunning.

It's the limitRaw parameter used in swaps (check for "exact in", check for "exact out") and maxBptAmountIn and minBptAmoutOut parameters in liquidity providing (here, here, here, here).

It is essential to set these limits correctly when performing swaps or liquidity management to prevent frontrunning and ensure a secure trading experience.
Query interface
One of the challenges faced by decentralized exchanges (DEXes) that support multiple pools with different tokens, invariants, and swap routes is the complexity of the swap process. Emulating the protocol by external software, setting slippage limits, and searching for optimal swapping paths can be a daunting task. Moreover, it requires continuous support of trading software to keep up with protocol changes.

Balancer V3 addresses this issue by introducing a special "read-only" mode for the protocol - the "query" mode. In this mode, all necessary operations, such as complex swap, can be emulated using the real state of the Vault and pools. This allows users to emulate their swaps, calculating the result of the swap. While it doesn't guarantee that their transactions will not be frontrun, it enables users to set appropriate slippage limits, allowing frontrunners to extract only a controllable profit from swaps and liquidity management operations.

The functions that implement queries in Balancer V3 are located in the Router.sol contract. The examples of swap-related query functions include querySwapSingleTokenExactIn() and querySwapSingleTokenExactOut(). Additionally, Balancer V3 provides query functions for liquidity management.

The query process begins with the invocation of the quote() function in the Vault. This function is wrapped in the query() modifier, which performs the following checks:

  • Verifies that it's a "static" call by checking the the tx.origin == 0 condition in the isStaticCall() function. This check means that there's no signer of current transaction, and the call is performed as "read-only" on some of the RPC nodes (for example, using eth_call). This check can be tricky, and may not work properly in some cases (for example tx.origin != 0 in Remix). It's better to read how the transaction parameters are set on different platforms (for example, here)
  • Reverts if the "query" mode is disabled (for instance, if some platform doesn't handle the tx.origin == 0 condition correctly)
  • Unlocks the Vault (as most Balancer functions will not work without the unlocked Vault)

Using this "read-only" context provided by the "query" mode, regular Balancer functions can be called, returning exactly the same values as a regular call. However, in some cases, these functions may not function properly or execute unnesessary operations in the query context. To address this, Balancer, in some places (e.g., here) modifies the execution of these functions when the query context is active. For example, it may simply return calculated values without performing further state updates.

In some specialized cases, the query context requires specialized mocks. For instance, in the _removeLiquidtity here, it is necessary to emulate the burning of tokens by increasing their balance. To achieve this, the ERC20MultiToken in Balancer has a special _queryModeBalanceIncrease() function, which increases the token balance, but only in "read-only" mode, allowing for the emulation of token burning.
Implementation details
When working with invariants in pools, the protocol requires careful handling of rounding when calculating swap amounts and fees for tokens with different precision. A peculiar situation can arise in certain cases with different precisions: the first, "original" (full precision) value is increased (e.g., an invariant), and the second value is greater than the first. However, after rounding and applying to token amounts with different precision, the second value can surprisingly become equal or even less(!) than the first rounded value.

Such complications require a very careful approach to rounding. A strategy to avoid potential attacks is to always round target amounts "on behalf" of the protocol, not the user. This means:

  • When calculating amountOut, we need to round this value down (to avoid sending extra tokens "out")
  • When calculating amountIn, we need to round this value up (to avoid the loss of tokens "in")
  • There are also some special cases, as explained in this comment.

The example of different roundings is presented here, demonstrating that in the case of "tokens out" (SwapKind.EXACT_IN) rounding is performed down, while in other cases, the rounding is perfomed up. Another similar case in the _swap() functon is demonstrated here and here (also for different "in/out" scenarios).

A careful approach to rounding in the protocols operating with multiple assets with different precisions should be applied to any DeFi protocol to mitigate problems with rounding. In addition, it's highly desirable to test these protocols using fuzzing and formal verification methods to ensure their reliability and security.

Another safety-related mechanic used in Balancer is the minimal trading amounts, which mitigate the problems with small-scale operations that can benefit from rounding, fees calculations, and "zeroing" too small target values. In Balancer V3, there are two such values: _MINIMUM_TRADE_AMOUNT and _MINIMUM_WRAP_AMOUNT, which are used in swaps, liquidity operations, and operations with shares.

Additionally, Balancer employs the usage of "hint amounts" in some functions, such as here. The purpose of these additional safety values is to pass the "desirable" amount to the functions that can potentially be exploited by reentrancy or donation attacks. The function, upon receiving the "hint amount", reverts if the target amounts don't match the "hint" values (like here). Another example with "hint" values is the case in the settle() function, where the target amount to be settled is restricted by the hint value.
Conclusion
We reviewed Balancer V3, a complex DEX protocol that allows operating with many different types of assets. The main features of Balancer V3 are:

  • Three-level architecture: separating pools logic layer, settlement layer, and external routers layer
  • Multi-token pools: ability to work with pools containing multiple tokens (up to 8 different tokens)
  • Customizable pools: ability to add different types of pools with different invariants and internal logic
  • Non-standard tokens support: ability to work with non-standard tokens (yield-bearing, rebaseable) using ERC4626 wrapped protocol tokens
  • Rich hooks system: allowing to add more functionality to swaps and liquidity providing
  • Query interface: allowing to emulate operations in the protocol

Balancer V3 contains many safety-related mechanics, including lock/unlock logic for the Vault, transient storage, operation limits and hint values, protecting the protocol from reentrancy and donation attacks. A lot of attention is paid to maths and rounding for the operations with tokens with different precisions. All attack vectors above are common for DEX projects, and Balancer is well-equipped to handle them.

Balancer V3 introduces several noteworthy advancements in DeFi, such as its modular architecture, support for multi-token pools, and a comprehensive hooks system. These features contribute to its flexibility and potential for innovation in decentralized exchange protocols. Developers and auditors may find studying Balancer V3 valuable for understanding modern DEX design and implementation.

See you in our next articles!
  • Who is MixBytes?
    MixBytes is a team of expert blockchain auditors and security researchers specializing in providing comprehensive smart contract audits and technical advisory services for EVM-compatible and Substrate-based projects. Join us on X to stay up-to-date with the latest industry trends and insights.
  • Disclaimer
    The information contained in this Website is for educational and informational purposes only and shall not be understood or construed as financial or investment advice.
Other posts