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 the ISwapFeePercentageBounds and IUnbalancedLiquidityInvariantRatioBounds interfaces (which define getMinimumSwapFeePercentage / getMaximumSwapFeePercentage, and getMinimumInvariantRatio / getMaximumInvariantRatio, respectively). To expedite the development process, Balancer provides two contracts to inherit from:

Both IBasePool and BalancerPoolToken are used across all core Balancer pools, even those implemented by Balancer Labs (ie: WeightedPoolopen in new window).

Standard Balancer pools also implement the optional Version interface (for easy on- and off-chain verification of the contract version), and IPoolInfo, which exposes Vault getters (e.g., getTokens, and getTokenInfo) through the pool itself as a convenience. On top of that, the standard pools define custom interfaces that return structs corresponding to the immutable and dynamic data fields for the pools. For instance, Weighted Pools return the weights. Note that dynamic pool values (e.g., live balances), exposed through either IPoolInfo or the custom interfaces, will only be valid on-chain if the Vault is locked (i.e., not in the middle of a transaction).

Below, we present a naive implementation of a two token ConstantProductPool and ConstantSumPool utilizing (sqrt(X * Y) = K) and (X + Y = K) as references for walking through the required functions necessary to implement a custom AMM on Balancer protocol.

You can think of the invariant as a measure of the "value" of the pool, which is related to the total liquidity (i.e., the "BPT rate" is invariant / totalSupply). Two critical properties must hold (for Balancer, and for AMMs in general):

  1. The invariant should not change due to a swap. In practice, it can increase due to swap fees, which effectively add liquidity after the swap - but it should never decrease.
  2. The invariant must be "linear"; i.e., increasing the balances proportionally must increase the invariant in the same proportion: inv(a * n,b * n,c * n) = inv(a, b, c) * n

Property #1 is required to prevent "round trip" paths that drain value from the pool (and all LP shareholders). Intuitively, an accurate pricing algorithm ensures the user gets an equal value of token out given token in, so the total value should not change.

Property #2 is essential for the "fungibility" of LP shares. If it did not hold, then different users depositing the same total value would get a different number of LP shares. In that case, LP shares would not be interchangeable, as they must be in a fair DEX.

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?

The keyword Live denotes balances that have been scaled by their respective IRateProvider and have any pending yield fees 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)open in new window and StableSwapopen in new window.

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 WeightedPoolopen in new window and Stable Poolopen in new window 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 the amountsIn for the same bptAmountOut for both addLiquidity scenarios. AddLiquidityProportional does not call into the custom pool it instead calculates bptAmountOut using BasePoolMath.solopen in new window whereas addLiquidityUnbalanced calls the custom pool's computeInvariant.
  • 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 uses computeBalance whereas addLiquidityUnbalanced uses computeInvariant.

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 gets penalizedopen in new window, 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_OUTopen in new window and RemoveLiquidityKind.SINGLE_TOKEN_EXACT_INopen in new window.

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 WeightedPoolopen in new window and StablePoolopen in new window 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 of tokenIn they want to spend.
  • EXACT_OUT - The user defines the exact amount of tokenOut they want to receive.

The minAmountOut or maxAmountIn are enforced by the vaultopen in new window .

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 hereopen in new window.

For additional references, refer to the WeightedPoolopen in new window and StablePoolopen in new window implementations.

Constructor arguments

At a minimum, your constructor should have the required arguments to instantiate the BalancerPoolToken:

  • IVault vault: The address of the Balancer vault
  • string name: ERC20 compliant name that will identify the pool token (BPT).
  • string symbol: ERC20 compliant symbol 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 NewPoolParamsopen in new window 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 feesopen in new window.

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. Fees are always taken on tokenIn.

Balancer supports two types of swap fees:

  • Static swap fee: Defined on vault.registerPool() and managed via calls to vault.setStaticSwapFeePercentage(). For more information, see Swap fee.
  • Dynamic swap fee: Managed by a Hooks contract. Whether a swap with a pool uses the dynamic swap fee is determined at pool registration. A Hook sets the flag indicating support for 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'sopen in new window TWAMM implementation on Balancer v2, specifically when the pool registers long term ordersopen in new window.

When adding support for custom liquidity operations, it's recommended that your pool contract implement IPoolLiquidityopen in new window

contract ConstantSumPool is IBasePool, IPoolLiquidity, BalancerPoolToken {
    ...
}

Add liquidity custom

For your AMM to support add liquidity custom, it must:

  • Implement onAddLiquidityCustom, as defined hereopen in new window
  • Set LiquidityManagement.supportsAddLiquidityCustom to true on pool register.

Remove liquidity custom

For your AMM to support remove liquidity custom, it must:

  • Implement onRemoveLiquidityCustom, as defined hereopen in new window
  • Set LiquidityManagement.supportsRemoveLiquidityCustom to true 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 add and remove liquidity unbalanced (i.e., non-proportional; enabled by default. These cannot be disabled independently.)
  • enable add liquidity custom (disabled by default)
  • enable remove liquidity custom (disabled by default)
  • enable donation (disabled by default)

To achieve this, the respective entry in the LiquidityManagement struct needs to be set.

struct LiquidityManagement {
    bool disableUnbalancedLiquidity;
    bool enableAddLiquidityCustom;
    bool enableRemoveLiquidityCustom;
    bool enableDonation;
}

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.solopen in new window). 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.