How to Create a Composable Stable Pool using Balancer v2 Lite

Ian Moore, PhD
Coinmonks
Published in
6 min readFeb 2, 2024

--

1. Introduction

Unlike working with the Uniswap codebase, getting started with the Balancer v2 monorepo can be a daunting task. The best consolidated educational resource on Balancer v2 (that I’ve found) is Balancer’s developer docs repos. After thoroughly reading through this resource, it largely orients developers to using Balancer’s NPM JavaScript libraries. After scouring multiple GitHub repos, this also seems to be how most projects are consuming Balancer as well. I’m presuming that this is most likely due to the large size of the project’s codebase.

In part 1 we discussed how to create a weighted pool with Balancer v2 Lite, here we present part 2. In this discussion we cover the basics on how to setup a composable stable pool for those who want remove the obfuscation, and peel back the many layers of JavaScript to help streamline their understanding of Balancer v2 core.

2. Test Setup for Balancer v2

Here we walkthrough the basic steps that are needed to setting up a composable stable pool using Balancer v2 Lite, which include:

  • Deploying a vault
  • Deploying test tokens
  • Setting up a protocol fee provider
  • Deploying a pool contract
  • Funding the pool
  • Performing a swap

Deploy Vault

The Vault contract is considered to be the most innovative part of the Balancer protocol, and is heavily integrated with the BasePool contract. This is where the protocol holds all the pool tokens and performs all the necessary bookkeeping, which serves as the the main interface for pool interactions like joins, exits and swaps.

However, prior to launching we need to setup the authorization mechanism which is responsible for protecting the protocol, which is called the TimelockAuthorizer . This mechanism performs both a Timelock and Authorization in one contract; for an in-depth explanation, see this article. The typescript is as follows:

let entrypoint: MockAuthorizerAdaptorEntrypoint;
let vault: Vault;
let authorizer: Contract;
let vaultSigner: SignerWithAddress;

entrypoint = await new MockAuthorizerAdaptorEntrypoint__factory(deployer).deploy()
authorizer = await new TimelockAuthorizer__factory(deployer).deploy(admin.address, ZERO_ADDRESS, entrypoint.address, MONTH)
vault = await new Vault__factory(deployer).deploy(authorizer.address, ZERO_ADDRESS, 0, 0)
await impersonateAccount(vault.address);
await setBalance(vault.address, fp(100));
vaultSigner = await SignerWithAddress.create(ethers.provider.getSigner(vault.address));

Deploy Tokens

To engage with the Vault, we need to next mint some test tokens. For this we engage with the TestToken contract where its compiled code is called from the pvt directory. Before tokens are sent to the Vault, they must be approved for appropriate allowances so that the tokens can be moved. Infinite approvals (ie, 2e^256 - 1) can be sent to satisfy the amounts we wish to move. The typescript is as follows:

let tokens: TokenList;

const tokenAmounts = fp(100);
tokens = await TokenList.create(TOKEN_SYMBOLS, { sorted: true });
await tokens.mint({ to: lp, amount: tokenAmounts });
await tokens.approve({ to: vault.address, from: lp, amount: tokenAmounts });

Protocol Fee Provider

Before we deploy the pool contract, we must set the protocol fee percentages. The ProtocolFeePercentagesProvider is used to set the ground truth to determine the protocol fee percentages paid by integrators. Protocol swap fees are a percentage of the already collected swap fees. For instance, say a pool has a 1% swap fee, and there was a 10% protocol swap fee; then 0.9% of each trade would be collected for the LPs, and 0.1% would be collected for the protocol fee collector contract; see Balancer docs. The typescript setup is as follows:

let feesProvider: ProtocolFeePercentagesProvider;
feesProvider = await new ProtocolFeePercentagesProvider__factory(deployer).deploy(vault.address,
MAX_YIELD_VALUE,
MAX_AUM_VALUE);

Deploy Pool Contract

Now that we have the Vault and ProtocolFeePercentagesProvider deployed, we can now deploy theComposableStablePool contract (with pre-minted balancer pool token — BPT).

When pools are first launched they come equipped with an emergency pause state, hence the pauseWindowDuration and bufferPeriodDuration parameters, which defined as follows:

  • pauseWindowDuration: allows for a contract to be paused during a max 90 days period after deployment
  • bufferPeriodDuration: if the contract is paused when the pause window finishes, it will remain in the paused state through an additional max 30 days buffer period

Other parameters that unique to the composable stable pool setup are tokenRateCacheDurations, exemptFromYieldProtocolFeeFlags, and the amplificationParameter, which are described as follows:

  • tokenRateCacheDurations: token rate caches are used to avoid querying the price rate for a token every time we need to work with it; the old rate field is used for precise protocol fee calculation, to ensure that token yield is only taxed once.
  • exemptFromYieldProtocolFeeFlags: if token is exempt from yield fees, then store it as an immutable state variable
  • amplificationParameter: this parameter controls the bonding curve mechanism within the stable swap math; for more detailed explanation see this article

