Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add omnipool liquidity limits #822

Merged
merged 14 commits into from
Jun 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pallets/omnipool/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pallet-omnipool"
version = "4.2.2"
version = "4.3.0"
authors = ['GalacticCouncil']
edition = "2021"
license = "Apache-2.0"
Expand Down
84 changes: 84 additions & 0 deletions pallets/omnipool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,8 @@ pub mod pallet {
ZeroAmountOut,
/// Existential deposit of asset is not available.
ExistentialDepositNotAvailable,
/// Slippage protection
SlippageLimit,
}

#[pallet::call]
Expand Down Expand Up @@ -580,6 +582,44 @@ pub mod pallet {
)]
#[transactional]
pub fn add_liquidity(origin: OriginFor<T>, asset: T::AssetId, amount: Balance) -> DispatchResult {
Self::add_liquidity_with_limit(origin, asset, amount, Balance::MIN)
}

/// Add liquidity of asset `asset` in quantity `amount` to Omnipool.
///
/// Limit protection is applied.
///
/// `add_liquidity` adds specified asset amount to Omnipool and in exchange gives the origin
/// corresponding shares amount in form of NFT at current price.
///
/// Asset's tradable state must contain ADD_LIQUIDITY flag, otherwise `NotAllowed` error is returned.
///
/// NFT is minted using NTFHandler which implements non-fungibles traits from frame_support.
///
/// Asset weight cap must be respected, otherwise `AssetWeightExceeded` error is returned.
/// Asset weight is ratio between new HubAsset reserve and total reserve of Hub asset in Omnipool.
///
/// Add liquidity fails if price difference between spot price and oracle price is higher than allowed by `PriceBarrier`.
///
/// Parameters:
/// - `asset`: The identifier of the new asset added to the pool. Must be already in the pool
/// - `amount`: Amount of asset added to omnipool
/// - `min_shares_limit`: The min amount of delta share asset the user should receive in the position
///
/// Emits `LiquidityAdded` event when successful.
///
#[pallet::call_index(13)]
#[pallet::weight(<T as Config>::WeightInfo::add_liquidity()
.saturating_add(T::OmnipoolHooks::on_liquidity_changed_weight()
.saturating_add(T::ExternalPriceOracle::get_price_weight()))
)]
#[transactional]
pub fn add_liquidity_with_limit(
origin: OriginFor<T>,
asset: T::AssetId,
amount: Balance,
min_shares_limit: Balance,
) -> DispatchResult {
let who = ensure_signed(origin.clone())?;

ensure!(
Expand Down Expand Up @@ -625,6 +665,11 @@ pub mod pallet {
)
.ok_or(ArithmeticError::Overflow)?;

ensure!(
*state_changes.asset.delta_shares >= min_shares_limit,
Error::<T>::SlippageLimit
);

let new_asset_state = asset_state
.delta_update(&state_changes.asset)
.ok_or(ArithmeticError::Overflow)?;
Expand Down Expand Up @@ -724,6 +769,40 @@ pub mod pallet {
origin: OriginFor<T>,
position_id: T::PositionItemId,
amount: Balance,
) -> DispatchResult {
Self::remove_liquidity_with_limit(origin, position_id, amount, Balance::MIN)
}

/// Remove liquidity of asset `asset` in quantity `amount` from Omnipool
///
/// Limit protection is applied.
///
/// `remove_liquidity` removes specified shares amount from given PositionId (NFT instance).
///
/// Asset's tradable state must contain REMOVE_LIQUIDITY flag, otherwise `NotAllowed` error is returned.
///
/// if all shares from given position are removed, position is destroyed and NFT is burned.
///
/// Remove liquidity fails if price difference between spot price and oracle price is higher than allowed by `PriceBarrier`.
///
/// Dynamic withdrawal fee is applied if withdrawal is not safe. It is calculated using spot price and external price oracle.
/// Withdrawal is considered safe when trading is disabled.
///
/// Parameters:
/// - `position_id`: The identifier of position which liquidity is removed from.
/// - `amount`: Amount of shares removed from omnipool
/// - `min_limit`: The min amount of asset to be removed for the user
///
/// Emits `LiquidityRemoved` event when successful.
///
#[pallet::call_index(14)]
#[pallet::weight(<T as Config>::WeightInfo::remove_liquidity().saturating_add(T::OmnipoolHooks::on_liquidity_changed_weight()))]
#[transactional]
pub fn remove_liquidity_with_limit(
origin: OriginFor<T>,
position_id: T::PositionItemId,
amount: Balance,
min_limit: Balance,
) -> DispatchResult {
let who = ensure_signed(origin.clone())?;

Expand Down Expand Up @@ -790,6 +869,11 @@ pub mod pallet {
)
.ok_or(ArithmeticError::Overflow)?;

ensure!(
*state_changes.asset.delta_reserve >= min_limit,
Error::<T>::SlippageLimit
);

let new_asset_state = asset_state
.delta_update(&state_changes.asset)
.ok_or(ArithmeticError::Overflow)?;
Expand Down
248 changes: 248 additions & 0 deletions pallets/omnipool/src/tests/add_liquidity_with_limit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
use super::*;
use frame_support::assert_noop;

#[test]
fn add_liquidity_should_work_when_asset_exists_in_pool() {
ExtBuilder::default()
.add_endowed_accounts((LP1, 1_000, 5000 * ONE))
.add_endowed_accounts((LP2, 1_000, 5000 * ONE))
.with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1))
.with_token(1_000, FixedU128::from_float(0.65), LP2, 2000 * ONE)
.build()
.execute_with(|| {
let token_amount = 2000 * ONE;
let liq_added = 400 * ONE;

// ACT
let position_id = last_position_id();
assert_ok!(Omnipool::add_liquidity_with_limit(
RuntimeOrigin::signed(LP1),
1_000,
liq_added,
liq_added
));

// ASSERT - asset state, pool state, position
assert_asset_state!(
1_000,
AssetReserveState {
reserve: token_amount + liq_added,
hub_reserve: 1560 * ONE,
shares: 2400 * ONE,
protocol_shares: Balance::zero(),
cap: DEFAULT_WEIGHT_CAP,
tradable: Tradability::default(),
}
);

let position = Positions::<Test>::get(position_id).unwrap();

let expected = Position::<Balance, AssetId> {
asset_id: 1_000,
amount: liq_added,
shares: liq_added,
price: (1560 * ONE, token_amount + liq_added),
};

assert_eq!(position, expected);

assert_pool_state!(12_060 * ONE, 24_120 * ONE, SimpleImbalance::default());

assert_balance!(LP1, 1_000, 4600 * ONE);

let minted_position = POSITIONS.with(|v| v.borrow().get(&position_id).copied());

assert_eq!(minted_position, Some(LP1));
});
}

#[test]
fn add_stable_asset_liquidity_works() {
ExtBuilder::default()
.add_endowed_accounts((LP1, DAI, 5000 * ONE))
.add_endowed_accounts((LP2, 1_000, 5000 * ONE))
.with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1))
.build()
.execute_with(|| {
let liq_added = 400 * ONE;
let position_id = <NextPositionId<Test>>::get();
assert_ok!(Omnipool::add_liquidity_with_limit(
RuntimeOrigin::signed(LP1),
DAI,
liq_added,
liq_added
));

assert_asset_state!(
DAI,
AssetReserveState {
reserve: 1000 * ONE + liq_added,
hub_reserve: 700000000000000,
shares: 1400000000000000,
protocol_shares: 0,
cap: DEFAULT_WEIGHT_CAP,
tradable: Tradability::default(),
}
);

let position = Positions::<Test>::get(position_id).unwrap();

let expected = Position::<Balance, AssetId> {
asset_id: DAI,
amount: liq_added,
shares: liq_added,
price: (700 * ONE, 1400 * ONE),
};

assert_eq!(position, expected);

assert_pool_state!(10_700 * ONE, 21_400 * ONE, SimpleImbalance::default());

assert_balance!(LP1, DAI, 4600 * ONE);

let minted_position = POSITIONS.with(|v| v.borrow().get(&position_id).copied());

assert_eq!(minted_position, Some(LP1));
});
}

