Create a custom AMM with a novel invariant
Balancer protocol provides developers with a modular architecture that enables the rapid development of custom AMMs.
AMMs built on Balancer inherit the security of the Balancer vault, and benefit from a streamlined development process. Balancer v3 was re-built from the ground up with developer experience as a core focus. Development teams can now focus on their product innovation without having to build an entire AMM.
This section is for developers looking to build a new custom pool type with a novel invariant. If you are looking to extend an existing pool type with hooks, start here.
Build your custom AMM
At a high level, creating a custom AMM on Balancer protocol involves the implementation of five functions onSwap
, computeInvariant
and computeBalance
as well as getMaximumSwapFeePercentage
and getMinimumSwapFeePercentage
. To expedite the development process, Balancer provides two contracts to inherit from:
- IBasePool.sol - This interface defines the required functions that every Balancer pool must implement
- BalancerPoolToken.sol - This contract implements the ERC20MultiToken standard that enables your pool contract to be ERC20 compliant while delegating BPT accounting to the vault. For more information, refer to BalancerPoolToken.
Both IBasePool
and BalancerPoolToken
are used across all core Balancer pools, even those implemented by Balancer Labs (ie: WeightedPool).
Below, we present a naive implementation of a two token ConstantProductPool
& ConstantSumPool
utilising (X * Y = K) & (X + Y = K) as a reference for walking through the required functions necessary to implement a custom AMM on Balancer protocol:
What does Scaled18 mean?
Internally, Balancer protocol scales all tokens to 18 decimals to minimize the potential for errors that can occur when comparing tokens with different decimals numbers (ie: WETH/USDC). Scaled18
is a suffix used to signify values has already been scaled. By default, ALL values provided to the pool will always be Scaled18
. Refer to Decimal scaling for more information.
What does Live refer to in balancesLiveScaled18?
They keyword Live
denote balances that have been scaled by their respective IRateProvider
and have any pending yield fee removed. Refer to Live Balances for more information.
How are add and remove liquidity operations implemented?
Balancer protocol leverages a novel approximation, termed the Liquidity invariant approximation, to provide a generalized solution for liquidity operations. By implementing computeInvariant
and computeBalance
, your custom AMM will immediately support all Balancer liquidity operations: unbalanced
, proportional
and singleAsset
.
Compute Invariant
Custom AMMs built on Balancer protocol are defined primarily by their invariant. Broadly speaking, an invariant is a mathematical function that defines how the AMM exchanges one asset for another. A few widely known invariants include Constant Product (X * Y = K) and Stableswap.
Our two-token ConstantSumPool
uses the constant sum invariant, or X + Y = K
. To implement computeInvariant
, we simply add the balances of the two tokens. For the ConstantProductPool
the invariant calculation is the square root of the product of balances. This ensures invariant growth proportional to liquidity growth.
For additional references, refer to the WeightedPool and Stable Pool implementations.
application context on computeBalance
In the context of computeBalance
the invariant is used as a measure of liquidity. What you need to consider when implementing all possible liquidity operations on the pool is that:
- bptAmountOut for an unbalanced add liquidity operation should equal bptAmountOut for a proportional add liquidity in the case that
exactAmountsIn
for the unbalanced add are equal to theamountsIn
for the same bptAmountOut for both addLiquidity scenarios.AddLiquidityProportional
does not call into the custom pool it instead calculates BptAmountOut within the BasePoolMath.sol whereasaddLiquidityUnbalanced
calls the custom pool'scomputeInvariant
. - the amountIn for an exactBptAmountOut in an
addLiquiditySingleTokenExactOut
should equal the amountIn for an unbalanced addLiquidity when the bptAmountOut is expected to be the same for both operations.addLiquiditySingleTokenExactOut
usescomputeBalance
whereasaddLiquidityUnbalanced
usescomputeInvariant
. These are important consideration to ensure that LPs get the same share of the pool's liquidity when adding liquidity. In a Uniswap V2 Pair adding liquidity not in proportional amounts get's penalized, which you can also implement in a custom pool, as long as you accurately handle the bullet points outlined above.
Compute Balance
computeBalance
returns the new balance of a pool token necessary to achieve an invariant change. It is essentially the inverse of the pool's invariant. The invariantRatio
is the ratio of the new invariant (after an operation) to the old. computeBalance
is used for liquidity operations where the token amount in/out is unknown, specifically AddLiquidityKind.SINGLE_TOKEN_EXACT_OUT
and RemoveLiquidityKind.SINGLE_TOKEN_EXACT_IN
.
You can see the implementations of the ConstantProductPool
and ConstantSumPool
below:
A note on invariantRatio
The invariantRatio
refers to the new BPT supply over the total BPT supply and is calculated within the BasePoolMath.sol
via newSupply.divUp(totalSupply)
.
For additional references, refer to the WeightedPool and StablePool implementations.
On Swap
Although the outcome of onSwap
could be determined using computeInvariant
and computeBalance
, it is highly likely that there is a more gas-efficient strategy. onSwap
is provided as a means to facilitate lower cost swaps.
Balancer protocol supports two types of swaps:
EXACT_IN
- The user defines the exact amount oftokenIn
they want to spend.EXACT_OUT
- The user defines the exact amount oftokenOut
they want to receive.
The minAmountOut
or maxAmountIn
are enforced by the vault .
When swapping tokens, our constant K
must remain unchanged. Since our two-token ConstantSumPool
uses the constant sum invariant (X + Y = K
), the amount entering the pool will always equal the amount leaving the pool:
The PoolSwapParams
struct definition can be found here.
For additional references, refer to the WeightedPool and StablePool implementations.
Constructor arguments
At a minimum, your constructor should have the required arguments to instantiate the BalancerPoolToken
:
IVault vault
: The address of the Balancer vaultstring name
: ERC20 compliantname
that will identify the pool token (BPT).string symbol
: ERC20 compliantsymbol
that will identify the pool token (BPT).
constructor(IVault vault, string name, string symbol) BalancerPoolToken(vault, name, symbol) {}
The approach taken by Balancer Labs is to define a NewPoolParams struct to better organize the constructor arguments.
Swap fees
The charging of swap fees is managed entirely by the Balancer vault. The pool is only responsible for declaring the swapFeePercentage
for any given swap or unbalanced liquidity operation on registration as well as declaring an minimum and maximum swap fee percentage. For more information, see Swap fees.
Do I need to take swap fees into account when implementing onSwap?
No, swap fees are managed entirely by the Balancer vault. For an EXACT_OUT
swap, the amount in (params.amountGivenScaled18
) will already have the swap fee removed before onSwap
is called.
Balancer supports two types of swap fees:
- Static swap fee: Defined on
vault.registerPool()
and managed via calls tovault.setStaticSwapFeePercentage()
. For more information, see Swap fee. - Dynamic swap fee: are managed by a Hooks contract. If a swap with a pool uses the dynamic swap fee is determined on pool registration. A Hook flags that it supports dynamic fees on
vault.registerPool()
. For more information, see Dynamic swap fees.
Hooks
Hooks as standalone contracts are not part of a custom pool's implementation. However they can be combined with custom pools. For a detailed understanding, see Hooks.
Vault reentrancy
Hooks allow a pool to reenter the vault within the context of a pool operation. While onSwap
, computeInvariant
and computeBalance
must be executed within a reentrancy guard, the vault is architected such that hooks operate outside of this requirement.
Add / Remove liquidity
The implementation of computeInvariant
and computeBalance
allows a pool to support ALL Add/Remove liquidity types. For instances where your custom AMM has additional requirements for add/remove liquidity operations, Balancer provides support for AddLiquidityKind.CUSTOM
and RemoveLiquidityKind.CUSTOM
. An example custom liquidity operation can be found in Cron Finance's TWAMM implementation on Balancer v2, specifically when the pool registers long term orders.
When adding support for custom liquidity operations, it's recommended that your pool contract implement IPoolLiquidity
contract ConstantSumPool is IBasePool, IPoolLiquidity, BalancerPoolToken {
...
}
Add liquidity custom
For your AMM to support add liquidity custom, it must:
- Implement
onAddLiquidityCustom
, as defined here - Set
LiquidityManagement.supportsAddLiquidityCustom
totrue
on pool register.
Remove liquidity custom
For your AMM to support remove liquidity custom, it must:
- Implement
onRemoveLiquidityCustom
, as defined here - Set
LiquidityManagement.supportsRemoveLiquidityCustom
totrue
on pool register.
Remove support for built in liquidity operations
There may be instances where your AMM should not support specific built-in liquidity operations. If certain operations should be enabled in your custom pool is defined in LiquidityManagement
. You can choose to:
- disable unbalanced liquidity add and removals
- enable add liquidity custom
- enable remove liquidity custom
To achieve this, the respective entry in the LiquidityManagement
struct needs to be set.
struct LiquidityManagement {
bool disableUnbalancedLiquidity;
bool enableAddLiquidityCustom;
bool enableRemoveLiquidityCustom;
}
These settings get passed into the pool registration flow.
Testing your pool
Depending on the combination of liquidity operations you allow for your pool you need to ensure the correct amount of BPT get's minted whenever a user adds/removes liquidity unbalanced (which calls into computeInvariant
) and proportional adds/removes (which does not call into the pool and solely relies on BasePoolMath.sol). Let's say your pool has reserves of [100, 100] and an addLiquidityProportional([50,50])
gets the user 100 BPT in return, if the user were to addLiquidityUnbalanced([50,50])
you must ensure that the amount of BPT that gets minted is the same as in the addLiquidityProportional([50,50])
operation. Consider also reading through liquidity invariant approximation to get more context on various combination of pool operations.
Deploying your pool
See the guide to Deploy a Custom AMM Using a Factory.