Once a pool is successfully deployed, it will issue a poolId, which is an essential component to interfacing with a pool. It can be retrieved by calling getPoolId() from the ComposableStablePool contract. Other common pool data that can be retrieved are pool balances by calling getPoolTokens() from the Vault contract, and the pool’s swap fee by calling getSwapFeePercentage() from the ComposableStablePool contract. The typescript is as follows:

let composableStablePool: ComposableStablePool;  

const rates: BigNumberish[] = [];
const rateProviders: Contract[] = [];
const tokenRateCacheDurations: number[] = [];
const exemptFromYieldProtocolFeeFlags: boolean[] = [];

for (let i = 0; i < numTokens; i++) {
const artifact = await getArtifact('MockRateProvider');
const factory = new ethers.ContractFactory(artifact.abi, artifact.bytecode, other);
rateProviders[i] = await factory.deploy();

await rateProviders[i].mockRate(rates[i] || FP_ONE);
tokenRateCacheDurations[i] = MONTH + i;
exemptFromYieldProtocolFeeFlags[i] = i % 2 == 0; // set true for even tokens
}

const pool_params = {
vault: vault.address,
protocolFeeProvider: feesProvider.address,
name: NAME,
symbol: SYMBOL,
tokens: tokens.addresses,
rateProviders: TypesConverter.toAddresses(rateProviders),
tokenRateCacheDurations: tokenRateCacheDurations,
exemptFromYieldProtocolFeeFlags: exemptFromYieldProtocolFeeFlags,
amplificationParameter: AMPLIFICATION,
swapFeePercentage: POOL_SWAP_FEE_PERCENTAGE,
pauseWindowDuration: BASE_PAUSE_WINDOW_DURATION,
bufferPeriodDuration: BASE_BUFFER_PERIOD_DURATION,
owner: recipient.address,
version: VERSION,
};

composableStablePool = await new ComposableStablePool__factory(deployer).deploy(pool_params)

Join Pool

Once your pool has successfully issued a poolId, it is time to send it your test tokens. This is done by calling joinPool() on the Vault contract. Essential to calling joinPool() is setting up a JoinPoolRequest or ExitPoolRequest which takes a userData argument and specifies how the pool should be joined or exited. It is important to note that the userData argument is a highly versatile field and must be encoded by use case; see Balancer Developer docs for more information.

Unlike what we discussed with the weighted pool implementation, here the BPT is listed with the pool tokens. Hence, we need to consider the array indice for which it is located within the token array for when we setup the JoinPoolRequest data structure. Once successfully transferred, getPoolTokens() can be called from the Vault contract to check for the update. This should return information for both the pool tokens and the BPT token. The typescript is as follows:

let bptIndex: number;
let initialBalances: BigNumberish[];
let vault: Vault;

bptIndex = Number(await composableStablePool.getBptIndex())
initialBalances = Array.from({ length: numTokens + 1 }).map((_, i) => (i == bptIndex ? fp(2596148429267412): fp(1 - i / 10)));
const joinKind = 0;
const abi = ['uint256', 'uint256[]'];
const data = [joinKind, initialBalances];
const userData = defaultAbiCoder.encode(abi,data);
const poolId = await composableStablePool.getPoolId();
const poolTokens = await vault.getPoolTokens(poolId)

const request: JoinPoolRequest = {
assets: poolTokens.tokens,
maxAmountsIn: initialBalances,
userData: userData,
fromInternalBalance: false
};

const tx = await vault.connect(lp).joinPool(poolId, lp.address, recipient.address, request);

SwapGivenIn / SwapGivenOut

Now that our pool has token balances, it is time to perform a test swap. Like joins, this is also done by calling swap() on the Vault contract. Essential to calling the swap() function is first building our SingleSwap and FundManagement structs. In Balancer, there are two types of swap kinds: SwapGivenIn and SwapGivenOut. In the SingleSwap struct we define the swap kind as a GivenIn, which indicates the exact amount of tokens that will be sent in. For more information on these structs, see Balancer Developer docs. Once the swap has been successfully implemented, getPoolTokens() can be called from the Vault contract to check for the update. The typescript is as follows:

let vault: Vault;

const poolId = await composableStablePool.getPoolId();
const amount = fp(0.1);
const amountWithFees = fpMul(amount, POOL_SWAP_FEE_PERCENTAGE.add(fp(1)));

const singleSwap: SingleSwap = {
poolId: poolId,
kind: SwapKind.GivenIn,
assetIn: tokens.get(1).instance.address,
assetOut: tokens.get(0).instance.address,
amount: amountWithFees, // Needs to be > 0
userData: '0x',
};

const funds: FundManagement = {
sender: lp.address,
recipient: recipient.address,
fromInternalBalance: false,
toInternalBalance: false,
};

const tx = await vault.connect(lp).swap(singleSwap, funds, 0, MAX_UINT256)

3. Summary

The work presented in this article is a larger body of work for doing things like: (a) simulate the behaviour of a simple liquidity pool [1]; (b) studying the effects of Impermanent Loss in an LP [2]; and (c) analyzing the risk profile and profitability in more advanced DeFi systems. Here, we provide the basic setup for those who are interested in getting into researching LPs. Please be sure to look out for future medium posts on this!

See GH repos for the typescript behind this presentation

--

--