#[test]
fn add_liquidity_for_non_pool_token_fails() {
ExtBuilder::default()
.add_endowed_accounts((LP1, 1_000, 5000 * ONE))
.with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1))
.build()
.execute_with(|| {
assert_noop!(
Omnipool::add_liquidity_with_limit(RuntimeOrigin::signed(LP1), 1_000, 2000 * ONE, 2000 * ONE),
Error::<Test>::AssetNotFound
);
});
}

#[test]
fn add_liquidity_with_insufficient_balance_fails() {
ExtBuilder::default()
.add_endowed_accounts((LP1, 1_000, 5000 * ONE))
.with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1))
.with_token(1_000, FixedU128::from_float(0.65), LP1, 2000 * ONE)
.build()
.execute_with(|| {
assert_noop!(
Omnipool::add_liquidity_with_limit(RuntimeOrigin::signed(LP3), 1_000, 2000 * ONE, 2000 * ONE),
Error::<Test>::InsufficientBalance
);
});
}

#[test]
fn add_liquidity_exceeding_weight_cap_fails() {
ExtBuilder::default()
.add_endowed_accounts((LP1, 1_000, 5000 * ONE))
.with_asset_weight_cap(Permill::from_float(0.1))
.with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1))
.with_token(1_000, FixedU128::from_float(0.65), LP1, 100 * ONE)
.build()
.execute_with(|| {
assert_noop!(
Omnipool::add_liquidity_with_limit(RuntimeOrigin::signed(LP1), 1_000, 2000 * ONE, 2000 * ONE),
Error::<Test>::AssetWeightCapExceeded
);
});
}

