Weighted AMM
In previous sections, we introduced the fundamental concepts of pools and the vault for pool interaction. This section delves into a unique type of pool, known as a weighted pool, housed within the amm module.
Weighted Math
Weighted pools utilize a constant product equation:
Where and denote the balance and weight of the th token in the pool, respectively.
Our implementations incorporate a notion termed virtual balances. This involves augmenting a token's actual balance with a virtual number for specific use-cases. A typical instance is when a new token is introduced into a pool, we generate a virtual balance for the token to regulate its price. The specifics of each use-case will be detailed in the relevant documentation. For now, we'll include virtual balances in the equation as follows:
Here, represents the virtualized balance of token , encompassing both the token's actual balance and virtual amounts.
Pool Interactions
This section outlines the equations used in pool interactions, such as trades, spot prices, liquidity deposit, and withdrawal.
Swaps
Let denote the trading fee. Our pool trading formulas, similar to Balancer's, account for virtual balances. Specifically, if the real balances in the pool prior to a trade are , the virtual balances are , and the weights are , the quantity of token received by the trader when depositing an amount of token is
The amount of token required to deposit to obtain an amount of token is
If a trader deposits an amount of token and receives an amount of token , and denote the real balances in the pool post-trade, then
as expected.
Note that we must ensure to execute the trade, unless the token is being removed from the pool. In this case, we also accept and subsequently remove the token from the pool.
Spot Prices
As alluded to earlier, virtual balances are used in lieu of real balances when trading pool assets. Therefore, for all , the spot price of in terms of is
LP token spot price: To calculate the LP token's price of the pool in terms of , we first determine the pool's total value in this token as follows:
Considering the quantity of LP tokens in circulation as , the LP token's spot price in terms of the token becomes
Liquidity Deposit
Proportional all-asset deposits: These deposits follow the standard procedure, where a user deposits amounts of each asset in the pool, proportional to the pool's current balances. Specifically, if are the balances in the pool, a user must deposit amounts of each asset that satisfy
Thus, the user will receive LP tokens, where is the number of LP tokens in circulation. If we consider as a scalar in the virtual balance designs, all virtual balances scale by . It's straightforward to see that the spot prices remain unaltered after this operation.
Single-asset deposits given LP amount: Here, the user aims to deposit a single asset (token ) and receive a certain LP token amount. We need to determine the token amount the user must deposit.
Suppose is the LP token amount the user wishes to receive. Let be the real balances, and are the virtual balances of the pool. Let be the pool fee. To facilitate the single-asset deposit of token , we contemplate several trades of token against other tokens, followed by a proportional all-asset deposit.
Let . Note, is the pool share corresponding to the LP tokens the user wishes to obtain. For , let be the amount the user acquires from the trades and will be used in the proportional all-asset deposit. Since, for each , the pool's real balance of token after trades is , and the user wishes to receive LP tokens which correspond to a pool fraction, we deduce that
Hence,
for each . Let denote the quantity of token a user must trade in the pool to acquire quantities of token , for each , excluding transaction fees. This relationship is represented by the following equation:
From this, we can derive:
When fees are accounted for, the user must trade an amount of token , where . The post-trade balance of token becomes . If a proportional all-asset deposit equal to a pool share is performed after the trades, the user must provide of token . Consequently, the total deposit required from the user is to obtain LP tokens through a single-asset deposit of token .
Non-proportional multi-asset deposits: In this scenario, the user intends to deposit varying amounts of one or more pool assets, and we must calculate the amount of LP tokens to be issued.
Suppose the real balances of the pool are and the virtual balances are . The user's deposit amounts are . We will assume a feeless pool for this discussion.
To undertake a proportional all-asset deposit, the user would need to exchange appropriate amounts of some assets to acquire more of others so that the user's post-trade asset amounts are proportional to the pool's real balances. Subsequently, the user can make a proportional all-asset deposit with their assets. Let represent the user's LP token share for the deposit; hence, the user will receive LP tokens.
We will determine the value of , allowing us to perform a single transaction instead of several trades followed by a proportional all-asset deposit. Note that the pool's real balances post-trade and following the all-asset deposit will be , as the user takes some assets from the pool only to deposit them back shortly after. Consider as the virtual balances of the pool post-deposit. Given that the outcome of Balancer's weighted geometric mean formula remains constant with trades and increases in proportion to all-asset deposits, we get:
Let's denote the value of the parameter after the deposit as . For all , there is a such that:
This gives us:
Our goal is to find that satisfies:
or equivalently:
To find the value of , we apply Newton's method. Define as:
For each , let
Hence,
Observe:
We apply Newton's method with:
and define:
Simulations indicate that 10 iterations of Newton's method suffice in most cases. We've established that this operation entails a swap step, which necessitates a swap fee. To streamline swap fee calculations, we apply the fee to the LP amount using the pool's swap fee ratio. As these estimations hinge on the ratio of assets used in swaps versus those used in proportional join, we first execute the largest possible proportional join. The remaining amounts are then calculated using the equations, and the swap fee is only applied to this portion.
Liquidity Withdrawals
Proportional all-asset withdrawals: These are conducted in the usual manner, where users redeeming a certain quantity of LP tokens receive proportional amounts of all pool assets, corresponding to their pool share. Specifically, if a user redeems an amount of LP tokens and the pool's real balances are , the user will receive, for each , an amount equal to of asset of the pool. Here, represents the number of LP tokens in circulation.
As with the all-asset deposit case, we'll now demonstrate that spot prices remain unchanged after an all-asset liquidity withdrawal. Let's assume are the pool's real balances post-withdrawal, are the virtual balances post-withdrawal, and represents the value of parameter post-withdrawal. Let . Note that for all ,
and
If we use parameter as a scaler in the virtual balances, they will change proportionally to the real balances and parameter , implying that spot prices remain unchanged after an all-asset liquidity withdrawal.
Single-asset withdrawals given LP amount: Here, the user redeems a specific quantity of LP tokens and wishes to receive only token , instead of a proportional amount of all pool assets. We aim to calculate the quantity of token the user will receive.
Let represent the quantity of LP tokens the user wishes to redeem. Let be the real balances and the virtual balances of the pool. Let represent the pool fee. To provide the liquidity provider with only token , we'll consider a proportional all-asset withdrawal followed by several trades. For simplicity, we'll apply the trading fee to token . Define as the pool's share corresponding to LP tokens to be redeemed, i.e., . Let and denote the real and virtual balances of the pool post-liquidity withdrawal, respectively. As per the proportional all-asset withdrawal case, for all , we have . Therefore,
Let represent the total amount of token the user will obtain after the corresponding trades, if there were no fees. Let and denote the real and virtual balances of the pool after the trades. We have
and
We observe that
Hence,
Therefore,
This formula provides the amount a liquidity provider should receive without fees. However, as we charge a fee on token , the user receives
Here, the trading fee is charged on the amount because the user first receives an amount of token with the proportional withdrawal, so this amount should be fee-exempt.
Non-proportional multi-asset withdrawal: This operation can be executed using the deposit equations with negative amounts for the given amounts.
Pool Initialization
To initialize a pool, we receive the balance for each token and mint the appropriate number of liquidity provider (LP) tokens. The number of LP tokens required for initialization is computed as follows:
In the above formula, represents the list of all tokens in the pool, and denote the initial balance and normalized weight of the 'th token, respectively.
Permanent Virtual Balance
Weighted pool types allow for initializing the pool with virtual balance, This feature specially helps token-launch events to launch your tokens without initial liquidity for the reserve token (e.g. USDC). In order to use this feature, you can use the initialize pool msg with the virtual balance for the tokens, keeping in mind that the pool requires at least some actual balance for at least one of the tokens in the pool.
Given an amount of virtual balance for a token, The pool uses the virtual+actual balance for calculating the LP supply according the same equations as above. In order to keep the pool prices stable in join/exit operations, the virtual balance should always be kept proportional to LP supply of the pool. Therefore the stored virtual balance is calculated by dividing the given amount by the calculated LP supply. The virtual balance is stored as a model with the following structure:
message PermanentVirtualBalancePoolToken {
uint64 pool_id = 1;
string denom = 2;
string virtual_balance = 3 [
(cosmos_proto.scalar) = "cosmos.Dec",
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Dec",
(gogoproto.nullable) = false
];
}
To calculate the effective virtual balance in pool operations, the virtual balance is multiplied by the LP supply of the pool.
Note that as the name suggests, the permanent virtual balances are always kept in the pool until the pool is completely drained.
Token Weights
As outlined in the weighted pool equations, each token in the pool should have an assigned weight. Consequently, we store the following data for each token of a weighted pool:
message WeightedToken {
uint64 pool_id = 1;
string denom = 2;
string normalized_start_weight = 3 [
(cosmos_proto.scalar) = "cosmos.Dec",
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Dec",
(gogoproto.nullable) = false
];
string normalized_end_weight = 4 [
(cosmos_proto.scalar) = "cosmos.Dec",
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Dec",
(gogoproto.nullable) = false
];
}
The pool_id
and denom
serve as keys to reference a specific token in a particular pool, while the remaining parameters are used to determine the token weights. These weights can change linearly over time, shifting from a normalized_start_weight
to a normalized_end_weight
within a specified time range per pool. This time range is stored in the following structure:
message WeightUpdateTiming {
uint64 pool_id = 1;
int64 start_unix_millis = 2;
int64 end_unix_millis = 3;
}
A pool's owner can request a weight update process based on the timing and target token weights. Weights are then programmed to transition linearly within the given time range from their current value to the target value.
With this model, token weights are always computed on demand using the following methods:
func (m *WeightUpdateTiming) CalculateProgress(blockTime time.Time) sdk.Dec {
currentTime := blockTime.UTC().UnixMilli()
// if ended -> progress is one
if m.EndUnixMillis <= currentTime {
return sdk.OneDec()
}
// if not started yet -> progress is zero
if currentTime <= m.StartUnixMillis {
return sdk.ZeroDec()
}
rangeLength := sdk.NewDec(m.EndUnixMillis - m.StartUnixMillis)
elapsed := sdk.NewDec(currentTime - m.StartUnixMillis)
// We don't need to consider zero division here as this is covered above.
return QuoDown(elapsed, rangeLength)
}
func (m *WeightedToken) CalculateNormalizedWeight(changeProgress sdk.Dec) sdk.Dec {
start := m.NormalizedStartWeight
end := m.NormalizedEndWeight
if changeProgress.GTE(sdk.OneDec()) || start.Equal(end) {
return end
}
if changeProgress.IsZero() {
return start
}
if start.GT(end) {
delta := MulDown(changeProgress, start.Sub(end))
return start.Sub(delta)
} else {
delta := MulDown(changeProgress, end.Sub(start))
return start.Add(delta)
}
}
Introducing Tokens
You cannot add a new token with a zero balance to an existing and initialized weighted pool (a type of balancer pool). To include a new token , we'll use a virtual adjustment balance, which will gradually decrease for the new token. This will stimulate arbitrage opportunities, increasing the real balance of the new token.
Let be the desired pool weight of the new token. Let and denote the weight and balance of token in the pool, respectively. Let be the lower price bound for the new token in terms of token . Let be the LP supply of the pool at time . We define the initial balance at introduction time as:
is only computed at introduction time and remains unchanged thereafter.
The virtual adjustment balance of the new token is given by
where is when the new token is added to the pool, and is the time over which the virtual adjustment balance decreases to . The time can be any suitable duration (a week, fifteen days, a month, etc.) that allows a slow decay of the virtual adjustment balance. Note that and for all . Upon introducing a new token , we assign it a weight and adjust the weights of the existing tokens in the Balancer-type pool as follows:
This ensures that the sum of the weights for all tokens in the pool remains equal to 1. Let's denote the weight of token in the pool after adding token as . Immediately after the introduction of token , the spot price of token in terms of token is
This price is lower than the actual price of token in terms of token . As the virtual balance of token drops to , the spot price of token will gradually increase. At some point, arbitrageurs will find it profitable to sell token to the pool, thus enhancing its real balance.
Please note that the sum of the real balance and the virtual balance is used as the token's balance in the pool when executing trades.
For newly introduced tokens, the following record structure is created:
message TemporalVirtualBalancePoolToken {
uint64 pool_id = 1;
string denom = 2;
string target_virtual_balance = 3 [
(cosmos_proto.scalar) = "cosmos.Dec",
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Dec",
(gogoproto.nullable) = false
];
int64 start_unix_millis = 4;
int64 end_unix_millis = 5;
}
Adding YAMM LPT
As explained in the Yield AMM section, our unique pool comprises cASSETs and pASSETs associated with a specific asset. In PRYZM, we endeavor to facilitate the creation of weighted pools that include the LP token from these pools, offering users an easy way to navigate multiple Yield AMMs. While creating such a pool is simple, introducing a new asset to the system and adding its LP to an existing pool requires a token introduction process. This process is explained in detail below.
We've already covered token introduction for weighted pools. However, to introduce new tokens to a weighted pool, we need its price in terms of the tokens already present in the pool. The amm module leverages PRYZM Oracle and price feeders to fulfill this requirement.
The steps to introduce new LP tokens to a nested pool are as follows:
- Governance creates an oracle price pair. This structure informs the feeders on how to compute the price of the underlying asset in terms of a specific quote token in the target weighted pool.
message Pair {
string base = 1;
string quote = 2;
string pool_id = 3; //refers to the data source pool (e.g. osmosis gamm pool)
string data_source = 4;
}
// TwapAlgorithm enumerates the valid algorithms for twap_algorithm.
enum TwapAlgorithm {
option (gogoproto.goproto_enum_prefix) = false;
TWAP_ALGORITHM_ARITHMETIC = 0 [(gogoproto.enumvalue_customname) = "ArithmeticTwapAlgorithm"];
TWAP_ALGORITHM_GEOMETRIC = 1 [(gogoproto.enumvalue_customname) = "GeometricTwapAlgorithm"];
}
message OraclePricePair {
string asset_id = 1;
// this is the token denom which should exist in the target weighted pool in pryzm chain
// the reason for adding this property and not using the pairs, is that the token denom in various chains might be different
// for example usdc token might have contract or ibc denom on different chains with different channel and ids
string quote_token = 2;
uint64 twap_duration_millis = 3;
TwapAlgorithm twap_algorithm = 4;
bool disabled = 5;
repeated Pair pairs = 6 [
(gogoproto.castrepeated) = "Pairs",
(gogoproto.nullable) = false
];
// this is the denom of the base token on this chain
// should be ibc denom for most cases
string base_denom = 7;
}
The OraclePricePair
message comprises five fields and one repeated field:
asset_id
: A string denoting the asset identifier. For instance, to introduce the LP token of Luna YAMM pool, assetId of the Luna asset would be used.quote_token
: A string denoting the quote token identifier, which should already exist in the target weighted pool.twap_duration_millis
: An integer representing the TWAP calculation duration in milliseconds.twap_algorithm
: An enum indicating the TWAP calculation algorithm used by the price feeders.disabled
: A boolean indicating whether the oracle price pair is disabled. Disabled price pairs do not get price data from feeders.pairs
: A route of pairs, including the pairs of base and quote tokens used to calculate the asset price in terms of the quote token. EachPair
message has three fields:base
,quote
, andpool_id
.pool_id
is a string denoting the data source pool identifier (e.g., an Osmosis Gamm pool), andbase
andquote
are strings denoting the base and quote tokens of the pair.
Upon having a price-pair, pool creator (or someone from administration list of the pool) can send a message to introduce the LPT to the target weighted pool. The message should include:
- Target weighted pool ID.
- Yamm pool ID.
- New token weight in the pool. Post-introduction, the LP token will have this weight in the weighted pool.
- Virtual balance interval, which is the time window discussed in the token introduction section. It starts from a computed value and gradually decreases to zero during this window.
We then first check the pool. If it's not initialized, we simply add the token to the pool with zero balance. Otherwise, we initiate the introduction by creating a pending token introduction with the following structure:
message PendingTokenIntroduction {
string asset_id = 1;
uint64 target_pool_id = 2;
string token_denom = 3;
string token_normalized_weight = 4 [
(cosmos_proto.scalar) = "cosmos.Dec",
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Dec",
(gogoproto.nullable) = false
];
int64 virtual_balance_interval_millis = 5;
}
In addition to establishing the pending token introduction, we also need governance to activate the oracle price pair, enabling price feeders to report the asset's price in terms of the specified quote.
- The oracle feeders report the price in the following payload:
message OraclePayloadDataSourceBlockHeight {
string data_source = 1;
ibc.core.client.v1.Height block_height = 2 [(gogoproto.nullable) = false];
}
// OraclePayload defines the structure of oracle vote payload
message OraclePayload {
repeated OraclePayloadDataSourceBlockHeight data_source_block_heights = 1 [(gogoproto.nullable) = false];
string price = 2 [
(cosmos_proto.scalar) = "cosmos.Dec",
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Dec",
(gogoproto.nullable) = false
];
repeated Pair pairs = 3 [
(gogoproto.castrepeated) = "Pairs",
(gogoproto.nullable) = false
];
string quote_token = 4;
}
We calculate the LP token's price lower bound in terms of the specified quote. This is done by first determining a lower bound on the LP price in terms of the asset, then multiplying it by the given asset price.
Suppose we have the asset price in terms of a token in the weighted pool, denoted . To obtain a lower limit for the LP token price () minted by a YAMM pool in terms of the chosen token , we proceed as follows. Let be the cAsset balance in the YAMM pool, be the cAsset price in terms of the corresponding Asset, and be the Asset price (or a suitable lower limit for it) in terms of token . Let be the existing LP tokens amount of the YAMM pool, and be the YAMM pool's total value in terms of . Then
and we take
as the lower limit for the equations in the token introduction section. Following the equations in that section, we can add the new token with a virtual balance.
Adding Base Tokens
We not only plan to have a weighted pool of YAMM LP tokens, but also of the pools' actual underlying assets. This pool facilitates instant batch swaps from the base assets to any other asset. Like the nested pool discussed earlier, we want the capability to add new refractable assets over time, necessitating the base tokens' addition to this weighted pool.
The process of introducing base tokens to a weighted pool is quite similar to that for the LP tokens, except for the price computation segment. As we have oracle feeders reporting the base asset's price, we don't need to contemplate the equations to compute the LP token's price. We can simply use the reported price for token introduction and virtual balance calculations.
Token Removal
Just as we may need to introduce new tokens into the pool, we may also need to remove tokens. To extract a specific token from the pool, we will again utilize virtual balances. The token removal process involves two steps. First Step: Let's select an appropriate time period (such as one week) and gradually escalate the virtual adjustment balance for token . Specifically, if is the initiation time of this step, and represents the balance of token at time , we establish the virtual adjustment balance as follows:
Consequently, , meaning that after the time period , the virtual adjustment balance is equivalent to the balance of token at the commencement of the first step.
Undoubtedly, this strategy will cause the price of token to decrease gradually, incentivizing arbitrageurs to purchase token from the pool. It's crucial that we prohibit traders from selling token to the pool during this phase, as it could hinder the token's removal.
Second Step: As soon as the actual balance of token in the pool reduces to , we activate a special function to remove this token from the pool. Note that the weights of the remaining pool tokens must be proportionally adjusted to maintain a sum of . Specifically, if is the weight of token , we adjust the weights of the other pool tokens in the following manner when the token removal function is invoked:
This ensures that the sum of the weights of the remaining pool tokens remains .
Similar to the process of introducing tokens, we form an expiring virtual token for these tokens with the following structure:
message TemporalVirtualBalancePoolToken {
uint64 pool_id = 1;
string denom = 2;
string target_virtual_balance = 3 [
(cosmos_proto.scalar) = "cosmos.Dec",
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Dec",
(gogoproto.nullable) = false
];
int64 start_unix_millis = 4;
int64 end_unix_millis = 5;
}