diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 2ae4c1182c0..9e27f80b68d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -68,9 +68,9 @@ jobs: /home/runner/.cargo/bin/rustup show - name: Run Test (with coverage) run: | - /home/runner/.cargo/bin/cargo install -f cargo-llvm-cov + /home/runner/.cargo/bin/cargo +nightly-2021-11-08 install -f cargo-llvm-cov /home/runner/.cargo/bin/rustup component add llvm-tools-preview --toolchain=nightly-2021-11-08 - SKIP_WASM_BUILD=1 /home/runner/.cargo/bin/cargo +nightly llvm-cov --workspace --locked --release --verbose --features=runtime-benchmarks --lcov --output-path lcov.info + SKIP_WASM_BUILD=1 /home/runner/.cargo/bin/cargo +nightly-2021-11-08 llvm-cov --workspace --locked --release --verbose --features=runtime-benchmarks --lcov --output-path lcov.info - name: Upload coverage to Codecov uses: codecov/codecov-action@v2 with: diff --git a/frame/composable-support/src/validation.rs b/frame/composable-support/src/validation.rs index b222dcc0616..0d2e352b9db 100644 --- a/frame/composable-support/src/validation.rs +++ b/frame/composable-support/src/validation.rs @@ -1,7 +1,7 @@ //! Module for validating extrinsic inputs //! //! This module is made of two main parts that are needed to validate an -//! extrinsic input, the `Validated` struct and the `Valitate` trait. +//! extrinsic input, the `Validated` struct and the `Validate` trait. //! //! # Example //! ## Single Validation diff --git a/frame/composable-traits/src/dex.rs b/frame/composable-traits/src/dex.rs index b276e24b793..a38b7130007 100644 --- a/frame/composable-traits/src/dex.rs +++ b/frame/composable-traits/src/dex.rs @@ -1,11 +1,17 @@ +use crate::{ + defi::CurrencyPair, + math::{SafeAdd, SafeSub}, +}; use codec::{Decode, Encode, MaxEncodedLen}; use frame_support::{traits::Get, BoundedVec, RuntimeDebug}; use scale_info::TypeInfo; -use sp_runtime::{DispatchError, Permill}; +use sp_arithmetic::traits::Saturating; +use sp_runtime::{ + traits::{CheckedMul, CheckedSub}, + ArithmeticError, DispatchError, Permill, +}; use sp_std::vec::Vec; -use crate::defi::CurrencyPair; - /// Trait for automated market maker. pub trait Amm { /// The asset ID type @@ -144,6 +150,96 @@ pub struct ConstantProductPoolInfo { pub owner_fee: Permill, } +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum SaleState { + NotStarted, + Ongoing, + Ended, +} + +#[derive(RuntimeDebug, Encode, Decode, MaxEncodedLen, Copy, Clone, PartialEq, Eq, TypeInfo)] +pub struct Sale { + /// Block at which the sale start. + pub start: BlockNumber, + /// Block at which the sale stop. + pub end: BlockNumber, + /// Initial weight of the base asset of the current pair. + pub initial_weight: Permill, + /// Final weight of the base asset of the current pair. + pub final_weight: Permill, +} + +impl + Ord + Copy + Saturating + SafeAdd + SafeSub> Sale { + // TODO unit test + pub fn current_weights( + &self, + current_block: BlockNumber, + ) -> Result<(Permill, Permill), DispatchError> { + /* NOTE(hussein-aitlahcen): currently only linear + + Linearly decrease the base asset initial_weight to final_weight. + Quote asset weight is simple 1-base_asset_weight + + Assuming final_weight < initial_weight + current_weight = initial_weight - (current - start) / (end - start) * (initial_weight - final_weight) + = initial_weight - normalized_current / sale_duration * weight_range + = initial_weight - point_in_sale * weight_range + */ + let normalized_current_block = current_block.safe_sub(&self.start)?; + let point_in_sale = Permill::from_rational( + normalized_current_block.try_into().map_err(|_| ArithmeticError::Overflow)?, + self.duration().try_into().map_err(|_| ArithmeticError::Overflow)?, + ); + let weight_range = self + .initial_weight + .checked_sub(&self.final_weight) + .ok_or(ArithmeticError::Underflow)?; + let current_base_weight = self + .initial_weight + .checked_sub( + &point_in_sale.checked_mul(&weight_range).ok_or(ArithmeticError::Overflow)?, + ) + .ok_or(ArithmeticError::Underflow)?; + let current_quote_weight = Permill::one() + .checked_sub(¤t_base_weight) + .ok_or(ArithmeticError::Underflow)?; + Ok((current_base_weight, current_quote_weight)) + } +} + +impl Sale { + pub fn duration(&self) -> BlockNumber { + // NOTE(hussein-aitlahcen): end > start as previously checked by PoolIsValid. + self.end.saturating_sub(self.start) + } +} + +impl Sale { + pub fn state(&self, current_block: BlockNumber) -> SaleState { + if current_block < self.start { + SaleState::NotStarted + } else if current_block >= self.end { + SaleState::Ended + } else { + SaleState::Ongoing + } + } +} + +#[derive(RuntimeDebug, Encode, Decode, MaxEncodedLen, Copy, Clone, PartialEq, Eq, TypeInfo)] +pub struct LiquidityBootstrappingPoolInfo { + /// Owner of the pool + pub owner: AccountId, + /// Asset pair of the pool along their weight. + /// Base asset is the project token. + /// Quote asset is the collateral token. + pub pair: CurrencyPair, + /// Sale period of the LBP. + pub sale: Sale, + /// Trading fees. + pub fee: Permill, +} + #[derive(Encode, Decode, MaxEncodedLen, TypeInfo, Clone, PartialEq, Eq, RuntimeDebug)] pub enum DexRouteNode { Curve(PoolId), diff --git a/frame/pablo/plots/lbp/lbp_buy_project.png b/frame/pablo/plots/lbp/lbp_buy_project.png new file mode 100644 index 00000000000..0629b9b6e44 Binary files /dev/null and b/frame/pablo/plots/lbp/lbp_buy_project.png differ diff --git a/frame/pablo/plots/lbp/lbp_sell_project.png b/frame/pablo/plots/lbp/lbp_sell_project.png new file mode 100644 index 00000000000..4ac62f9f7e4 Binary files /dev/null and b/frame/pablo/plots/lbp/lbp_sell_project.png differ diff --git a/frame/pablo/plots/lbp/lbp_spot_price.png b/frame/pablo/plots/lbp/lbp_spot_price.png new file mode 100644 index 00000000000..a8341757008 Binary files /dev/null and b/frame/pablo/plots/lbp/lbp_spot_price.png differ diff --git a/frame/pablo/plots/lbp/lbp_weights.png b/frame/pablo/plots/lbp/lbp_weights.png new file mode 100644 index 00000000000..244833c6666 Binary files /dev/null and b/frame/pablo/plots/lbp/lbp_weights.png differ diff --git a/frame/pablo/src/common_test_functions.rs b/frame/pablo/src/common_test_functions.rs index 18b45bba69c..2f691ef8d81 100644 --- a/frame/pablo/src/common_test_functions.rs +++ b/frame/pablo/src/common_test_functions.rs @@ -1,6 +1,6 @@ use crate::{ mock::{Pablo, *}, - PoolConfiguration::{ConstantProduct, StableSwap}, + PoolConfiguration::{ConstantProduct, LiquidityBootstrapping, StableSwap}, PoolInitConfiguration, }; use frame_support::{ @@ -11,7 +11,7 @@ use frame_support::{ /// `expected_lp_check` takes base_amount, quote_amount and lp_tokens in order and returns /// true if lp_tokens are expected for given base_amount, quote_amount. pub fn common_add_remove_lp( - init_config: PoolInitConfiguration, + init_config: PoolInitConfiguration, init_base_amount: Balance, init_quote_amount: Balance, base_amount: Balance, @@ -22,6 +22,7 @@ pub fn common_add_remove_lp( let pair = match init_config { PoolInitConfiguration::StableSwap { pair, .. } => pair, PoolInitConfiguration::ConstantProduct { pair, .. } => pair, + PoolInitConfiguration::LiquidityBootstrapping(pool) => pool.pair, }; // Mint the tokens assert_ok!(Tokens::mint_into(pair.base, &ALICE, init_base_amount)); @@ -41,6 +42,7 @@ pub fn common_add_remove_lp( let lp_token = match pool { StableSwap(pool) => pool.lp_token, ConstantProduct(pool) => pool.lp_token, + LiquidityBootstrapping(_) => panic!("Not implemented"), }; // Mint the tokens assert_ok!(Tokens::mint_into(pair.base, &BOB, base_amount)); diff --git a/frame/pablo/src/lib.rs b/frame/pablo/src/lib.rs index 67b565836d1..d2ab7770b24 100644 --- a/frame/pablo/src/lib.rs +++ b/frame/pablo/src/lib.rs @@ -38,12 +38,15 @@ pub use pallet::*; #[cfg(test)] mod common_test_functions; #[cfg(test)] +mod liquidity_bootstrapping_tests; +#[cfg(test)] mod mock; #[cfg(test)] mod stable_swap_tests; #[cfg(test)] mod uniswap_tests; +mod liquidity_bootstrapping; mod stable_swap; mod uniswap; @@ -64,41 +67,62 @@ pub mod pallet { transactional, PalletId, RuntimeDebug, }; - use frame_system::{ensure_signed, pallet_prelude::OriginFor}; + use crate::liquidity_bootstrapping::LiquidityBootstrapping; + use composable_support::validation::Validated; + use composable_traits::{currency::LocalAssets, dex::LiquidityBootstrappingPoolInfo}; + use frame_system::{ + ensure_signed, + pallet_prelude::{BlockNumberFor, OriginFor}, + }; use sp_runtime::{ - traits::{AccountIdConversion, Convert, One, Zero}, + traits::{AccountIdConversion, BlockNumberProvider, Convert, One, Zero}, Permill, }; #[derive(RuntimeDebug, Encode, Decode, MaxEncodedLen, Clone, PartialEq, Eq, TypeInfo)] - pub enum PoolInitConfiguration { + pub enum PoolInitConfiguration { StableSwap { + // TODO consider adding the owner here to allow a third party owner pair: CurrencyPair, amplification_coefficient: u16, fee: Permill, protocol_fee: Permill, }, ConstantProduct { + // TODO consider adding the owner here to allow a third party owner pair: CurrencyPair, fee: Permill, owner_fee: Permill, }, + LiquidityBootstrapping(LiquidityBootstrappingPoolInfo), } #[derive(RuntimeDebug, Encode, Decode, MaxEncodedLen, Clone, PartialEq, Eq, TypeInfo)] - pub enum PoolConfiguration { + pub enum PoolConfiguration { StableSwap(StableSwapPoolInfo), ConstantProduct(ConstantProductPoolInfo), + LiquidityBootstrapping(LiquidityBootstrappingPoolInfo), } - type AssetIdOf = ::AssetId; - type BalanceOf = ::Balance; - type AccountIdOf = ::AccountId; + pub(crate) type AssetIdOf = ::AssetId; + pub(crate) type BalanceOf = ::Balance; + pub(crate) type AccountIdOf = ::AccountId; + pub(crate) type LiquidityBootstrappingPoolInfoOf = LiquidityBootstrappingPoolInfo< + ::AccountId, + ::AssetId, + ::BlockNumber, + >; type PoolIdOf = ::PoolId; - type PoolConfigurationOf = - PoolConfiguration<::AccountId, ::AssetId>; - type PoolInitConfigurationOf = PoolInitConfiguration<::AssetId>; - + type PoolConfigurationOf = PoolConfiguration< + ::AccountId, + ::AssetId, + ::BlockNumber, + >; + type PoolInitConfigurationOf = PoolInitConfiguration< + ::AccountId, + ::AssetId, + ::BlockNumber, + >; // TODO refactor event publishing with cu-23v2y3n #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] @@ -240,6 +264,25 @@ pub mod pallet { #[pallet::constant] type PalletId: Get; + + // Used for spot price calculation for LBP + type LocalAssets: LocalAssets>; + + /// Minimum duration for a sale. + #[pallet::constant] + type LbpMinSaleDuration: Get>; + + /// Maximum duration for a sale. + #[pallet::constant] + type LbpMaxSaleDuration: Get>; + + /// Maximum initial weight. + #[pallet::constant] + type LbpMaxInitialWeight: Get; + + /// Minimum final weight. + #[pallet::constant] + type LbpMinFinalWeight: Get; } #[pallet::pallet] @@ -407,6 +450,13 @@ pub mod pallet { Self::deposit_event(Event::PoolCreated { owner: who.clone(), pool_id }); Ok(pool_id) }, + PoolInitConfiguration::LiquidityBootstrapping(pool_config) => { + let validated_pool_config = Validated::new(pool_config)?; + let pool_id = + LiquidityBootstrapping::::do_create_pool(validated_pool_config)?; + Self::deposit_event(Event::PoolCreated { owner: who.clone(), pool_id }); + Ok(pool_id) + }, } } @@ -439,6 +489,8 @@ pub mod pallet { PoolConfiguration::StableSwap(stable_swap_pool_info) => Ok(stable_swap_pool_info.pair), ConstantProduct(constant_product_pool_info) => Ok(constant_product_pool_info.pair), + PoolConfiguration::LiquidityBootstrapping(liquidity_bootstrapping_pool_info) => + Ok(liquidity_bootstrapping_pool_info.pair), } } @@ -463,6 +515,13 @@ pub mod pallet { asset_id, amount, ), + PoolConfiguration::LiquidityBootstrapping(liquidity_bootstrapping_pool_info) => + LiquidityBootstrapping::::get_exchange_value( + liquidity_bootstrapping_pool_info, + pool_account, + asset_id, + amount, + ), } } @@ -514,6 +573,24 @@ pub mod pallet { minted_lp: mint_amount, }); }, + PoolConfiguration::LiquidityBootstrapping(liquidity_bootstrapping_pool_info) => { + LiquidityBootstrapping::::add_liquidity( + who, + liquidity_bootstrapping_pool_info, + pool_account, + base_amount, + quote_amount, + min_mint_amount, + keep_alive, + )?; + Self::deposit_event(Event::::LiquidityAdded { + who: who.clone(), + pool_id, + base_amount, + quote_amount, + minted_lp: T::Balance::zero(), + }); + }, } // TODO refactor event publishing with cu-23v2y3n Ok(()) @@ -565,8 +642,20 @@ pub mod pallet { total_issuance: updated_lp, }); }, + PoolConfiguration::LiquidityBootstrapping(liquidity_bootstrapping_pool_info) => { + let (base_amount, quote_amount) = + LiquidityBootstrapping::::remove_liquidity( + who, + pool_id, + liquidity_bootstrapping_pool_info, + pool_account, + lp_amount, + min_base_amount, + min_quote_amount, + )?; + Self::deposit_event(Event::PoolDeleted { pool_id, base_amount, quote_amount }); + }, } - // TODO refactor event publishing with cu-23v2y3n Ok(()) } @@ -666,6 +755,33 @@ pub mod pallet { Ok(base_amount) }, + PoolConfiguration::LiquidityBootstrapping(liquidity_bootstrapping_pool_info) => { + let current_block = frame_system::Pallet::::current_block_number(); + let (fees, base_amount) = LiquidityBootstrapping::::do_get_exchange( + liquidity_bootstrapping_pool_info, + &pool_account, + pair, + current_block, + quote_amount, + true, + )?; + + ensure!(base_amount >= min_receive, Error::::CannotRespectMinimumRequested); + + T::Assets::transfer(pair.quote, who, &pool_account, quote_amount, keep_alive)?; + // NOTE(hussein-aitlance): no need to keep alive the pool account + T::Assets::transfer(pair.base, &pool_account, who, base_amount, false)?; + Self::deposit_event(Event::::Swapped { + pool_id, + who: who.clone(), + base_asset: pair.base, + quote_asset: pair.quote, + base_amount, + quote_amount, + fee: fees, + }); + Ok(base_amount) + }, } // TODO refactor event publishing with cu-23v2y3n @@ -701,6 +817,15 @@ pub mod pallet { let quote_amount = Self::get_exchange_value(pool_id, asset_id, amount)?; Self::exchange(who, pool_id, pair, quote_amount, T::Balance::zero(), keep_alive) }, + PoolConfiguration::LiquidityBootstrapping(liquidity_bootstrapping_pool_info) => { + let pair = if asset_id == liquidity_bootstrapping_pool_info.pair.base { + liquidity_bootstrapping_pool_info.pair + } else { + liquidity_bootstrapping_pool_info.pair.swap() + }; + let quote_amount = Self::get_exchange_value(pool_id, asset_id, amount)?; + Self::exchange(who, pool_id, pair, quote_amount, T::Balance::zero(), keep_alive) + }, } } @@ -727,6 +852,14 @@ pub mod pallet { }; Self::exchange(who, pool_id, pair, amount, T::Balance::zero(), keep_alive) }, + PoolConfiguration::LiquidityBootstrapping(liquidity_bootstrapping_pool_info) => { + let pair = if asset_id == liquidity_bootstrapping_pool_info.pair.base { + liquidity_bootstrapping_pool_info.pair.swap() + } else { + liquidity_bootstrapping_pool_info.pair + }; + Self::exchange(who, pool_id, pair, amount, T::Balance::zero(), keep_alive) + }, } } } diff --git a/frame/pablo/src/liquidity_bootstrapping.rs b/frame/pablo/src/liquidity_bootstrapping.rs new file mode 100644 index 00000000000..028b3ed7899 --- /dev/null +++ b/frame/pablo/src/liquidity_bootstrapping.rs @@ -0,0 +1,209 @@ +use crate::{ + AccountIdOf, AssetIdOf, BalanceOf, Config, Error, LiquidityBootstrappingPoolInfoOf, + PoolConfiguration, PoolCount, Pools, +}; +use composable_maths::dex::constant_product::{compute_out_given_in, compute_spot_price}; +use composable_support::validation::{Validate, Validated}; +use composable_traits::{currency::LocalAssets, defi::CurrencyPair, dex::SaleState, math::SafeAdd}; +use frame_support::{ + pallet_prelude::*, + traits::fungibles::{Inspect, Transfer}, + transactional, +}; +use frame_system::pallet_prelude::BlockNumberFor; +use sp_runtime::traits::{BlockNumberProvider, Convert, One, Zero}; +use std::marker::PhantomData; + +#[derive(Copy, Clone, Encode, Decode, MaxEncodedLen, PartialEq, Eq, TypeInfo)] +pub struct PoolIsValid(PhantomData); + +impl Validate, PoolIsValid> for PoolIsValid { + fn validate( + input: LiquidityBootstrappingPoolInfoOf, + ) -> Result, &'static str> { + if input.pair.base == input.pair.quote { + return Err("Pair elements must be distinct.") + } + + if input.sale.end <= input.sale.start { + return Err("Sale end must be after start.") + } + + if input.sale.duration() < T::LbpMinSaleDuration::get() { + return Err("Sale duration must be greater than minimum duration.") + } + + if input.sale.duration() > T::LbpMaxSaleDuration::get() { + return Err("Sale duration must not exceed maximum duration.") + } + + if input.sale.initial_weight < input.sale.final_weight { + return Err("Initial weight must be greater than final weight.") + } + + if input.sale.initial_weight > T::LbpMaxInitialWeight::get() { + return Err("Initial weight must not exceed the defined maximum.") + } + + if input.sale.final_weight < T::LbpMinFinalWeight::get() { + return Err("Final weight must not be lower than the defined minimum.") + } + + Ok(input) + } +} + +pub(crate) struct LiquidityBootstrapping(PhantomData); + +impl LiquidityBootstrapping { + pub(crate) fn do_create_pool( + pool: Validated, PoolIsValid>, + ) -> Result { + let pool_id = + PoolCount::::try_mutate(|pool_count| -> Result { + let pool_id = *pool_count; + Pools::::insert( + pool_id, + PoolConfiguration::LiquidityBootstrapping(pool.clone().value()), + ); + *pool_count = pool_id.safe_add(&T::PoolId::one())?; + Ok(pool_id) + })?; + + Ok(pool_id) + } + + fn ensure_sale_state( + pool: &LiquidityBootstrappingPoolInfoOf, + current_block: BlockNumberFor, + expected_sale_state: SaleState, + ) -> Result<(), DispatchError> { + ensure!( + pool.sale.state(current_block) == expected_sale_state, + Error::::InvalidSaleState + ); + Ok(()) + } + + #[allow(dead_code)] + pub(crate) fn do_spot_price( + pool: LiquidityBootstrappingPoolInfoOf, + pool_account: AccountIdOf, + pair: CurrencyPair>, + current_block: BlockNumberFor, + ) -> Result, DispatchError> { + Self::ensure_sale_state(&pool, current_block, SaleState::Ongoing)?; + ensure!(pair == pool.pair, Error::::PairMismatch); + + let weights = pool.sale.current_weights(current_block)?; + + let (wo, wi) = if pair.base == pool.pair.base { weights } else { (weights.1, weights.0) }; + + let bi = T::Convert::convert(T::Assets::balance(pair.quote, &pool_account)); + let bo = T::Convert::convert(T::Assets::balance(pair.base, &pool_account)); + let base_unit = T::LocalAssets::unit::(pair.base)?; + + let spot_price = compute_spot_price(wi, wo, bi, bo, base_unit)?; + + Ok(T::Convert::convert(spot_price)) + } + + pub(crate) fn do_get_exchange( + pool: LiquidityBootstrappingPoolInfoOf, + pool_account: &AccountIdOf, + pair: CurrencyPair>, + current_block: BlockNumberFor, + quote_amount: BalanceOf, + apply_fees: bool, + ) -> Result<(BalanceOf, BalanceOf), DispatchError> { + Self::ensure_sale_state(&pool, current_block, SaleState::Ongoing)?; + + ensure!(pair == pool.pair, Error::::PairMismatch); + ensure!(!quote_amount.is_zero(), Error::::InvalidAmount); + + let weights = pool.sale.current_weights(current_block)?; + + let (wo, wi) = if pair.base == pool.pair.base { weights } else { (weights.1, weights.0) }; + + let ai = T::Convert::convert(quote_amount); + let (ai_minus_fees, fees) = if apply_fees { + let fees = pool.fee.mul_floor(ai); + // Safe as fees is a fraction of ai + (ai - fees, fees) + } else { + (ai, 0) + }; + let bi = T::Convert::convert(T::Assets::balance(pair.quote, &pool_account)); + let bo = T::Convert::convert(T::Assets::balance(pair.base, &pool_account)); + + let base_amount = compute_out_given_in(wi, wo, bi, bo, ai_minus_fees)?; + + Ok((T::Convert::convert(fees), T::Convert::convert(base_amount))) + } + + pub(crate) fn get_exchange_value( + pool: LiquidityBootstrappingPoolInfoOf, + pool_account: AccountIdOf, + asset_id: T::AssetId, + amount: T::Balance, + ) -> Result { + let pair = if asset_id == pool.pair.base { pool.pair.swap() } else { pool.pair }; + let current_block = frame_system::Pallet::::current_block_number(); + let (_, base_amount) = + Self::do_get_exchange(pool, &pool_account, pair, current_block, amount, false)?; + Ok(base_amount) + } + + #[transactional] + pub(crate) fn add_liquidity( + who: &T::AccountId, + pool: LiquidityBootstrappingPoolInfoOf, + pool_account: AccountIdOf, + base_amount: T::Balance, + quote_amount: T::Balance, + _: T::Balance, + keep_alive: bool, + ) -> Result<(), DispatchError> { + let current_block = frame_system::Pallet::::current_block_number(); + Self::ensure_sale_state(&pool, current_block, SaleState::NotStarted)?; + + ensure!(pool.owner == *who, Error::::MustBeOwner); + ensure!(!base_amount.is_zero() && !quote_amount.is_zero(), Error::::InvalidAmount); + + // NOTE(hussein-aitlahcen): as we only allow the owner to provide liquidity, we don't + // mint any LP. + T::Assets::transfer(pool.pair.base, who, &pool_account, base_amount, keep_alive)?; + T::Assets::transfer(pool.pair.quote, who, &pool_account, quote_amount, keep_alive)?; + + Ok(()) + } + + #[transactional] + pub(crate) fn remove_liquidity( + who: &T::AccountId, + pool_id: T::PoolId, + pool: LiquidityBootstrappingPoolInfoOf, + pool_account: AccountIdOf, + _: T::Balance, + _: T::Balance, + _: T::Balance, + ) -> Result<(BalanceOf, BalanceOf), DispatchError> { + let current_block = frame_system::Pallet::::current_block_number(); + Self::ensure_sale_state(&pool, current_block, SaleState::Ended)?; + + ensure!(pool.owner == *who, Error::::MustBeOwner); + + let repatriate = |a| -> Result, DispatchError> { + let a_balance = T::Assets::balance(a, &pool_account); + // NOTE(hussein-aitlahcen): not need to keep the pool account alive. + T::Assets::transfer(a, &pool_account, who, a_balance, false)?; + Ok(a_balance) + }; + + let base_amount = repatriate(pool.pair.base)?; + let quote_amount = repatriate(pool.pair.quote)?; + + Pools::::remove(pool_id); + Ok((base_amount, quote_amount)) + } +} diff --git a/frame/pablo/src/liquidity_bootstrapping_tests.rs b/frame/pablo/src/liquidity_bootstrapping_tests.rs new file mode 100644 index 00000000000..9a11b796e74 --- /dev/null +++ b/frame/pablo/src/liquidity_bootstrapping_tests.rs @@ -0,0 +1,673 @@ +use crate::{ + liquidity_bootstrapping::PoolIsValid, + mock::{Pablo, *}, + Error, PoolInitConfiguration, +}; +use composable_support::validation::Validated; +use composable_tests_helpers::test::helper::default_acceptable_computation_error; +use composable_traits::{ + defi::CurrencyPair, + dex::{LiquidityBootstrappingPoolInfo, Sale}, +}; +use frame_support::{ + assert_noop, assert_ok, + traits::fungibles::{Inspect, Mutate}, +}; +use sp_runtime::Permill; + +fn valid_pool( +) -> Validated, PoolIsValid> { + let pair = CurrencyPair::new(PROJECT_TOKEN, USDT); + let owner = ALICE; + let duration = MaxSaleDuration::get(); + let start = 0; + let end = start + duration; + let initial_weight = MaxInitialWeight::get(); + let final_weight = MinFinalWeight::get(); + let fee = Permill::from_perthousand(1); + Validated::new(LiquidityBootstrappingPoolInfo { + owner, + pair, + sale: Sale { start, end, initial_weight, final_weight }, + fee, + }) + .expect("impossible; qed;") +} + +fn with_pool( + owner: AccountId, + sale_duration: BlockNumber, + initial_weight: Permill, + final_weight: Permill, + fee: Permill, + f: impl FnOnce( + PoolId, + &LiquidityBootstrappingPoolInfo, + &dyn Fn(BlockNumber), + &dyn Fn(), + ) -> T, +) -> T { + let random_start = 0xDEADC0DE; + let pair = CurrencyPair::new(PROJECT_TOKEN, USDT); + let end = random_start + sale_duration; + let pool = Validated::<_, PoolIsValid>::new(LiquidityBootstrappingPoolInfo { + owner, + pair, + sale: Sale { start: random_start, end, initial_weight, final_weight }, + fee, + }) + .expect("impossible; qed;"); + new_test_ext().execute_with(|| -> T { + // Actually create the pool. + assert_ok!(Pablo::create( + Origin::signed(ALICE), + PoolInitConfiguration::LiquidityBootstrapping(pool.value()) + )); + + // Will always start to 0. + let pool_id = 0; + + // Relative to sale start. + let set_block = |x: BlockNumber| { + System::set_block_number(random_start + x); + }; + + // Forward to sale end. + let end_sale = || { + set_block(sale_duration + 1); + }; + + f(pool_id, &pool, &set_block, &end_sale) + }) +} + +fn within_sale_with_liquidity( + owner: AccountId, + sale_duration: BlockNumber, + initial_weight: Permill, + final_weight: Permill, + fee: Permill, + initial_project_tokens: Balance, + initial_usdt: Balance, + f: impl FnOnce( + PoolId, + &LiquidityBootstrappingPoolInfo, + &dyn Fn(BlockNumber), + &dyn Fn(), + ) -> T, +) -> T { + with_pool( + owner, + sale_duration, + initial_weight, + final_weight, + fee, + |pool_id, pool, set_block, end_sale| -> T { + assert_ok!(Tokens::mint_into(PROJECT_TOKEN, &owner, initial_project_tokens)); + assert_ok!(Tokens::mint_into(USDT, &owner, initial_usdt)); + + // Add initial liquidity. + assert_ok!(Pablo::add_liquidity( + Origin::signed(owner), + pool_id, + initial_project_tokens, + initial_usdt, + 0_u128, + false + )); + + // Actually start the sale. + set_block(0); + + f(pool_id, pool, set_block, end_sale) + }, + ) +} + +mod create { + use super::*; + + #[test] + fn arbitrary_user_can_create() { + new_test_ext().execute_with(|| { + assert_ok!(Pablo::create( + Origin::signed(ALICE), + PoolInitConfiguration::LiquidityBootstrapping(valid_pool().value()) + ),); + }); + } + + // TODO enable with cu-23v3155 + // #[test] + // fn admin_can_create() { + // new_test_ext().execute_with(|| { + // assert_ok!(Pablo::create(Origin::root(), + // PoolInitConfiguration::LiquidityBootstrapping(valid_pool().value()))); }); + // } +} + +mod sell { + use super::*; + + #[test] + fn can_sell_one_to_one() { + /* 50% weight = constant product, no fees. + */ + let unit = 1_000_000_000_000; + let initial_project_tokens = 1_000_000 * unit; + let initial_usdt = 1_000_000 * unit; + let sale_duration = MaxSaleDuration::get(); + let initial_weight = Permill::one() / 2; + let final_weight = Permill::one() / 2; + let fee = Permill::zero(); + within_sale_with_liquidity( + ALICE, + sale_duration, + initial_weight, + final_weight, + fee, + initial_project_tokens, + initial_usdt, + |pool_id, pool, _, _| { + // Buy project token + assert_ok!(Tokens::mint_into(USDT, &BOB, unit)); + assert_ok!(Pablo::sell(Origin::signed(BOB), pool_id, pool.pair.quote, unit, false)); + assert_ok!(default_acceptable_computation_error( + Tokens::balance(PROJECT_TOKEN, &BOB), + unit + )); + }, + ) + } +} + +mod buy { + use super::*; + + #[test] + fn can_buy_one_to_one() { + /* 50% weight = constant product, no fees. + */ + let unit = 1_000_000_000_000; + let initial_project_tokens = 1_000_000 * unit; + let initial_usdt = 1_000_000 * unit; + let sale_duration = MaxSaleDuration::get(); + let initial_weight = Permill::one() / 2; + let final_weight = Permill::one() / 2; + let fee = Permill::zero(); + within_sale_with_liquidity( + ALICE, + sale_duration, + initial_weight, + final_weight, + fee, + initial_project_tokens, + initial_usdt, + |pool_id, pool, _, _| { + // Buy project token + assert_ok!(Tokens::mint_into(USDT, &BOB, unit)); + assert_ok!(Pablo::buy(Origin::signed(BOB), pool_id, pool.pair.base, unit, false)); + assert_ok!(default_acceptable_computation_error( + Tokens::balance(PROJECT_TOKEN, &BOB), + unit + )); + }, + ) + } +} + +mod remove_liquidity { + use super::*; + + #[test] + fn cannot_remove_before_sale_end() { + let owner = ALICE; + let sale_duration = MaxSaleDuration::get(); + let initial_weight = Permill::one() / 2; + let final_weight = Permill::one() / 2; + let fee = Permill::zero(); + let unit = 1_000_000_000_000; + let initial_project_tokens = 1_000_000 * unit; + let initial_usdt = 1_000_000 * unit; + with_pool(owner, sale_duration, initial_weight, final_weight, fee, |pool_id, _, _, _| { + assert_ok!(Tokens::mint_into(PROJECT_TOKEN, &owner, initial_project_tokens)); + assert_ok!(Tokens::mint_into(USDT, &owner, initial_usdt)); + assert_noop!( + Pablo::remove_liquidity(Origin::signed(owner), pool_id, 0, 0, 0), + Error::::InvalidSaleState + ); + }); + } + + #[test] + fn can_remove_after_sale_end() { + let owner = ALICE; + let sale_duration = MaxSaleDuration::get(); + let initial_weight = Permill::one() / 2; + let final_weight = Permill::one() / 2; + let fee = Permill::zero(); + let unit = 1_000_000_000_000; + let initial_project_tokens = 1_000_000 * unit; + let initial_usdt = 1_000_000 * unit; + with_pool( + owner, + sale_duration, + initial_weight, + final_weight, + fee, + |pool_id, _, _, end_sale| { + assert_ok!(Tokens::mint_into(PROJECT_TOKEN, &owner, initial_project_tokens)); + assert_ok!(Tokens::mint_into(USDT, &owner, initial_usdt)); + end_sale(); + assert_ok!(Pablo::remove_liquidity(Origin::signed(owner), pool_id, 0, 0, 0)); + assert_eq!(Tokens::balance(PROJECT_TOKEN, &owner), initial_project_tokens); + assert_eq!(Tokens::balance(USDT, &owner), initial_usdt); + }, + ); + } +} + +mod add_liquidity { + use super::*; + + #[test] + fn can_add_liquidity_before_sale() { + let owner = ALICE; + let sale_duration = MaxSaleDuration::get(); + let initial_weight = Permill::one() / 2; + let final_weight = Permill::one() / 2; + let fee = Permill::zero(); + let unit = 1_000_000_000_000; + let initial_project_tokens = 1_000_000 * unit; + let initial_usdt = 1_000_000 * unit; + with_pool(owner, sale_duration, initial_weight, final_weight, fee, |pool_id, _, _, _| { + assert_ok!(Tokens::mint_into(PROJECT_TOKEN, &owner, initial_project_tokens)); + assert_ok!(Tokens::mint_into(USDT, &owner, initial_usdt)); + assert_ok!(Pablo::add_liquidity( + Origin::signed(owner), + pool_id, + initial_project_tokens, + initial_usdt, + 0_128, + false + )); + }); + } + + #[test] + fn cannot_add_liquidity_after_sale_started() { + let owner = ALICE; + let sale_duration = MaxSaleDuration::get(); + let initial_weight = Permill::one() / 2; + let final_weight = Permill::one() / 2; + let fee = Permill::zero(); + let unit = 1_000_000_000_000; + let initial_project_tokens = 1_000_000 * unit; + let initial_usdt = 1_000_000 * unit; + with_pool( + owner, + sale_duration, + initial_weight, + final_weight, + fee, + |pool_id, _, set_sale_block, _| { + assert_ok!(Tokens::mint_into(PROJECT_TOKEN, &owner, initial_project_tokens)); + assert_ok!(Tokens::mint_into(USDT, &owner, initial_usdt)); + set_sale_block(0); + assert_noop!( + Pablo::add_liquidity( + Origin::signed(owner), + pool_id, + initial_project_tokens, + initial_usdt, + 0_128, + false + ), + Error::::InvalidSaleState + ); + }, + ); + } +} + +mod invalid_pool { + use super::*; + + #[test] + fn final_weight_below_minimum() { + new_test_ext().execute_with(|| { + let pair = CurrencyPair::new(PROJECT_TOKEN, USDT); + let owner = ALICE; + let duration = MaxSaleDuration::get() - 1; + let start = 0; + let end = start + duration; + let initial_weight = MaxInitialWeight::get(); + let final_weight = MinFinalWeight::get() - Permill::from_parts(1); + let fee = Permill::from_perthousand(1); + assert!(Validated::<_, PoolIsValid>::new(LiquidityBootstrappingPoolInfo { + owner, + pair, + sale: Sale { start, end, initial_weight, final_weight }, + fee, + }) + .is_err()); + }); + } + + #[test] + fn initial_weight_above_maximum() { + new_test_ext().execute_with(|| { + let pair = CurrencyPair::new(PROJECT_TOKEN, USDT); + let owner = ALICE; + let duration = MaxSaleDuration::get() - 1; + let start = 0; + let end = start + duration; + let initial_weight = MaxInitialWeight::get() + Permill::from_parts(1); + let final_weight = MinFinalWeight::get(); + let fee = Permill::from_perthousand(1); + assert!(Validated::<_, PoolIsValid>::new(LiquidityBootstrappingPoolInfo { + owner, + pair, + sale: Sale { start, end, initial_weight, final_weight }, + fee, + }) + .is_err()); + }); + } + + #[test] + fn final_weight_above_initial_weight() { + new_test_ext().execute_with(|| { + let pair = CurrencyPair::new(PROJECT_TOKEN, USDT); + let owner = ALICE; + let duration = MaxSaleDuration::get() - 1; + let start = 0; + let end = start + duration; + let initial_weight = MinFinalWeight::get(); + let final_weight = MaxInitialWeight::get(); + let fee = Permill::from_perthousand(1); + assert!(Validated::<_, PoolIsValid>::new(LiquidityBootstrappingPoolInfo { + owner, + pair, + sale: Sale { start, end, initial_weight, final_weight }, + fee, + }) + .is_err()); + }); + } + + #[test] + fn end_before_start() { + new_test_ext().execute_with(|| { + let pair = CurrencyPair::new(PROJECT_TOKEN, USDT); + let owner = ALICE; + let start = 1; + let end = 0; + let initial_weight = MaxInitialWeight::get(); + let final_weight = MinFinalWeight::get(); + let fee = Permill::from_perthousand(1); + assert!(Validated::<_, PoolIsValid>::new(LiquidityBootstrappingPoolInfo { + owner, + pair, + sale: Sale { start, end, initial_weight, final_weight }, + fee, + }) + .is_err()); + }); + } + + #[test] + fn above_maximum_sale_duration() { + new_test_ext().execute_with(|| { + let pair = CurrencyPair::new(PROJECT_TOKEN, USDT); + let owner = ALICE; + let duration = MaxSaleDuration::get() + 1; + let start = 0; + let end = start + duration; + let initial_weight = MaxInitialWeight::get(); + let final_weight = MinFinalWeight::get(); + let fee = Permill::from_perthousand(1); + assert!(Validated::<_, PoolIsValid>::new(LiquidityBootstrappingPoolInfo { + owner, + pair, + sale: Sale { start, end, initial_weight, final_weight }, + fee, + }) + .is_err()); + }); + } + + #[test] + fn below_minimum_sale_duration() { + new_test_ext().execute_with(|| { + let pair = CurrencyPair::new(PROJECT_TOKEN, USDT); + let owner = ALICE; + let duration = MinSaleDuration::get() - 1; + let start = 0; + let end = start + duration; + let initial_weight = MaxInitialWeight::get(); + let final_weight = MinFinalWeight::get(); + let fee = Permill::from_perthousand(1); + assert!(Validated::<_, PoolIsValid>::new(LiquidityBootstrappingPoolInfo { + owner, + pair, + sale: Sale { start, end, initial_weight, final_weight }, + fee, + }) + .is_err()); + }); + } +} + +#[cfg(feature = "visualization")] +mod visualization { + use super::*; + use crate::liquidity_bootstrapping::LiquidityBootstrapping; + + #[test] + fn plot() { + new_test_ext().execute_with(|| { + let pair = CurrencyPair::new(PROJECT_TOKEN, USDT); + let owner = ALICE; + let two_days = 48 * 3600 / 12; + let window = 100; + let pool = LiquidityBootstrappingPoolInfo { + owner, + pair, + sale: Sale { + start: window, + end: two_days + window, + initial_weight: Permill::from_percent(92), + final_weight: Permill::from_percent(50), + }, + fee: Permill::from_perthousand(1), + }; + let pool_id = + Pablo::do_create_pool(&owner, PoolInitConfiguration::LiquidityBootstrapping(pool)) + .expect("impossible; qed;"); + + let unit = 1_000_000_000_000; + let initial_project_tokens = 100_000_000 * unit; + let initial_usdt = 1_000_000 * unit; + + assert_ok!(Tokens::mint_into(PROJECT_TOKEN, &ALICE, initial_project_tokens)); + assert_ok!(Tokens::mint_into(USDT, &ALICE, initial_usdt)); + assert_ok!(Pablo::add_liquidity( + Origin::signed(ALICE), + pool_id, + initial_project_tokens, + initial_usdt, + 0, + false + )); + let pool_account = Pablo::account_id(&pool_id); + + { + let points = (pool.sale.start..pool.sale.end) + .map(|block| { + ( + block, + LiquidityBootstrapping::::do_spot_price( + pool, + pool_account, + pool.pair, + block, + ) + .expect("impossible; qed;") as f64 / + unit as f64, + ) + }) + .collect::>(); + let max_amount = points.iter().copied().fold(f64::NAN, |x, (_, y)| f64::max(x, y)); + + use plotters::prelude::*; + let area = BitMapBackend::new("./plots/lbp/lbp_spot_price.png", (1024, 768)) + .into_drawing_area(); + area.fill(&WHITE).unwrap(); + + let mut chart = ChartBuilder::on(&area) + .caption("Spot price", ("Arial", 50).into_font()) + .margin(100u32) + .x_label_area_size(30u32) + .y_label_area_size(30u32) + .build_cartesian_2d(pool.sale.start..pool.sale.end, 0f64..max_amount) + .unwrap(); + + chart.configure_mesh().draw().unwrap(); + chart + .draw_series(LineSeries::new(points, &RED)) + .unwrap() + .label("base") + .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], &RED)); + chart + .configure_series_labels() + .background_style(&WHITE.mix(0.8)) + .border_style(&BLACK) + .draw() + .unwrap(); + } + + { + use plotters::prelude::*; + + let plot_swap = |pair, swap_amount, name, caption| { + let points = (pool.sale.start..pool.sale.end) + .map(|block| { + let (fees, base_amount) = + LiquidityBootstrapping::::do_get_exchange( + pool, + &pool_account, + pair, + block, + swap_amount, + true, + ) + .expect("impossible; qed;"); + (block, fees / unit, base_amount / unit) + }) + .collect::>(); + let amounts = + points.clone().iter().copied().map(|(x, _, y)| (x, y)).collect::>(); + let amounts_with_fees = + points.into_iter().map(|(x, y, z)| (x, y + z)).collect::>(); + + let max_amount = + amounts_with_fees.iter().copied().map(|(_, x)| x).max().unwrap(); + + let area = BitMapBackend::new(name, (1024, 768)).into_drawing_area(); + area.fill(&WHITE).unwrap(); + + let mut chart = ChartBuilder::on(&area) + .caption(caption, ("Arial", 50).into_font()) + .margin(100u32) + .x_label_area_size(30u32) + .y_label_area_size(30u32) + .build_cartesian_2d(pool.sale.start..pool.sale.end, 0..max_amount) + .unwrap(); + + chart.configure_mesh().draw().unwrap(); + chart + .draw_series(LineSeries::new(amounts, &BLUE)) + .unwrap() + .label("Received tokens fees applied") + .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], &BLUE)); + chart + .draw_series(LineSeries::new(amounts_with_fees, &RED)) + .unwrap() + .label("Received tokens fees not applied") + .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], &RED)); + chart + .configure_series_labels() + .background_style(&WHITE.mix(0.8)) + .border_style(&BLACK) + .draw() + .unwrap(); + }; + + let buy_amount = 500; + plot_swap( + pair, + buy_amount * unit, + "./plots/lbp/lbp_buy_project.png", + format!("Buy project tokens with {} USDT", buy_amount), + ); + let sell_amount = 100_000; + plot_swap( + pair.swap(), + sell_amount * unit, + "./plots/lbp/lbp_sell_project.png", + format!("Sell {} project tokens", sell_amount), + ); + } + + { + use plotters::prelude::*; + let area = BitMapBackend::new("./plots/lbp/lbp_weights.png", (1024, 768)) + .into_drawing_area(); + area.fill(&WHITE).unwrap(); + + let mut chart = ChartBuilder::on(&area) + .caption("y = weight", ("Arial", 50).into_font()) + .margin(100u32) + .x_label_area_size(30u32) + .y_label_area_size(30u32) + .build_cartesian_2d( + pool.sale.start..pool.sale.end, + 0..Permill::one().deconstruct(), + ) + .unwrap(); + + let points = (pool.sale.start..pool.sale.end).map(|block| { + (block, pool.sale.current_weights(block).expect("impossible; qed;")) + }); + + chart.configure_mesh().draw().unwrap(); + chart + .draw_series(LineSeries::new( + points + .clone() + .map(|(block, (base_weight, _))| (block, base_weight.deconstruct())), + &RED, + )) + .unwrap() + .label("base") + .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], &RED)); + chart + .draw_series(LineSeries::new( + points + .map(|(block, (_, quote_weight))| (block, quote_weight.deconstruct())), + &BLUE, + )) + .unwrap() + .label("quote") + .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], &BLUE)); + chart + .configure_series_labels() + .background_style(&WHITE.mix(0.8)) + .border_style(&BLACK) + .draw() + .unwrap(); + } + }); + } +} diff --git a/frame/pablo/src/mock.rs b/frame/pablo/src/mock.rs index b40edbc52c8..f3a6e2012ca 100644 --- a/frame/pablo/src/mock.rs +++ b/frame/pablo/src/mock.rs @@ -8,14 +8,17 @@ use sp_core::H256; use sp_runtime::{ testing::Header, traits::{BlakeTwo256, ConvertInto, IdentityLookup}, + Permill, }; use system::EnsureRoot; pub type CurrencyId = u128; +pub type BlockNumber = u64; pub const BTC: AssetId = 0; pub const USDT: CurrencyId = 2; pub const USDC: CurrencyId = 4; +pub const PROJECT_TOKEN: AssetId = 1; type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; type Block = frame_system::mocking::MockBlock; @@ -66,7 +69,7 @@ impl system::Config for Test { type Origin = Origin; type Call = Call; type Index = u64; - type BlockNumber = u64; + type BlockNumber = BlockNumber; type Hash = H256; type Hashing = BlakeTwo256; type AccountId = AccountId; @@ -111,6 +114,10 @@ impl orml_tokens::Config for Test { parameter_types! { pub Precision: u128 = 100_u128; pub TestPalletID : PalletId = PalletId(*b"pablo_pa"); + pub MinSaleDuration: BlockNumber = 3600 / 12; + pub MaxSaleDuration: BlockNumber = 30 * 24 * 3600 / 12; + pub MaxInitialWeight: Permill = Permill::from_percent(95); + pub MinFinalWeight: Permill = Permill::from_percent(5); } impl pablo::Config for Test { @@ -122,6 +129,11 @@ impl pablo::Config for Test { type Convert = ConvertInto; type PoolId = PoolId; type PalletId = TestPalletID; + type LocalAssets = LpTokenFactory; + type LbpMinSaleDuration = MinSaleDuration; + type LbpMaxSaleDuration = MaxSaleDuration; + type LbpMaxInitialWeight = MaxInitialWeight; + type LbpMinFinalWeight = MinFinalWeight; } // Build genesis storage according to the mock runtime. diff --git a/frame/pablo/src/uniswap.rs b/frame/pablo/src/uniswap.rs index 8032620c2e4..bf8aa936bc5 100644 --- a/frame/pablo/src/uniswap.rs +++ b/frame/pablo/src/uniswap.rs @@ -1,4 +1,4 @@ -use crate::{Config, Error, Event, Pallet, PoolConfiguration, PoolCount, Pools}; +use crate::{Config, Error, PoolConfiguration, PoolCount, Pools}; use composable_maths::dex::constant_product::{ compute_deposit_lp, compute_in_given_out, compute_out_given_in, }; @@ -55,8 +55,6 @@ impl Uniswap { Ok(pool_id) })?; - >::deposit_event(Event::PoolCreated { pool_id, owner: who.clone() }); - Ok(pool_id) }