#[test]
fn add_insufficient_liquidity_fails() {
ExtBuilder::default()
.add_endowed_accounts((LP1, 1_000, 5000 * ONE))
.with_min_added_liquidity(5 * ONE)
.with_asset_weight_cap(Permill::from_float(0.1))
.with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1))
.with_token(1_000, FixedU128::from_float(0.65), LP1, 2000 * ONE)
.build()
.execute_with(|| {
assert_noop!(
Omnipool::add_liquidity_with_limit(RuntimeOrigin::signed(LP3), 1_000, ONE, ONE),
Error::<Test>::InsufficientLiquidity
);
});
}

#[test]
fn add_liquidity_should_fail_when_asset_state_does_not_include_add_liquidity() {
ExtBuilder::default()
.add_endowed_accounts((LP1, 1_000, 5000 * ONE))
.with_min_added_liquidity(ONE)
.with_asset_weight_cap(Permill::from_float(0.1))
.with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1))
.with_token(1_000, FixedU128::from_float(0.65), LP1, 2000 * ONE)
.build()
.execute_with(|| {
assert_ok!(Omnipool::set_asset_tradable_state(
RuntimeOrigin::root(),
1000,
Tradability::SELL | Tradability::BUY | Tradability::REMOVE_LIQUIDITY
));

assert_noop!(
Omnipool::add_liquidity_with_limit(RuntimeOrigin::signed(LP1), 1_000, 2 * ONE, 2 * ONE),
Error::<Test>::NotAllowed
);
});
}

#[test]
fn add_liquidity_should_fail_when_prices_differ_and_is_higher() {
ExtBuilder::default()
.add_endowed_accounts((LP1, 1_000, 5000 * ONE))
.add_endowed_accounts((LP2, 1_000, 5000 * ONE))
.with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1))
.with_token(1_000, FixedU128::from_float(0.65), LP2, 2000 * ONE)
.with_max_allowed_price_difference(Permill::from_percent(1))
.with_external_price_adjustment((3, 100, false))
.build()
.execute_with(|| {
assert_noop!(
Omnipool::add_liquidity_with_limit(RuntimeOrigin::signed(LP1), 1_000, 400 * ONE, 400 * ONE),
Error::<Test>::PriceDifferenceTooHigh
);
});
}

#[test]
fn add_liquidity_should_fail_when_prices_differ_and_is_lower() {
ExtBuilder::default()
.add_endowed_accounts((LP1, 1_000, 5000 * ONE))
.add_endowed_accounts((LP2, 1_000, 5000 * ONE))
.with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1))
.with_token(1_000, FixedU128::from_float(0.65), LP2, 2000 * ONE)
.with_max_allowed_price_difference(Permill::from_percent(1))
.with_external_price_adjustment((3, 100, true))
.build()
.execute_with(|| {
assert_noop!(
Omnipool::add_liquidity_with_limit(RuntimeOrigin::signed(LP1), 1_000, 400 * ONE, 400 * ONE),
Error::<Test>::PriceDifferenceTooHigh
);
});
}

#[test]
fn add_liquidity_should_fail_when_doesnt_reach_min_limit() {
ExtBuilder::default()
.add_endowed_accounts((LP1, 1_000, 5000 * ONE))
.add_endowed_accounts((LP2, 1_000, 5000 * ONE))
.with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1))
.with_token(1_000, FixedU128::from_float(0.65), LP2, 2000 * ONE)
.build()
.execute_with(|| {
//Do some trade not to have parity between liquidity and shares
assert_ok!(Omnipool::sell(RuntimeOrigin::signed(LP1), 1_000, DAI, 20 * ONE, 0));

// ACT
assert_noop!(
Omnipool::add_liquidity_with_limit(RuntimeOrigin::signed(LP1), 1_000, 500 * ONE, 496 * ONE), //user received 495, so below limit
Error::<Test>::SlippageLimit
);
});
}
2 changes: 2 additions & 0 deletions pallets/omnipool/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ mod invariants;
mod remove_liquidity;
mod sell;

mod add_liquidity_with_limit;
mod barrier;
mod imbalance;
pub(crate) mod mock;
mod positions;
mod refund;
mod remove_liquidity_with_limit;
mod remove_token;
mod spot_price;
mod tradability;
Expand Down
Loading
Loading