diff --git a/Cargo.lock b/Cargo.lock index df49ca52b8..7de7d938f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -190,7 +190,7 @@ dependencies = [ [[package]] name = "altair-runtime" -version = "0.10.33" +version = "0.10.34" dependencies = [ "axelar-gateway-precompile", "cfg-primitives", @@ -245,6 +245,7 @@ dependencies = [ "pallet-evm-chain-id", "pallet-evm-precompile-dispatch", "pallet-fees", + "pallet-foreign-investments", "pallet-identity", "pallet-interest-accrual", "pallet-investments", @@ -257,6 +258,7 @@ dependencies = [ "pallet-migration-manager", "pallet-multisig", "pallet-nft-sales", + "pallet-order-book", "pallet-permissions", "pallet-pool-registry", "pallet-pool-system", @@ -1195,6 +1197,7 @@ dependencies = [ "pallet-evm-chain-id", "pallet-evm-precompile-dispatch", "pallet-fees", + "pallet-foreign-investments", "pallet-identity", "pallet-interest-accrual", "pallet-investments", @@ -1207,6 +1210,7 @@ dependencies = [ "pallet-migration-manager", "pallet-multisig", "pallet-nft", + "pallet-order-book", "pallet-permissions", "pallet-pool-registry", "pallet-pool-system", @@ -2684,7 +2688,7 @@ dependencies = [ [[package]] name = "development-runtime" -version = "0.10.27" +version = "0.10.29" dependencies = [ "axelar-gateway-precompile", "cfg-primitives", @@ -2743,6 +2747,7 @@ dependencies = [ "pallet-evm-chain-id", "pallet-evm-precompile-dispatch", "pallet-fees", + "pallet-foreign-investments", "pallet-identity", "pallet-interest-accrual", "pallet-investments", @@ -7510,6 +7515,25 @@ dependencies = [ "sp-std", ] +[[package]] +name = "pallet-foreign-investments" +version = "1.0.0" +dependencies = [ + "cfg-primitives", + "cfg-traits", + "cfg-types", + "frame-benchmarking", + "frame-support", + "frame-system", + "log", + "parity-scale-codec 3.6.4", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-grandpa" version = "4.0.0-dev" @@ -11071,10 +11095,12 @@ dependencies = [ "pallet-ethereum-transaction", "pallet-evm", "pallet-evm-chain-id", + "pallet-foreign-investments", "pallet-investments", "pallet-liquidity-pools", "pallet-liquidity-pools-gateway", "pallet-loans", + "pallet-order-book", "pallet-permissions", "pallet-pool-registry", "pallet-pool-system", diff --git a/Cargo.toml b/Cargo.toml index 64edcefeff..1d03ea1f74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ members = [ "pallets/crowdloan-reward", "pallets/ethereum-transaction", "pallets/fees", + "pallets/foreign-investments", "pallets/interest-accrual", "pallets/investments", "pallets/keystore", diff --git a/libs/test-utils/src/mocks/accountant.rs b/libs/test-utils/src/mocks/accountant.rs index ff759426ce..6fe086b481 100644 --- a/libs/test-utils/src/mocks/accountant.rs +++ b/libs/test-utils/src/mocks/accountant.rs @@ -105,7 +105,7 @@ macro_rules! impl_mock_accountant { pub payment_currency: $currency_id, } - impl cfg_traits::InvestmentAccountant<$account_id> for $name + impl cfg_traits::investments::InvestmentAccountant<$account_id> for $name where Tokens: frame_support::traits::tokens::fungibles::Mutate<$account_id> + frame_support::traits::tokens::fungibles::Transfer<$account_id> @@ -166,7 +166,7 @@ macro_rules! impl_mock_accountant { } } - impl cfg_traits::InvestmentProperties<$account_id> for InvestmentInfo { + impl cfg_traits::investments::InvestmentProperties<$account_id> for InvestmentInfo { type Currency = $currency_id; type Id = $investment_id; diff --git a/libs/test-utils/src/mocks/order_manager.rs b/libs/test-utils/src/mocks/order_manager.rs index cecd3c38e5..7d8c41c0c1 100644 --- a/libs/test-utils/src/mocks/order_manager.rs +++ b/libs/test-utils/src/mocks/order_manager.rs @@ -14,7 +14,7 @@ pub use pallet::*; #[frame_support::pallet] pub mod pallet { - use cfg_traits::{ + use cfg_traits::investments::{ Investment, InvestmentAccountant, InvestmentProperties, OrderManager, TrancheCurrency, }; use cfg_types::orders::{FulfillmentWithPrice, TotalOrder}; @@ -277,6 +277,20 @@ pub mod pallet { .unwrap_or_default() .amount) } + + fn investment_requires_collect( + _investor: &T::AccountId, + _investment_id: Self::InvestmentId, + ) -> bool { + unimplemented!("not needed here, could also default to false") + } + + fn redemption_requires_collect( + _investor: &T::AccountId, + _investment_id: Self::InvestmentId, + ) -> bool { + unimplemented!("not needed here, could also default to false") + } } impl OrderManager for Pallet diff --git a/libs/traits/src/investments.rs b/libs/traits/src/investments.rs new file mode 100644 index 0000000000..f43091e5fb --- /dev/null +++ b/libs/traits/src/investments.rs @@ -0,0 +1,417 @@ +// Copyright 2021 Centrifuge GmbH (centrifuge.io). +// This file is part of Centrifuge chain project. + +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). + +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +use sp_std::fmt::Debug; + +/// A trait for converting from a PoolId and a TranchId +/// into a given Self::Currency +pub trait TrancheCurrency { + fn generate(pool_id: PoolId, tranche_id: TrancheId) -> Self; + + fn of_pool(&self) -> PoolId; + + fn of_tranche(&self) -> TrancheId; +} + +/// A trait, when implemented allows to invest into +/// investment classes +pub trait Investment { + type Amount; + type CurrencyId; + type Error: Debug; + type InvestmentId; + + /// Updates the current investment amount of who into the + /// investment class to amount. + /// Meaning: if amount < previous investment, then investment + /// will be reduced, and increases in the opposite case. + fn update_investment( + who: &AccountId, + investment_id: Self::InvestmentId, + amount: Self::Amount, + ) -> Result<(), Self::Error>; + + /// Checks whether a currency can be used for buying the given investment. + fn accepted_payment_currency( + investment_id: Self::InvestmentId, + currency: Self::CurrencyId, + ) -> bool; + + /// Returns, if possible, the currently unprocessed investment amount (in + /// pool currency) of who into the given investment class. + /// + /// NOTE: If the investment was (partially) processed, the unprocessed + /// amount is only updated upon collecting. + fn investment( + who: &AccountId, + investment_id: Self::InvestmentId, + ) -> Result; + + /// Updates the current redemption amount (in tranche tokens) of who into + /// the investment class to amount. + /// Meaning: if amount < previous redemption, then the redemption + /// will be reduced, and increased in the opposite case. + /// + /// NOTE: Redemptions are bound by the processed investment amount. + fn update_redemption( + who: &AccountId, + investment_id: Self::InvestmentId, + amount: Self::Amount, + ) -> Result<(), Self::Error>; + + /// Checks whether a currency is accepted as a payout for the given + /// investment. + fn accepted_payout_currency( + investment_id: Self::InvestmentId, + currency: Self::CurrencyId, + ) -> bool; + + /// Returns, if possible, the currently unprocessed redemption amount (in + /// tranche tokens) of who into the given investment class. + /// + /// NOTE: If the redemption was (partially) processed, the unprocessed + /// amount is only updated upon collecting. + fn redemption( + who: &AccountId, + investment_id: Self::InvestmentId, + ) -> Result; + + /// Checks whether an investment requires to be collected before it can be + /// updated. + /// + /// NOTE: Defaults to false if the investment does not exist. + fn investment_requires_collect(investor: &AccountId, investment_id: Self::InvestmentId) + -> bool; + + /// Checks whether a redemption requires to be collected before it can be + /// further updated. + /// + /// NOTE: Defaults to false if the redemption does not exist. + fn redemption_requires_collect(investor: &AccountId, investment_id: Self::InvestmentId) + -> bool; +} + +/// A trait which allows to collect existing investments and redemptions. +pub trait InvestmentCollector { + type Error: Debug; + type InvestmentId; + type Result: Debug; + + /// Collect the results of a user's invest orders for the given + /// investment. If any amounts are not fulfilled they are directly + /// appended to the next active order for this investment. + fn collect_investment( + who: AccountId, + investment_id: Self::InvestmentId, + ) -> Result; + + /// Collect the results of a users redeem orders for the given + /// investment. If any amounts are not fulfilled they are directly + /// appended to the next active order for this investment. + fn collect_redemption( + who: AccountId, + investment_id: Self::InvestmentId, + ) -> Result; +} + +/// A trait, when implemented must take care of +/// collecting orders (invest & redeem) for a given investment class. +/// When being asked it must return the current orders and +/// when being singled about a fulfillment, it must act accordingly. +pub trait OrderManager { + type Error; + type InvestmentId; + type Orders; + type Fulfillment; + + /// When called the manager return the current + /// invest orders for the given investment class. + fn invest_orders(asset_id: Self::InvestmentId) -> Self::Orders; + + /// When called the manager return the current + /// redeem orders for the given investment class. + fn redeem_orders(asset_id: Self::InvestmentId) -> Self::Orders; + + /// When called the manager return the current + /// invest orders for the given investment class. + /// Callers of this method can expect that the returned + /// orders equal the returned orders from `invest_orders`. + /// + /// **NOTE:** Once this is called, the OrderManager is expected + /// to start a new round of orders and return an error if this + /// method is to be called again before `invest_fulfillment` is + /// called. + fn process_invest_orders(asset_id: Self::InvestmentId) -> Result; + + /// When called the manager return the current + /// invest orders for the given investment class. + /// Callers of this method can expect that the returned + /// orders equal the returned orders from `redeem_orders`. + /// + /// **NOTE:** Once this is called, the OrderManager is expected + /// to start a new round of orders and return an error if this + /// method is to be called again before `redeem_fulfillment` is + /// called. + fn process_redeem_orders(asset_id: Self::InvestmentId) -> Result; + + /// Signals the manager that the previously + /// fetch invest orders for a given investment class + /// will be fulfilled by fulfillment. + fn invest_fulfillment( + asset_id: Self::InvestmentId, + fulfillment: Self::Fulfillment, + ) -> Result<(), Self::Error>; + + /// Signals the manager that the previously + /// fetch redeem orders for a given investment class + /// will be fulfilled by fulfillment. + fn redeem_fulfillment( + asset_id: Self::InvestmentId, + fulfillment: Self::Fulfillment, + ) -> Result<(), Self::Error>; +} + +/// A trait who's implementer provides means of accounting +/// for investments of a generic kind. +pub trait InvestmentAccountant { + type Error; + type InvestmentId; + type InvestmentInfo: InvestmentProperties; + type Amount; + + /// Information about an asset. Must allow to derive + /// owner, payment and denomination currency + fn info(id: Self::InvestmentId) -> Result; + + /// Return the balance of a given user for the given investmnet + fn balance(id: Self::InvestmentId, who: &AccountId) -> Self::Amount; + + /// Transfer a given investment from source, to destination + fn transfer( + id: Self::InvestmentId, + source: &AccountId, + dest: &AccountId, + amount: Self::Amount, + ) -> Result<(), Self::Error>; + + /// Increases the existence of + fn deposit( + buyer: &AccountId, + id: Self::InvestmentId, + amount: Self::Amount, + ) -> Result<(), Self::Error>; + + /// Reduce the existence of an asset + fn withdraw( + seller: &AccountId, + id: Self::InvestmentId, + amount: Self::Amount, + ) -> Result<(), Self::Error>; +} + +/// A trait that allows to retrieve information +/// about an investment class. +pub trait InvestmentProperties { + /// The overarching Currency that payments + /// for this class are made in + type Currency; + /// Who the investment class can be identified + type Id; + + /// Returns the owner of the investment class + fn owner(&self) -> AccountId; + + /// Returns the id of the investment class + fn id(&self) -> Self::Id; + + /// Returns the currency in which the investment class + /// can be bought. + fn payment_currency(&self) -> Self::Currency; + + /// Returns the account a payment for the investment class + /// must be made to. + /// + /// Defaults to owner. + fn payment_account(&self) -> AccountId { + self.owner() + } +} + +impl> InvestmentProperties for &T { + type Currency = T::Currency; + type Id = T::Id; + + fn owner(&self) -> AccountId { + (*self).owner() + } + + fn id(&self) -> Self::Id { + (*self).id() + } + + fn payment_currency(&self) -> Self::Currency { + (*self).payment_currency() + } + + fn payment_account(&self) -> AccountId { + (*self).payment_account() + } +} + +/// Trait to handle Investment Portfolios for accounts +pub trait InvestmentsPortfolio { + type InvestmentId; + type CurrencyId; + type Balance; + type Error; + type AccountInvestmentPortfolio; + + /// Get the payment currency for an investment. + fn get_investment_currency_id( + investment_id: Self::InvestmentId, + ) -> Result; + + /// Get the investments and associated payment currencies and balances for + /// an account. + fn get_account_investments_currency( + who: &Account, + ) -> Result; +} + +/// Trait to handle investments in (presumably) foreign currencies, i.e., other +/// currencies than the pool currency. +/// +/// NOTE: Has many similarities with the [Investment] trait. +pub trait ForeignInvestment { + type Amount; + type CurrencyId; + type Error: Debug; + type InvestmentId; + type CollectInvestResult; + + /// Initiates the increment of a foreign investment amount in + /// `foreign_payment_currency` of who into the investment class + /// `pool_currency` to amount. + /// + /// NOTE: In general, we can assume that the foreign and pool currencies + /// mismatch and that swapping one into the other happens asynchronously. In + /// that case, the finalization of updating the investment needs to be + /// handled decoupled from the ForeignInvestment trait, e.g., by some hook. + fn increase_foreign_investment( + who: &AccountId, + investment_id: Self::InvestmentId, + amount: Self::Amount, + foreign_payment_currency: Self::CurrencyId, + pool_currency: Self::CurrencyId, + ) -> Result<(), Self::Error>; + + /// Initiates the decrement of a foreign investment amount in + /// `foreign_payment_currency` of who into the investment class + /// `pool_currency` to amount. + /// + /// NOTE: In general, we can assume that the foreign and pool currencies + /// mismatch and that swapping one into the other happens asynchronously. In + /// that case, the finalization of updating the investment needs to be + /// handled decoupled from the ForeignInvestment trait, e.g., by some hook. + fn decrease_foreign_investment( + who: &AccountId, + investment_id: Self::InvestmentId, + amount: Self::Amount, + foreign_payment_currency: Self::CurrencyId, + pool_currency: Self::CurrencyId, + ) -> Result<(), Self::Error>; + + /// Initiates the increment of a foreign redemption amount for the given + /// investment id. + /// + /// NOTE: The `foreign_payout_currency` is only required to ensure + /// subsequent redemption updating calls match to the original chosen + /// `foreign_payment_currency`. + fn increase_foreign_redemption( + who: &AccountId, + investment_id: Self::InvestmentId, + amount: Self::Amount, + foreign_payout_currency: Self::CurrencyId, + ) -> Result<(), Self::Error>; + + /// Initiates the decrement of a foreign redemption amount. + /// + /// NOTES: + /// * The decrementing redemption amount is bound by the previously + /// incremented redemption amount. + /// * The `foreign_payout_currency` is only required for the potential + /// dispatch of a response message. + fn decrease_foreign_redemption( + who: &AccountId, + investment_id: Self::InvestmentId, + amount: Self::Amount, + foreign_payout_currency: Self::CurrencyId, + ) -> Result<(Self::Amount, Self::Amount), Self::Error>; + + /// Collect the results of a user's foreign invest orders for the given + /// investment. If any amounts are not fulfilled they are directly + /// appended to the next active order for this investment. + fn collect_foreign_investment( + who: &AccountId, + investment_id: Self::InvestmentId, + foreign_currency: Self::CurrencyId, + pool_currency: Self::CurrencyId, + ) -> Result; + + /// Collect the results of a user's foreign redeem orders for the given + /// investment. If any amounts are not fulfilled they are directly + /// appended to the next active order for this investment. + /// + /// NOTE: The currency of the collected amount will be `pool_currency` + /// whereas the user eventually wants to receive it in + /// `foreign_payout_currency`. + fn collect_foreign_redemption( + who: &AccountId, + investment_id: Self::InvestmentId, + foreign_payout_currency: Self::CurrencyId, + pool_currency: Self::CurrencyId, + ) -> Result<(), Self::Error>; + + /// Returns, if possible, the currently unprocessed investment amount (in + /// pool currency) of who into the given investment class. + /// + /// NOTE: If the investment was (partially) processed, the unprocessed + /// amount is only updated upon collecting. + fn investment( + who: &AccountId, + investment_id: Self::InvestmentId, + ) -> Result; + + /// Returns, if possible, the currently unprocessed redemption amount (in + /// tranche tokens) of who into the given investment class. + /// + /// NOTE: If the redemption was (partially) processed, the unprocessed + /// amount is only updated upon collecting. + fn redemption( + who: &AccountId, + investment_id: Self::InvestmentId, + ) -> Result; + + /// Checks whether a currency can be used for buying the given investment. + fn accepted_payment_currency( + investment_id: Self::InvestmentId, + currency: Self::CurrencyId, + ) -> bool; + + /// Checks whether a currency is accepted as a payout for the given + /// investment. + fn accepted_payout_currency( + investment_id: Self::InvestmentId, + currency: Self::CurrencyId, + ) -> bool; +} diff --git a/libs/traits/src/lib.rs b/libs/traits/src/lib.rs index 603683909f..fdcc122bfb 100644 --- a/libs/traits/src/lib.rs +++ b/libs/traits/src/lib.rs @@ -42,6 +42,8 @@ pub mod data; pub mod ethereum; /// Traits related to interest rates. pub mod interest; +/// Traits related to investments. +pub mod investments; /// Traits related to liquidity pools. pub mod liquidity_pools; /// Traits related to rewards. @@ -377,238 +379,6 @@ impl PreConditions for Never { } } -/// A trait for converting from a PoolId and a TranchId -/// into a given Self::Currency -pub trait TrancheCurrency { - fn generate(pool_id: PoolId, tranche_id: TrancheId) -> Self; - - fn of_pool(&self) -> PoolId; - - fn of_tranche(&self) -> TrancheId; -} - -/// A trait, when implemented allows to invest into -/// investment classes -pub trait Investment { - type Amount; - type CurrencyId; - type Error: Debug; - type InvestmentId; - - /// Updates the current investment amount of who into the - /// investment class to amount. - /// Meaning: if amount < previous investment, then investment - /// will be reduced, and increases in the opposite case. - fn update_investment( - who: &AccountId, - investment_id: Self::InvestmentId, - amount: Self::Amount, - ) -> Result<(), Self::Error>; - - /// Checks whether a currency can be used for buying `InvestmentId` - fn accepted_payment_currency( - investment_id: Self::InvestmentId, - currency: Self::CurrencyId, - ) -> bool; - - /// Returns, if possible, the current investment amount of who into the - /// given investment class - fn investment( - who: &AccountId, - investment_id: Self::InvestmentId, - ) -> Result; - - /// Updates the current redemption amount of who into the - /// investment class to amount. - /// Meaning: if amount < previous redemption, then redemption - /// will be reduced, and increases in the opposite case. - fn update_redemption( - who: &AccountId, - investment_id: Self::InvestmentId, - amount: Self::Amount, - ) -> Result<(), Self::Error>; - - /// Checks whether a currency is accepted as a payout for an `InvestmentId` - fn accepted_payout_currency( - investment_id: Self::InvestmentId, - currency: Self::CurrencyId, - ) -> bool; - - /// Returns, if possible, the current redemption amount of who into the - /// given investment class - fn redemption( - who: &AccountId, - investment_id: Self::InvestmentId, - ) -> Result; -} - -/// A trait which allows to collect existing investments and redemptions. -pub trait InvestmentCollector { - type Error: Debug; - type InvestmentId; - type Result: Debug; - - /// Collect the results of a user's invest orders for the given - /// investment. If any amounts are not fulfilled they are directly - /// appended to the next active order for this investment. - fn collect_investment( - who: AccountId, - investment_id: Self::InvestmentId, - ) -> Result; - - /// Collect the results of a users redeem orders for the given - /// investment. If any amounts are not fulfilled they are directly - /// appended to the next active order for this investment. - fn collect_redemption( - who: AccountId, - investment_id: Self::InvestmentId, - ) -> Result; -} - -/// A trait, when implemented must take care of -/// collecting orders (invest & redeem) for a given investment class. -/// When being asked it must return the current orders and -/// when being singled about a fulfillment, it must act accordingly. -pub trait OrderManager { - type Error; - type InvestmentId; - type Orders; - type Fulfillment; - - /// When called the manager return the current - /// invest orders for the given investment class. - fn invest_orders(asset_id: Self::InvestmentId) -> Self::Orders; - - /// When called the manager return the current - /// redeem orders for the given investment class. - fn redeem_orders(asset_id: Self::InvestmentId) -> Self::Orders; - - /// When called the manager return the current - /// invest orders for the given investment class. - /// Callers of this method can expect that the returned - /// orders equal the returned orders from `invest_orders`. - /// - /// **NOTE:** Once this is called, the OrderManager is expected - /// to start a new round of orders and return an error if this - /// method is to be called again before `invest_fulfillment` is - /// called. - fn process_invest_orders(asset_id: Self::InvestmentId) -> Result; - - /// When called the manager return the current - /// invest orders for the given investment class. - /// Callers of this method can expect that the returned - /// orders equal the returned orders from `redeem_orders`. - /// - /// **NOTE:** Once this is called, the OrderManager is expected - /// to start a new round of orders and return an error if this - /// method is to be called again before `redeem_fulfillment` is - /// called. - fn process_redeem_orders(asset_id: Self::InvestmentId) -> Result; - - /// Signals the manager that the previously - /// fetch invest orders for a given investment class - /// will be fulfilled by fulfillment. - fn invest_fulfillment( - asset_id: Self::InvestmentId, - fulfillment: Self::Fulfillment, - ) -> Result<(), Self::Error>; - - /// Signals the manager that the previously - /// fetch redeem orders for a given investment class - /// will be fulfilled by fulfillment. - fn redeem_fulfillment( - asset_id: Self::InvestmentId, - fulfillment: Self::Fulfillment, - ) -> Result<(), Self::Error>; -} - -/// A trait who's implementer provides means of accounting -/// for investments of a generic kind. -pub trait InvestmentAccountant { - type Error; - type InvestmentId; - type InvestmentInfo: InvestmentProperties; - type Amount; - - /// Information about an asset. Must allow to derive - /// owner, payment and denomination currency - fn info(id: Self::InvestmentId) -> Result; - - /// Return the balance of a given user for the given investmnet - fn balance(id: Self::InvestmentId, who: &AccountId) -> Self::Amount; - - /// Transfer a given investment from source, to destination - fn transfer( - id: Self::InvestmentId, - source: &AccountId, - dest: &AccountId, - amount: Self::Amount, - ) -> Result<(), Self::Error>; - - /// Increases the existance of - fn deposit( - buyer: &AccountId, - id: Self::InvestmentId, - amount: Self::Amount, - ) -> Result<(), Self::Error>; - - /// Reduce the existance of an asset - fn withdraw( - seller: &AccountId, - id: Self::InvestmentId, - amount: Self::Amount, - ) -> Result<(), Self::Error>; -} - -/// A trait that allows to retrieve information -/// about an investment class. -pub trait InvestmentProperties { - /// The overarching Currency that payments - /// for this class are made in - type Currency; - /// Who the investment class can be identified - type Id; - - /// Returns the owner of the investment class - fn owner(&self) -> AccountId; - - /// Returns the id of the investment class - fn id(&self) -> Self::Id; - - /// Returns the currency in which the investment class - /// can be bought. - fn payment_currency(&self) -> Self::Currency; - - /// Returns the account a payment for the investment class - /// must be made to. - /// - /// Defaults to owner. - fn payment_account(&self) -> AccountId { - self.owner() - } -} - -impl> InvestmentProperties for &T { - type Currency = T::Currency; - type Id = T::Id; - - fn owner(&self) -> AccountId { - (*self).owner() - } - - fn id(&self) -> Self::Id { - (*self).id() - } - - fn payment_currency(&self) -> Self::Currency { - (*self).payment_currency() - } - - fn payment_account(&self) -> AccountId { - (*self).payment_account() - } -} - pub mod fees { use codec::FullCodec; use frame_support::{dispatch::DispatchResult, traits::tokens::Balance}; @@ -702,9 +472,10 @@ pub trait TokenSwaps { type Balance; type SellRatio; type OrderId; + type OrderDetails; /// Swap tokens buying a `buy_amount` of `currency_in` using the - /// `currency_out` tokens. The implementator of this method should know + /// `currency_out` tokens. The implementer of this method should know /// the current market rate between those two currencies. /// `sell_rate_limit` defines the highest price acceptable for /// `currency_in` currency when buying with `currency_out`. This @@ -732,16 +503,16 @@ pub trait TokenSwaps { /// buy_amount: 100 * FOREIGN_ASSET_0_DECIMALS, /// initial_buy_amount: 100 * FOREIGN_ASSET_0_DECIMALS, /// sell_rate_limit: Quantity::checked_from_rational(3u32, - /// 2u32).unwrap(), min_fullfillment_amount: 100 * + /// 2u32).unwrap(), min_fulfillment_amount: 100 * /// FOREIGN_ASSET_0_DECIMALS, max_sell_amount: 150 * /// FOREIGN_ASSET_1_DECIMALS } fn place_order( account: Account, - currency_out: Self::CurrencyId, currency_in: Self::CurrencyId, + currency_out: Self::CurrencyId, buy_amount: Self::Balance, sell_rate_limit: Self::SellRatio, - min_fullfillment_amount: Self::Balance, + min_fulfillment_amount: Self::Balance, ) -> Result; /// Update an existing active order. @@ -751,7 +522,7 @@ pub trait TokenSwaps { /// /// This Can fail for various reasons /// - /// E.g. min_fullfillment_amount is lower and + /// E.g. min_fulfillment_amount is lower and /// the system has already fulfilled up to the previous /// one. /// @@ -773,7 +544,7 @@ pub trait TokenSwaps { /// buy_amount: 15 * FOREIGN_ASSET_0_DECIMALS, /// initial_buy_amount: 100 * FOREIGN_ASSET_0_DECIMALS, /// sell_rate_limit: Quantity::checked_from_integer(2u32).unwrap(), - /// min_fullfillment_amount: 6 * FOREIGN_ASSET_0_DECIMALS, + /// min_fulfillment_amount: 6 * FOREIGN_ASSET_0_DECIMALS, /// max_sell_amount: 30 * FOREIGN_ASSET_1_DECIMALS /// } fn update_order( @@ -781,39 +552,55 @@ pub trait TokenSwaps { order_id: Self::OrderId, buy_amount: Self::Balance, sell_rate_limit: Self::SellRatio, - min_fullfillment_amount: Self::Balance, + min_fulfillment_amount: Self::Balance, ) -> DispatchResult; /// A sanity check that can be used for validating that a trading pair /// is supported. Will also be checked when placing an order but might be /// cheaper. - fn valid_pair(currency_out: Self::CurrencyId, currency_in: Self::CurrencyId) -> bool; + fn valid_pair(currency_in: Self::CurrencyId, currency_out: Self::CurrencyId) -> bool; /// Cancel an already active order. fn cancel_order(order: Self::OrderId) -> DispatchResult; /// Check if the order is still active. fn is_active(order: Self::OrderId) -> bool; + + /// Retrieve the details of the order if it exists. + fn get_order_details(order: Self::OrderId) -> Option; } -/// Trait to handle Investment Portfolios for accounts -pub trait InvestmentsPortfolio { - type InvestmentId; - type CurrencyId; +/// Trait to transmit a change of status for anything uniquely identifiable. +/// +/// NOTE: The main use case to handle asynchronous operations. +pub trait StatusNotificationHook { + /// The identifying type + type Id; + /// The type for possible states + type Status; + /// The error type + type Error: Debug; + + /// Notify that the status has changed for the given id + fn notify_status_change(id: Self::Id, status: Self::Status) -> Result<(), Self::Error>; +} + +/// Trait to synchronously provide a currency conversion estimation for foreign +/// currencies into/from pool currencies. +pub trait IdentityCurrencyConversion { type Balance; + type Currency; type Error; - type AccountInvestmentPortfolio; - - /// Get the payment currency for an investment. - fn get_investment_currency_id( - investment_id: Self::InvestmentId, - ) -> Result; - - /// Get the investments and associated payment currencies and balances for - /// an account. - fn get_account_investments_currency( - who: &Account, - ) -> Result; + + /// Estimate the worth of an outgoing currency amount in the incoming + /// currency. + /// + /// NOTE: At least applies decimal conversion if both currencies mismatch. + fn stable_to_stable( + currency_in: Self::Currency, + currency_out: Self::Currency, + amount_out: Self::Balance, + ) -> Result; } /// A trait for trying to convert between two types. diff --git a/libs/types/src/investments.rs b/libs/types/src/investments.rs index 58e50be776..25d6008c50 100644 --- a/libs/types/src/investments.rs +++ b/libs/types/src/investments.rs @@ -10,12 +10,17 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. -use cfg_traits::InvestmentProperties; -use codec::{Decode, Encode}; -use frame_support::RuntimeDebug; +use cfg_primitives::OrderId; +use cfg_traits::investments::InvestmentProperties; +use codec::{Decode, Encode, MaxEncodedLen}; +use frame_support::{dispatch::fmt::Debug, RuntimeDebug}; use scale_info::TypeInfo; +use sp_arithmetic::traits::{EnsureAdd, EnsureSub}; +use sp_runtime::{traits::Zero, DispatchError, DispatchResult}; use sp_std::cmp::PartialEq; +use crate::orders::Order; + /// A representation of a investment identifier that can be converted to an /// account address #[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo)] @@ -52,3 +57,210 @@ where self.payment_currency.clone() } } + +/// The outstanding collections for an account +#[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo)] +pub struct InvestCollection { + /// This is the payout in the denomination currency + /// of an investment + /// * If investment: In payment currency + /// * If payout: In denomination currency + pub payout_investment_invest: Balance, + + /// This is the remaining investment in the payment currency + /// of an investment + /// * If investment: In payment currency + /// * If payout: In denomination currency + pub remaining_investment_invest: Balance, +} + +impl Default for InvestCollection { + fn default() -> Self { + InvestCollection { + payout_investment_invest: Zero::zero(), + remaining_investment_invest: Zero::zero(), + } + } +} + +impl InvestCollection { + /// Create a `InvestCollection` directly from an active invest order of + /// a user. + /// The field `remaining_investment_invest` is set to the + /// amount of the active invest order of the user and will + /// be subtracted from upon given fulfillment's + pub fn from_order(order: &Order) -> Self { + InvestCollection { + payout_investment_invest: Zero::zero(), + remaining_investment_invest: order.amount(), + } + } +} + +/// The outstanding collections for an account +#[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo)] +pub struct RedeemCollection { + /// This is the payout in the payment currency + /// of an investment + /// * If redemption: In denomination currency + /// * If payout: In payment currency + pub payout_investment_redeem: Balance, + + /// This is the remaining redemption in the denomination currency + /// of an investment + /// * If redemption: In denomination currency + /// * If payout: In payment currency + pub remaining_investment_redeem: Balance, +} + +impl Default for RedeemCollection { + fn default() -> Self { + RedeemCollection { + payout_investment_redeem: Zero::zero(), + remaining_investment_redeem: Zero::zero(), + } + } +} + +impl RedeemCollection { + /// Create a `RedeemCollection` directly from an active redeem order of + /// a user. + /// The field `remaining_investment_redeem` is set to the + /// amount of the active redeem order of the user and will + /// be subtracted from upon given fulfillment's + pub fn from_order(order: &Order) -> Self { + RedeemCollection { + payout_investment_redeem: Zero::zero(), + remaining_investment_redeem: order.amount(), + } + } +} + +/// The collected investment/redemption amount for an account +#[derive(Encode, Default, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen)] +pub struct CollectedAmount { + /// The amount which was collected + /// * If investment: Tranche tokens + /// * If redemption: Payment currency + pub amount_collected: Balance, + + /// The amount which was converted during processing based on the + /// fulfillment price(s) + /// * If investment: Payment currency + /// * If redemption: Tranche tokens + pub amount_payment: Balance, +} + +/// A representation of an investment identifier and the corresponding owner. +/// +/// NOTE: Trimmed version of `InvestmentInfo` required for foreign investments. +#[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, Default, TypeInfo, MaxEncodedLen)] + +pub struct ForeignInvestmentInfo { + pub owner: AccountId, + pub id: InvestmentId, + pub last_swap_reason: Option, +} + +/// A simple representation of a currency swap. +#[derive( + Clone, + Default, + Copy, + PartialOrd, + Ord, + PartialEq, + Eq, + Debug, + Encode, + Decode, + TypeInfo, + MaxEncodedLen, +)] +pub struct Swap< + Balance: Clone + Copy + EnsureAdd + EnsureSub + Ord + Debug, + Currency: Clone + PartialEq, +> { + /// The incoming currency, i.e. the desired one. + pub currency_in: Currency, + /// The outgoing currency, i.e. the one which should be replaced. + pub currency_out: Currency, + /// The amount of outgoing currency which shall be exchanged. + pub amount: Balance, +} + +impl + Swap +{ + /// Ensures that the ingoing and outgoing currencies of two swaps... + /// * Either match fully (in1 = in2, out1 = out2) if the swap direction is + /// the same for both swaps, i.e. (into pool, into pool) or (into foreign, + /// into foreign) + /// * Or the ingoing and outgoing currencies match (in1 = out2, out1 = in2) + /// if the swap direction is opposite, i.e. (into pool, into foreign) or + /// (into foreign, into pool) + pub fn ensure_currencies_match( + &self, + other: &Self, + is_same_swap_direction: bool, + ) -> DispatchResult { + if is_same_swap_direction + && (self.currency_in != other.currency_in || self.currency_out != other.currency_out) + { + Err(DispatchError::Other( + "Swap currency mismatch for same swap direction", + )) + } else if !is_same_swap_direction + && (self.currency_in != other.currency_out || self.currency_out != other.currency_in) + { + Err(DispatchError::Other( + "Swap currency mismatch for opposite swap direction", + )) + } else { + Ok(()) + } + } +} + +/// A representation of an executed investment decrement. +#[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, Default, TypeInfo, MaxEncodedLen)] + +pub struct ExecutedForeignDecreaseInvest { + /// The currency in which `DecreaseInvestOrder` was realised + pub foreign_currency: Currency, + /// The amount of `currency` that was actually executed in the original + /// `DecreaseInvestOrder` message, i.e., the amount by which the + /// investment order was actually decreased by. + pub amount_decreased: Balance, + /// The unprocessed plus processed but not yet collected investment amount + /// denominated in `foreign` payment currency + pub amount_remaining: Balance, +} + +/// A representation of an executed collected investment. +#[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, Default, TypeInfo, MaxEncodedLen)] + +pub struct ExecutedForeignCollectInvest { + /// The amount that was actually collected + pub amount_currency_payout: Balance, + /// The amount of tranche tokens received for the investment made + pub amount_tranche_tokens_payout: Balance, + /// The unprocessed plus processed but not yet collected investment amount + /// denominated in foreign currency + pub amount_remaining_invest: Balance, +} + +/// A representation of an executed collected redemption. +#[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, Default, TypeInfo, MaxEncodedLen)] + +pub struct ExecutedForeignCollectRedeem { + /// The foreign currency in which the payout takes place + pub currency: Currency, + /// The amount of `currency` being paid out to the investor + pub amount_currency_payout: Balance, + /// How many tranche tokens were actually redeemed + pub amount_tranche_tokens_payout: Balance, + /// The unprocessed plus processed but not yet collected redemption amount + /// of tranche tokens + pub amount_remaining_redeem: Balance, +} diff --git a/libs/types/src/tokens.rs b/libs/types/src/tokens.rs index 0e0a5af3a5..02051f2393 100644 --- a/libs/types/src/tokens.rs +++ b/libs/types/src/tokens.rs @@ -16,7 +16,7 @@ use cfg_primitives::{ types::{PoolId, TrancheId}, Balance, PalletIndex, }; -use cfg_traits::TrancheCurrency as TrancheCurrencyT; +use cfg_traits::investments::TrancheCurrency as TrancheCurrencyT; use codec::{Decode, Encode, MaxEncodedLen}; pub use orml_asset_registry::AssetMetadata; use scale_info::TypeInfo; @@ -29,7 +29,7 @@ use xcm::{ VersionedMultiLocation, }; -use crate::{xcm::XcmMetadata, EVMChainId}; +use crate::{domain_address::DomainAddress, xcm::XcmMetadata, EVMChainId}; /// The type for all Currency ids that our chains handles. /// Foreign assets gather all the tokens that are native to other chains, such @@ -316,6 +316,14 @@ pub enum LiquidityPoolsWrappedToken { }, } +impl From for DomainAddress { + fn from(token: LiquidityPoolsWrappedToken) -> Self { + match token { + LiquidityPoolsWrappedToken::EVM { chain_id, address } => Self::EVM(chain_id, address), + } + } +} + pub const LP_ETH_USDC_CURRENCY_ID: CurrencyId = CurrencyId::ForeignAsset(100001); pub const ETHEREUM_MAINNET_CHAIN_ID: EVMChainId = 1; diff --git a/pallets/foreign-investments/Cargo.toml b/pallets/foreign-investments/Cargo.toml new file mode 100644 index 0000000000..6b9a02c758 --- /dev/null +++ b/pallets/foreign-investments/Cargo.toml @@ -0,0 +1,67 @@ +[package] +authors = ["Centrifuge "] +description = "Pallet to enable investments and redemptions via a foreign interface" +edition = "2021" +license = "LGPL-3.0" +name = "pallet-foreign-investments" +repository = "https://github.com/centrifuge/centrifuge-chain" +version = "1.0.0" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { package = "parity-scale-codec", version = "3.0.0", default-features = false, features = [ + "derive", +] } +log = { version = "0.4.17", default-features = false } +scale-info = { version = "2.3.0", default-features = false, features = ["derive"] } + +cfg-primitives = { path = "../../libs/primitives", default-features = false } +cfg-traits = { path = "../../libs/traits", default-features = false } +cfg-types = { path = "../../libs/types", default-features = false } + +frame-support = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.38" } +frame-system = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.38" } +sp-runtime = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.38" } +sp-std = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.38" } + +# Benchmarking dependencies - optional +frame-benchmarking = { git = "https://github.com/paritytech/substrate", default-features = false, optional = true, branch = "polkadot-v0.9.38" } + +[dev-dependencies] +sp-core = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.38" } +sp-io = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.38" } + +[features] +default = ["std"] +std = [ + "cfg-primitives/std", + "cfg-traits/std", + "cfg-types/std", + "codec/std", + "frame-benchmarking?/std", + "frame-support/std", + "frame-system/std", + "log/std", + "scale-info/std", + "sp-runtime/std", + "sp-std/std", +] +runtime-benchmarks = [ + "cfg-traits/runtime-benchmarks", + "cfg-types/runtime-benchmarks", + "cfg-primitives/runtime-benchmarks", + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", +] +try-runtime = [ + "cfg-primitives/try-runtime", + "cfg-traits/try-runtime", + "cfg-types/try-runtime", + "frame-support/try-runtime", + "frame-system/try-runtime", + "sp-runtime/try-runtime", +] diff --git a/pallets/foreign-investments/src/errors.rs b/pallets/foreign-investments/src/errors.rs new file mode 100644 index 0000000000..a1e670bac2 --- /dev/null +++ b/pallets/foreign-investments/src/errors.rs @@ -0,0 +1,71 @@ +// Copyright 2021 Centrifuge Foundation (centrifuge.io). +// This file is part of Centrifuge Chain project. + +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). + +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +use codec::{Decode, Encode}; +use frame_support::PalletError; +use scale_info::TypeInfo; + +use crate::pallet::{Config, Error}; + +#[derive(Encode, Decode, TypeInfo, PalletError)] +pub enum InvestError { + /// Failed to increase the investment. + IncreaseTransition, + /// The desired decreasing amount exceeds the max amount. + DecreaseAmountOverflow, + /// Failed to transition the state as a result of a decrease. + DecreaseTransition, + /// Failed to transition after fulfilled swap order. + FulfillSwapOrderTransition, + /// Failed to transition a (partially) processed investment after + /// collecting. + CollectTransition, + /// The investment needs to be collected before it can be updated further. + CollectRequired, +} + +#[derive(Encode, Decode, TypeInfo, PalletError)] + +pub enum RedeemError { + /// Failed to increase the redemption. + IncreaseTransition, + /// Failed to collect the redemption. + CollectTransition, + /// Failed to retrieve the foreign payout currency for a collected + /// redemption. + /// + /// NOTE: This error can only occur, if a user tries to collect before + /// having increased their redemption as this would store the payout + /// currency. + CollectPayoutCurrencyNotFound, + /// The desired decreasing amount exceeds the max amount. + DecreaseAmountOverflow, + /// Failed to transition the state as a result of a decrease. + DecreaseTransition, + /// Failed to transition after fulfilled swap order. + FulfillSwapOrderTransition, + /// The redemption needs to be collected before it can be updated further. + CollectRequired, +} + +impl From for Error { + fn from(error: InvestError) -> Self { + Error::::InvestError(error) + } +} + +impl From for Error { + fn from(error: RedeemError) -> Self { + Error::::RedeemError(error) + } +} diff --git a/pallets/foreign-investments/src/hooks.rs b/pallets/foreign-investments/src/hooks.rs new file mode 100644 index 0000000000..f5b0e9dba6 --- /dev/null +++ b/pallets/foreign-investments/src/hooks.rs @@ -0,0 +1,248 @@ +// Copyright 2021 Centrifuge Foundation (centrifuge.io). +// This file is part of Centrifuge Chain project. + +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). + +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +use cfg_traits::{ + investments::{Investment, InvestmentCollector}, + StatusNotificationHook, +}; +use cfg_types::investments::{CollectedAmount, ForeignInvestmentInfo}; +use frame_support::{ensure, transactional}; +use sp_runtime::{ + traits::{EnsureAdd, EnsureSub, Zero}, + DispatchError, DispatchResult, +}; +use sp_std::marker::PhantomData; + +use crate::{ + errors::{InvestError, RedeemError}, + types::{InvestState, InvestTransition, RedeemState, RedeemTransition, TokenSwapReason}, + Config, Error, ForeignInvestmentInfo as ForeignInvestmentInfoStorage, InvestmentState, Pallet, + RedemptionState, SwapOf, +}; + +/// The hook struct which acts upon a fulfilled swap order. Depending on the +/// last swap reason, advances either the [`InvestmentState`] or +/// [`RedemptionState`]. +/// +/// Assumes `TokenSwaps` as caller of of the the `notify_status_change` message. +pub struct FulfilledSwapOrderHook(PhantomData); + +// Hook execution for (partially) fulfilled token swaps which should be consumed +// by `TokenSwaps`. +impl StatusNotificationHook for FulfilledSwapOrderHook { + type Error = DispatchError; + type Id = T::TokenSwapOrderId; + type Status = SwapOf; + + #[transactional] + fn notify_status_change( + id: T::TokenSwapOrderId, + status: SwapOf, + ) -> Result<(), DispatchError> { + let maybe_info = ForeignInvestmentInfoStorage::::get(id); + if maybe_info.is_none() { + return Ok(()); + } + let info = maybe_info.expect("Cannot be None; qed"); + + match info.last_swap_reason { + // Swapping into pool or foreign + Some(TokenSwapReason::Investment) => { + Self::fulfill_invest_swap_order(&info.owner, info.id, status, true) + } + // Swapping into foreign + Some(TokenSwapReason::Redemption) => { + Self::fulfill_redeem_swap_order(&info.owner, info.id, status) + } + // Both states are swapping into foreign + Some(TokenSwapReason::InvestmentAndRedemption) => { + let active_invest_swap_amount = InvestmentState::::get(&info.owner, info.id) + .get_active_swap_amount_foreign_denominated()?; + let active_redeem_swap_amount = InvestmentState::::get(&info.owner, info.id) + .get_active_swap() + .map(|swap| swap.amount) + .unwrap_or(T::Balance::zero()); + + ensure!( + status.amount + <= active_invest_swap_amount.ensure_add(active_redeem_swap_amount)?, + Error::::FulfilledTokenSwapAmountOverflow + ); + + let invest_swap = SwapOf:: { + amount: active_invest_swap_amount, + ..status + }; + let redeem_swap = SwapOf:: { + amount: status.amount.ensure_sub(active_invest_swap_amount)?, + ..status + }; + + // NOTE: Fulfillment of invest swap before redeem one for no particular reason + Self::fulfill_invest_swap_order(&info.owner, info.id, invest_swap, false)?; + Self::fulfill_redeem_swap_order(&info.owner, info.id, redeem_swap) + } + _ => { + log::debug!("Fulfilled token swap order id {:?} without advancing foreign investment because swap reason does not exist", id); + Ok(()) + } + } + } +} + +impl FulfilledSwapOrderHook { + /// Transitions the `InvestState` after fulfilling a swap order. + /// + /// NOTE: If the transition should be followed by a `RedeemState` + /// transition, the `update_swap_order` should be set to false in order to + /// oppress updating the swap order here. + #[transactional] + fn fulfill_invest_swap_order( + who: &T::AccountId, + investment_id: T::InvestmentId, + swap: SwapOf, + update_swap_order: bool, + ) -> DispatchResult { + // If the investment requires to be collected, the transition of the + // `InvestState` would fail. By implicitly collecting here, we defend against + // that and ensure that the swap order fulfillment won't be reverted (since this + // function is `transactional`). + // + // NOTE: We only collect the tranche tokens, but do not transfer them back. This + // updates the unprocessed investment amount such that transitioning the + // `InvestState` is not blocked. The user still has to do that manually by + // sending `CollectInvest`. + if T::Investment::investment_requires_collect(who, investment_id) { + T::Investment::collect_investment(who.clone(), investment_id)?; + } + + let pre_state = InvestmentState::::get(who, investment_id); + let post_state = pre_state + .transition(InvestTransition::FulfillSwapOrder(swap)) + .map_err(|e| { + // Inner error holds finer granularity but should never occur + log::debug!("ForeignInvestment state transition error: {:?}", e); + Error::::from(InvestError::FulfillSwapOrderTransition) + })?; + Pallet::::apply_invest_state_transition( + who, + investment_id, + post_state, + update_swap_order, + ) + } + + /// Transitions the `RedeemState` after fulfilling a swap order. + #[transactional] + fn fulfill_redeem_swap_order( + who: &T::AccountId, + investment_id: T::InvestmentId, + swap: SwapOf, + ) -> DispatchResult { + // If the investment requires to be collected, the transition of the + // `RedeemState` would fail. By implicitly collecting here, we defend against + // that and ensure that the swap order fulfillment won't be reverted (since this + // function is `transactional`). + // + // NOTE: We only collect the pool currency, but do neither transfer them to the + // investor nor initiate the swap back into foreign currency. This updates the + // unprocessed investment amount such that transitioning the `RedeemState` is + // not blocked. The user still has to do that manually by + // sending `CollectInvest`. + if T::Investment::redemption_requires_collect(who, investment_id) { + T::Investment::collect_redemption(who.clone(), investment_id)?; + } + + // Check if redeem state is swapping and thus needs to be fulfilled + let pre_state = RedemptionState::::get(who, investment_id); + let post_state = pre_state + .transition(RedeemTransition::FulfillSwapOrder(swap)) + .map_err(|e| { + // Inner error holds finer granularity but should never occur + log::debug!("ForeignInvestment state transition error: {:?}", e); + Error::::from(RedeemError::FulfillSwapOrderTransition) + })?; + Pallet::::apply_redeem_state_transition(who, investment_id, post_state) + } +} + +/// The hook struct which acts upon the collection of a foreign investment. +/// +/// NOTE: Only increments the collected amount and transitions the `InvestState` +/// to update the unprocessed invest amount but does not transfer back the +/// collected amounts. We expect the user do that via +/// `collect_foreign_investment`. +pub struct CollectedInvestmentHook(PhantomData); +impl StatusNotificationHook for CollectedInvestmentHook { + type Error = DispatchError; + type Id = ForeignInvestmentInfo; + type Status = CollectedAmount; + + #[transactional] + fn notify_status_change( + id: ForeignInvestmentInfo, + status: CollectedAmount, + ) -> DispatchResult { + let ForeignInvestmentInfo { + id: investment_id, + owner: investor, + .. + } = id; + let pre_state = InvestmentState::::get(&investor, investment_id); + + // Exit early if there is no foreign investment + if pre_state == InvestState::::NoState { + return Ok(()); + } + + Pallet::::denote_collected_investment(&investor, investment_id, status)?; + + Ok(()) + } +} + +/// The hook struct which acts upon a finalized redemption collection. +/// +/// NOTE: Only increments the collected amount and transitions the `RedeemState` +/// to update the unprocessed redeem amount but does not transfer back the +/// collected amounts. We expect the user do via +/// `collect_foreign_redemption`. + +pub struct CollectedRedemptionHook(PhantomData); +impl StatusNotificationHook for CollectedRedemptionHook { + type Error = DispatchError; + type Id = ForeignInvestmentInfo; + type Status = CollectedAmount; + + #[transactional] + fn notify_status_change( + id: ForeignInvestmentInfo, + status: CollectedAmount, + ) -> DispatchResult { + let ForeignInvestmentInfo { + id: investment_id, + owner: investor, + .. + } = id; + let pre_state = RedemptionState::::get(&investor, investment_id); + + // Exit early if there is no foreign redemption + if pre_state == RedeemState::NoState { + return Ok(()); + } + + Pallet::::denote_collected_redemption(&investor, investment_id, status)?; + + Ok(()) + } +} diff --git a/pallets/foreign-investments/src/impls/invest.rs b/pallets/foreign-investments/src/impls/invest.rs new file mode 100644 index 0000000000..4c3a98a6eb --- /dev/null +++ b/pallets/foreign-investments/src/impls/invest.rs @@ -0,0 +1,1119 @@ +// Copyright 2021 Centrifuge Foundation (centrifuge.io). +// This file is part of Centrifuge Chain project. + +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). + +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +use core::cmp::Ordering; + +use cfg_traits::IdentityCurrencyConversion; +use cfg_types::investments::Swap; +use sp_runtime::{ + traits::{EnsureAdd, EnsureSub, Zero}, + ArithmeticError, DispatchError, +}; + +use crate::types::{InvestState, InvestStateConfig, InvestTransition}; + +impl InvestState +where + T: InvestStateConfig, +{ + /// Solely apply state machine to transition one `InvestState` into another + /// based on the transition, see . + /// + /// NOTE: MUST call `apply_invest_state_transition` on the post state to + /// actually mutate storage. + pub fn transition( + &self, + transition: InvestTransition, + ) -> Result { + match transition { + InvestTransition::IncreaseInvestOrder(swap) => Self::handle_increase(self, swap), + InvestTransition::DecreaseInvestOrder(swap) => Self::handle_decrease(self, swap), + InvestTransition::FulfillSwapOrder(swap) => { + Self::handle_fulfilled_swap_order(self, swap) + } + InvestTransition::CollectInvestment(amount_unprocessed) => { + Self::handle_collect(self, amount_unprocessed) + } + } + } + + /// Returns the active swap if it exists, i.e. if the state includes + /// `ActiveSwapInto{Foreign, Pool}Currency`. + pub(crate) fn get_active_swap(&self) -> Option> { + match *self { + Self::NoState => None, + Self::InvestmentOngoing { .. } => None, + Self::ActiveSwapIntoPoolCurrency { swap } => Some(swap), + Self::ActiveSwapIntoForeignCurrency { swap } => Some(swap), + Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { swap, .. } => Some(swap), + Self::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { swap, .. } => Some(swap), + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDone { swap, .. } => Some(swap), + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { swap, .. } => Some(swap), + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap, + .. + } => Some(swap), + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap, + .. + } => Some(swap), + Self::SwapIntoForeignDone { .. } => None, + Self::SwapIntoForeignDoneAndInvestmentOngoing { .. } => None, + } + } + + /// Returns the potentially existing active swap amount denominated in pool + /// currency: + /// * If the state includes `ActiveSwapIntoPoolCurrency`, it returns + /// `Some(swap.amount)`. + /// * If the state includes `ActiveSwapIntoForeignCurrency`, it returns + /// `Some(swap.amount)` converted into pool currency denomination. + /// * Else, it returns `None`. + pub(crate) fn get_active_swap_amount_pool_denominated( + &self, + ) -> Result { + match *self { + Self::NoState => Ok(T::Balance::zero()), + Self::InvestmentOngoing { .. } => Ok(T::Balance::zero()), + Self::ActiveSwapIntoPoolCurrency { swap } => Ok(swap.amount), + Self::ActiveSwapIntoForeignCurrency { swap } => { + Ok(T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?) + } + Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { swap, .. } => Ok(swap.amount), + Self::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { swap, .. } => { + Ok(T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?) + } + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDone { swap, .. } => Ok(swap.amount), + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { swap, .. } => { + Ok(T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?) + } + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap, + .. + } => Ok(swap.amount), + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap, + .. + } => Ok(T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?), + Self::SwapIntoForeignDone { .. } => Ok(T::Balance::zero()), + Self::SwapIntoForeignDoneAndInvestmentOngoing { .. } => Ok(T::Balance::zero()), + } + } + + /// Returns the potentially existing active swap amount denominated in + /// foreign currency: + /// * If the state includes `ActiveSwapIntoPoolCurrency`, it returns + /// `Some(swap.amount)` converted into foreign currency denomination. + /// * If the state includes `ActiveSwapIntoForeignCurrency`, it returns + /// `Some(swap.amount)`. + /// * Else, it returns `None`. + pub(crate) fn get_active_swap_amount_foreign_denominated( + &self, + ) -> Result { + match *self { + Self::NoState => Ok(T::Balance::zero()), + Self::InvestmentOngoing { .. } => Ok(T::Balance::zero()), + Self::ActiveSwapIntoPoolCurrency { swap } => { + Ok(T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?) + } + Self::ActiveSwapIntoForeignCurrency { swap } => Ok(swap.amount), + Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { swap, .. } => { + Ok(T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?) + } + Self::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { swap, .. } => Ok(swap.amount), + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDone { swap, .. } => { + Ok(T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?) + } + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { swap, .. } => { + Ok(swap.amount) + } + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap, + .. + } => Ok(T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?), + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap, + .. + } => Ok(swap.amount), + Self::SwapIntoForeignDone { .. } => Ok(T::Balance::zero()), + Self::SwapIntoForeignDoneAndInvestmentOngoing { .. } => Ok(T::Balance::zero()), + } + } + + /// Returns the `invest_amount` if existent, else zero. + pub(crate) fn get_investing_amount(&self) -> T::Balance { + match *self { + Self::InvestmentOngoing { invest_amount } + | Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { invest_amount, .. } + | Self::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { invest_amount, .. } + | Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + invest_amount, + .. + } + | Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + invest_amount, + .. + } + | Self::SwapIntoForeignDoneAndInvestmentOngoing { invest_amount, .. } => invest_amount, + _ => T::Balance::zero(), + } + } +} + +// Actual impl of transition +impl InvestState +where + T: InvestStateConfig, +{ + /// Handle `increase` transitions depicted by `msg::increase` edges in the + /// invest state diagram: + /// * If there is no swap into foreign currency, the pool currency swap + /// amount is increased. + /// * Else, resolves opposite swap directions by immediately fulfilling the + /// side with lower amounts; or both if the swap amounts are equal. + /// + /// When we increase an investment, we normally have to swap it into pool + /// currency (`ActiveSwapIntoPoolCurrency`) before it can be invested + /// (`ActiveInvestmentOngoing`). However, if the current state includes + /// swapping back into pool currency (`ActiveSwapIntoForeignCurrency`) as + /// the result of a previous decrement, then we can minimize the amount + /// which needs to be swapped such that we always have **at most a single + /// active swap** which is the maximum of `pool_swap.amount` and + /// `foreign_swap.amount`. When we do this, we always need to bump the + /// investment amount as well as the `SwapIntoForeignDone` amount as a + /// result of immediately fulfilling the pool swap order up to the possible + /// amount. + /// + /// Example: + /// * Say before my pre invest state has `foreign_done = 1000` and + /// `foreign_swap.amount = 500`. Now we look at three scenarios in which we + /// increase below, exactly at and above the `foreign_swap.amount`: + /// * a) If we increase by 500, we can reduce the `foreign_swap.amount` + /// fully, which we denote by adding the 500 to the `foreign_done` amount. + /// Moreover, we can immediately invest the 500. The resulting state is + /// `(done_amount = 1500, investing = 500)`. + /// * b) If we increase by 400, we can reduce the `foreign_swap.amount` only + /// by 400 and increase both the `investing` as well as `foreign_done` + /// amount by that. The resulting state is + /// `(done_amount = 1400, foreign_swap.amount = 100, investing = 400)`. + /// * c) If we increase by 600, we can reduce the `foreign_swap.amount` + /// fully and need to add a swap into pool currency for 100. Moreover both + /// the `investing` as well as `foreign_done` amount can only be increased + /// by 500. The resulting state is + /// `(done_amount = 1500, pool_swap.amount = 100, investing = 500)`. + /// + /// NOTES: + /// * We can never directly compare `swap.amount` and `invest_amount` with + /// `foreign_swap.amount` and `done_amount` if the currencies mismatch as + /// the former pair is denominated in pool currency and the latter one in + /// foreign currency. + /// * We can ignore handling all states which include `*SwapIntoForeignDone` + /// without `ActiveSwapIntoForeignCurrency*` as we consume the done amount + /// and transition in the post transition phase. To be safe and to not + /// make any unhandled assumptions, we throw `DispatchError::Other` for + /// these states though we need to make sure this can never occur! + fn handle_increase( + &self, + swap: Swap, + ) -> Result { + if swap.currency_in == swap.currency_out { + return Self::handle_increase_non_foreign(self, swap); + } + + match &self { + Self::NoState => Ok(Self::ActiveSwapIntoPoolCurrency { swap }), + // Add pool swap + Self::InvestmentOngoing { invest_amount } => { + Ok(Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + swap, + invest_amount: *invest_amount, + }) + } + // Bump pool swap + Self::ActiveSwapIntoPoolCurrency { swap: pool_swap } => { + swap.ensure_currencies_match(pool_swap, true)?; + Ok(Self::ActiveSwapIntoPoolCurrency { + swap: Swap { + amount: swap.amount.ensure_add(pool_swap.amount)?, + ..swap + }, + }) + } + // Reduce foreign swap amount by the increasing amount and increase investing amount as + // well adding foreign_done amount by the minimum of active swap amounts + Self::ActiveSwapIntoForeignCurrency { swap: foreign_swap } => { + swap.ensure_currencies_match(foreign_swap, false)?; + let foreign_amount_pool_denominated = T::CurrencyConverter::stable_to_stable( + swap.currency_in, + swap.currency_out, + foreign_swap.amount, + )?; + let pool_amount_foreign_denominated = T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?; + + match swap.amount.cmp(&foreign_amount_pool_denominated) { + // Pool swap amount is immediately fulfilled, i.e. invested and marked as done into foreign + Ordering::Less => { + Ok(Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: Swap { + amount: foreign_swap + .amount + .ensure_sub(pool_amount_foreign_denominated)?, + ..*foreign_swap + }, + done_amount: pool_amount_foreign_denominated, + invest_amount: swap.amount, + }) + } + // Both opposite swaps are immediately fulfilled, i.e. invested and marked as done + Ordering::Equal => Ok(Self::SwapIntoForeignDoneAndInvestmentOngoing { + done_swap: *foreign_swap, + invest_amount: swap.amount, + }), + // Foreign swap amount is immediately fulfilled, i.e. invested and marked as done + Ordering::Greater => { + Ok( + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: Swap { + // safe since amount_in_foreign > foreign_swap.amount + amount: swap.amount.ensure_sub(foreign_amount_pool_denominated)?, + ..swap + }, + done_amount: foreign_swap.amount, + invest_amount: foreign_amount_pool_denominated, + }, + ) + } + } + } + // Bump pool swap + Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + swap: pool_swap, + invest_amount, + } => { + swap.ensure_currencies_match(pool_swap, true)?; + + Ok(Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + swap: Swap { + amount: swap.amount.ensure_add(pool_swap.amount)?, + ..swap + }, + invest_amount: *invest_amount, + }) + } + // Reduce foreign swap amount by the increasing amount and increase investing amount as + // well adding foreign_done amount by the minimum of active swap amounts + Self::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { + swap: foreign_swap, + invest_amount, + } => { + swap.ensure_currencies_match(foreign_swap, false)?; + + let foreign_amount_pool_denominated = T::CurrencyConverter::stable_to_stable( + swap.currency_in, + swap.currency_out, + foreign_swap.amount, + )?; + let pool_amount_foreign_denominated = T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?; + let invest_amount = + invest_amount.ensure_add(swap.amount.min(foreign_amount_pool_denominated))?; + let done_amount = pool_amount_foreign_denominated.min(foreign_swap.amount); + + match swap.amount.cmp(&foreign_amount_pool_denominated) { + // Pool swap amount is immediately fulfilled, i.e. invested and marked as done + // into foreign + Ordering::Less => { + Ok( + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: Swap { + amount: foreign_swap.amount.ensure_sub(pool_amount_foreign_denominated)?, + ..*foreign_swap + }, + done_amount, + invest_amount, + }, + ) + } + // Both opposite swaps are immediately fulfilled, i.e. invested and marked as done + Ordering::Equal => Ok(Self::SwapIntoForeignDoneAndInvestmentOngoing { + done_swap: *foreign_swap, + invest_amount, + }), + // Foreign swap amount is immediately fulfilled, i.e. invested and marked as done + Ordering::Greater => { + Ok( + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: Swap { + amount: swap.amount.ensure_sub(foreign_amount_pool_denominated)?, + ..swap + }, + done_amount, + invest_amount, + }, + ) + } + } + } + // Reduce amount of foreign by the increasing amount and increase investing as well as + // foreign_done amount by the minimum of active swap amounts + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: foreign_swap, + done_amount, + } => { + swap.ensure_currencies_match(foreign_swap, false)?; + + let foreign_amount_pool_denominated = T::CurrencyConverter::stable_to_stable( + swap.currency_in, + swap.currency_out, + foreign_swap.amount, + )?; + let pool_amount_foreign_denominated = T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?; + let invest_amount = swap.amount.min(foreign_amount_pool_denominated); + let done_amount = pool_amount_foreign_denominated + .min(foreign_swap.amount) + .ensure_add(*done_amount)?; + + match swap.amount.cmp(&foreign_amount_pool_denominated) { + // Pool swap amount is immediately fulfilled, i.e. invested and marked as done + // into foreign + Ordering::Less => { + Ok( + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: Swap { + amount: foreign_swap.amount.ensure_sub(pool_amount_foreign_denominated)?, + ..*foreign_swap + }, + done_amount, + invest_amount, + }, + ) + } + // Both opposite swaps are immediately fulfilled, i.e. invested and marked as + // done + Ordering::Equal => Ok(Self::SwapIntoForeignDoneAndInvestmentOngoing { + done_swap: Swap { + amount: done_amount, + ..*foreign_swap + }, + invest_amount, + }), + // Foreign swap amount is immediately fulfilled, i.e. invested and marked as + // done + Ordering::Greater => { + Ok( + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: Swap { + amount: swap.amount.ensure_sub(foreign_amount_pool_denominated)?, + ..swap + }, + done_amount, + invest_amount, + }, + ) + } + } + } + // Reduce amount of foreign swap by increasing amount and increase investing as well as + // foreign_done amount by minimum of swap amounts + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: foreign_swap, + done_amount, + invest_amount, + } => { + swap.ensure_currencies_match(foreign_swap, false)?; + + let foreign_amount_pool_denominated = T::CurrencyConverter::stable_to_stable( + swap.currency_in, + swap.currency_out, + foreign_swap.amount, + )?; + let pool_amount_foreign_denominated = T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?; + let invest_amount = + invest_amount.ensure_add(swap.amount.min(foreign_amount_pool_denominated))?; + let done_amount = pool_amount_foreign_denominated + .min(foreign_swap.amount) + .ensure_add(*done_amount)?; + + match swap.amount.cmp(&foreign_amount_pool_denominated) { + // Pool swap amount is immediately fulfilled, i.e. invested and marked as done into foreign + Ordering::Less => Ok( + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: Swap { + amount: foreign_swap.amount.ensure_sub(pool_amount_foreign_denominated)?, + ..*foreign_swap + }, + done_amount, + invest_amount, + }, + ), + // Both opposite swaps are immediately fulfilled, i.e. invested and marked as done + Ordering::Equal => Ok(Self::SwapIntoForeignDoneAndInvestmentOngoing { + done_swap: Swap { + amount: done_amount, + ..*foreign_swap + }, + invest_amount, + }), + // Foreign swap amount is immediately fulfilled, i.e. invested and marked as done + Ordering::Greater => Ok( + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: Swap { + amount: swap.amount.ensure_sub(foreign_amount_pool_denominated)?, + ..swap + }, + done_amount, + invest_amount, + }, + ), + } + } + _ => Err(DispatchError::Other( + "Invalid invest state, should automatically be transitioned into \ + ActiveSwapIntoPoolCurrencyAndInvestmentOngoing", + )), + } + } + + /// Handle `decrease` transitions depicted by `msg::decrease` edges in the + /// state diagram: + /// * If there is no swap into pool currency, the foreign currency swap + /// amount is increased up to the ongoing investment amount which is not + /// yet processed. + /// * Else, resolves opposite swap directions by immediately fulfilling the + /// side with lower amounts; or both if the swap amounts are equal. + /// + /// Throws if the decreasing amount exceeds the amount which is + /// currently swapping into pool currency and/or investing as we cannot + /// decrease more than was invested. We must ensure, this can never happen + /// at this stage! + /// + /// NOTE: We can ignore handling all states which include + /// `SwapIntoForeignDone` without `ActiveSwapIntoForeignCurrency` as we + /// consume the done amount and transition in the post transition phase. + /// Moreover, we can ignore handling all states which do not include + /// `ActiveSwapIntoPoolCurrency` or `InvestmentOngoing` as we cannot reduce + /// further then. + /// To be safe and to not make any unhandled assumptions, we throw + /// `DispatchError::Other` for these states though we need to make sure + /// this can never occur! + fn handle_decrease( + &self, + swap: Swap, + ) -> Result { + if swap.currency_in == swap.currency_out { + return Self::handle_decrease_non_foreign(self, swap); + } + + match &self { + // Cannot reduce if there is neither an ongoing investment nor an active swap into pool + // currency + Self::NoState + | Self::ActiveSwapIntoForeignCurrency { .. } + | Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { .. } => Err(DispatchError::Other( + "Invalid invest state when transitioning a decrease", + )), + // Increment foreign swap amount up to ongoing investment + Self::InvestmentOngoing { invest_amount } => { + let foreign_amount_pool_denominated = T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?; + + match foreign_amount_pool_denominated.cmp(invest_amount) { + Ordering::Less => Ok(Self::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { + swap, + invest_amount: invest_amount.ensure_sub(foreign_amount_pool_denominated)?, + }), + Ordering::Equal => Ok(Self::ActiveSwapIntoForeignCurrency { swap }), + // should never occur but let's be safe here + Ordering::Greater => Err(DispatchError::Arithmetic(ArithmeticError::Underflow)), + } + } + // Increment return done amount up to amount of the active pool swap + Self::ActiveSwapIntoPoolCurrency { swap: pool_swap } => { + swap.ensure_currencies_match(pool_swap, false)?; + + let foreign_amount_pool_denominated = T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?; + + match foreign_amount_pool_denominated.cmp(&pool_swap.amount) { + Ordering::Less => Ok(Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDone { + swap: Swap { + amount: pool_swap + .amount + .ensure_sub(foreign_amount_pool_denominated)?, + ..*pool_swap + }, + done_amount: swap.amount, + }), + Ordering::Equal => Ok(Self::SwapIntoForeignDone { done_swap: swap }), + // should never occur but let's be safe here + Ordering::Greater => Err(DispatchError::Arithmetic(ArithmeticError::Underflow)), + } + } + // Increment `foreign_done` up to pool swap amount and increment foreign swap amount up + // to ongoing investment + Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + swap: pool_swap, + invest_amount, + } => { + swap.ensure_currencies_match(pool_swap, false)?; + + let foreign_amount_pool_denominated = T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?; + let pool_amount_foreign_denominated = T::CurrencyConverter::stable_to_stable( + swap.currency_in, + swap.currency_out, + pool_swap.amount, + )?; + let max_decrease_amount_pool_denominated = + pool_swap.amount.ensure_add(*invest_amount)?; + + // Decrease swap into pool + if foreign_amount_pool_denominated < pool_swap.amount { + Ok( + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: Swap { + amount: pool_swap.amount.ensure_sub(foreign_amount_pool_denominated)?, + ..*pool_swap + }, + done_amount: swap.amount, + invest_amount: *invest_amount, + }, + ) + } + // Active swaps cancel out each other + else if foreign_amount_pool_denominated == pool_swap.amount { + Ok(Self::SwapIntoForeignDoneAndInvestmentOngoing { + done_swap: swap, + invest_amount: *invest_amount, + }) + } + // Decrement exceeds swap into pool and partially ongoing investment + else if foreign_amount_pool_denominated < max_decrease_amount_pool_denominated { + Ok( + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: Swap { + amount: swap.amount.ensure_sub(pool_amount_foreign_denominated)?, + ..swap + }, + done_amount: pool_amount_foreign_denominated, + // Foreign swap amount is larger than pool swap amount + invest_amount: max_decrease_amount_pool_denominated.ensure_sub(foreign_amount_pool_denominated)?, + }, + ) + } + // Decrement cancels entire swap into pool and ongoing investment + else if swap.amount == max_decrease_amount_pool_denominated { + Ok(Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: Swap { + amount: swap.amount.ensure_sub(pool_amount_foreign_denominated)?, + ..swap + }, + done_amount: pool_amount_foreign_denominated, + }) + } + // should never occur but let's be safe here + else { + Err(DispatchError::Arithmetic(ArithmeticError::Underflow)) + } + } + // Increment foreign swap up to ongoing investment + Self::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { + swap: foreign_swap, + invest_amount, + } => { + swap.ensure_currencies_match(foreign_swap, true)?; + + let amount = foreign_swap.amount.ensure_add(swap.amount)?; + let swap_amount_pool_denominated = T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?; + + match swap_amount_pool_denominated.cmp(invest_amount) { + Ordering::Less => Ok(Self::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { + swap: Swap { amount, ..swap }, + invest_amount: invest_amount.ensure_sub(swap_amount_pool_denominated)?, + }), + Ordering::Equal => Ok(Self::ActiveSwapIntoForeignCurrency { + swap: Swap { amount, ..swap }, + }), + // should never occur but let's be safe here + Ordering::Greater => Err(DispatchError::Arithmetic(ArithmeticError::Underflow)), + } + } + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: foreign_swap, + done_amount, + invest_amount, + } => { + swap.ensure_currencies_match(foreign_swap, true)?; + + let amount = foreign_swap.amount.ensure_add(swap.amount)?; + let swap_amount_pool_denominated = T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?; + + match swap_amount_pool_denominated.cmp(invest_amount) { + Ordering::Less => { + Ok( + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: Swap { amount, ..swap }, + done_amount: *done_amount, + invest_amount: invest_amount.ensure_sub(swap_amount_pool_denominated)?, + }, + ) + }, + Ordering::Equal => { + Ok(Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: Swap { amount, ..swap }, + done_amount: *done_amount, + }) + } + // should never occur but let's be safe here + Ordering::Greater => { + Err(DispatchError::Arithmetic(ArithmeticError::Underflow)) + } + } + } + _ => Err(DispatchError::Other( + "Invalid invest state, should automatically be transitioned into \ + ActiveSwapIntoPoolCurrencyAndInvestmentOngoing", + )), + } + } + + /// Handle partial/full token swap order transitions depicted by + /// `order_partial` and `order_full` edges in the state diagram. + /// + /// Please note, that we ensure that there can always be at most one swap, + /// either into pool currency (`ActiveSwapIntoPoolCurrency`) or into foreign + /// currency (`ActiveSwapIntoForeignCurrency`). Thus, if the previous state + /// (`&self`) is into pool, we know the incoming transition is made from + /// return into pool currency and vice versa if the previous state is + /// swapping into foreign currency. + /// + /// This transition should always increase the active ongoing + /// investment. + /// + /// NOTES: + /// * The fulfilled swap will always match the current state (i.e. IntoPool + /// or IntoForeign) and we do not need to denominate amounts into the + /// opposite currency. + /// * We can ignore handling all states which include `SwapIntoForeignDone` + /// without `ActiveSwapIntoForeignCurrency` as we consume the done amount + /// and transition in the post transition phase. Moreover, we can ignore + /// handling all states which do not include `ActiveSwapInto{Pool, + /// Return}Currency` as else there cannot be an active token swap for + /// investments. To be safe and to not make any unhandled assumptions, we + /// throw `DispatchError::Other` for these states though we need to make + /// sure this can never occur! + fn handle_fulfilled_swap_order( + &self, + swap: Swap, + ) -> Result { + match &self { + Self::NoState | Self::InvestmentOngoing { .. } => Err(DispatchError::Other( + "Invalid invest state when transitioning a fulfilled order", + )), + // Increment ongoing investment by swapped amount + Self::ActiveSwapIntoPoolCurrency { swap: pool_swap } => { + swap.ensure_currencies_match(pool_swap, true)?; + match swap.amount.cmp(&pool_swap.amount) { + Ordering::Equal => Ok(Self::InvestmentOngoing { + invest_amount: swap.amount, + }), + Ordering::Less => Ok(Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + swap: Swap { + amount: pool_swap.amount.ensure_sub(swap.amount)?, + ..swap + }, + invest_amount: swap.amount, + }), + // should never occur but let's be safe here + Ordering::Greater => Err(DispatchError::Arithmetic(ArithmeticError::Overflow)), + } + } + // Increment ongoing investment by swapped amount + Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + swap: pool_swap, + invest_amount, + } => { + swap.ensure_currencies_match(pool_swap, true)?; + let invest_amount = invest_amount.ensure_add(swap.amount)?; + match swap.amount.cmp(&pool_swap.amount) { + Ordering::Equal => Ok(Self::InvestmentOngoing { invest_amount }), + Ordering::Less => Ok(Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + swap: Swap { + amount: pool_swap.amount.ensure_sub(swap.amount)?, + ..swap + }, + invest_amount, + }), + // should never occur but let's be safe here + Ordering::Greater => Err(DispatchError::Arithmetic(ArithmeticError::Overflow)), + } + }, + // Increment done_foreign by swapped amount + Self::ActiveSwapIntoForeignCurrency { swap: foreign_swap } => { + swap.ensure_currencies_match(foreign_swap, true)?; + + match swap.amount.cmp(&foreign_swap.amount) { + Ordering::Equal => { + Ok(Self::SwapIntoForeignDone { done_swap: swap }) + } + Ordering::Less => { + Ok(Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: Swap { + amount: foreign_swap.amount.ensure_sub(swap.amount)?, + ..swap + }, + done_amount: swap.amount, + }) + }, + Ordering::Greater => Err(DispatchError::Arithmetic(ArithmeticError::Overflow)), } + }, + // Increment done_foreign by swapped amount, leave invest amount untouched + Self::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { + swap: foreign_swap, + invest_amount, + } => { + swap.ensure_currencies_match(foreign_swap, true)?; + + match swap.amount.cmp(&foreign_swap.amount) { + Ordering::Equal => { + Ok(Self::SwapIntoForeignDoneAndInvestmentOngoing { + done_swap: swap, + invest_amount: *invest_amount, + }) + } + Ordering::Less => { + Ok( + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: Swap { + amount: foreign_swap.amount.ensure_sub(swap.amount)?, + ..swap + }, + done_amount: swap.amount, + invest_amount: *invest_amount, + }, + ) + }, + Ordering::Greater => Err(DispatchError::Arithmetic(ArithmeticError::Overflow)), + } + }, + // Increment done_foreign by swapped amount + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: foreign_swap, + done_amount, + } => { + swap.ensure_currencies_match(foreign_swap, true)?; + let done_amount = done_amount.ensure_add(swap.amount)?; + + match swap.amount.cmp(&foreign_swap.amount) { + Ordering::Equal => { + Ok(Self::SwapIntoForeignDone { + done_swap: Swap { + amount: done_amount, + ..swap + }, + }) + } + Ordering::Less => { + Ok(Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: Swap { + amount: foreign_swap.amount.ensure_sub(swap.amount)?, + ..swap + }, + done_amount, + }) + }, + Ordering::Greater => Err(DispatchError::Arithmetic(ArithmeticError::Overflow)), + } + }, + // Increment done_foreign by swapped amount, leave invest amount untouched + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: foreign_swap, + done_amount, + invest_amount, + } => { + swap.ensure_currencies_match(foreign_swap, true)?; + let done_amount = done_amount.ensure_add(swap.amount)?; + + match swap.amount.cmp(&foreign_swap.amount) { + Ordering::Equal => { + Ok(Self::SwapIntoForeignDoneAndInvestmentOngoing { + done_swap: Swap { + amount: done_amount, + ..swap + }, + invest_amount: *invest_amount, + }) + } + Ordering::Less => { + Ok( + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: Swap { + amount: foreign_swap.amount.ensure_sub(swap.amount)?, + ..swap + }, + done_amount, + invest_amount: *invest_amount, + }, + ) + }, + Ordering::Greater => Err(DispatchError::Arithmetic(ArithmeticError::Overflow)), + } + }, + _ => Err(DispatchError::Other( + "Invalid invest state, should automatically be transitioned into state without AndSwapIntoForeignDone", + )), + } + } + + /// Handle increase transitions for the same incoming and outgoing + /// currencies. + /// + /// NOTE: We can ignore handling all states which include + /// `SwapIntoForeignDone` without `ActiveSwapIntoForeignCurrency` as we + /// consume the done amount and transition in the post transition phase. + /// Moreover, we can ignore any state which involves an active swap, i.e. + /// `ActiveSwapInto{Pool, Return}Currency`, as these must not exist if the + /// in and out currency is the same. + /// To be safe and to not make any unhandled assumptions, we throw + /// `DispatchError::Other` for these states though we need to make sure + /// this can never occur! + fn handle_increase_non_foreign( + &self, + swap: Swap, + ) -> Result { + match &self { + Self::NoState => Ok(Self::InvestmentOngoing { + invest_amount: swap.amount, + }), + Self::InvestmentOngoing { invest_amount } => Ok(Self::InvestmentOngoing { + invest_amount: invest_amount.ensure_add(swap.amount)?, + }), + Self::ActiveSwapIntoPoolCurrency { .. } + | Self::ActiveSwapIntoForeignCurrency { .. } + | Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { .. } + | Self::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { .. } + | Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { .. } + | Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + .. + } => Err(DispatchError::Other( + "Invalid invest state when transitioning an increased swap order with the same in- \ + and outgoing currency", + )), + _ => Err(DispatchError::Other( + "Invalid invest state, should automatically be transitioned into state without \ + AndSwapIntoForeignDone", + )), + } + } + + /// Handle decrease transitions for the same incoming and outgoing + /// currencies. + /// + /// NOTES: + /// * We can never directly compare `swap.amount` or `done_amount` with + /// `pool_swap.amount` and `invest_amount` if the currencies mismatch as + /// the former pair is denominated in foreign currency and the latter pair + /// in pool currency. + /// * We can ignore handling all states which include `SwapIntoForeignDone` + /// without `ActiveSwapIntoForeignCurrency` as we consume the done amount + /// and transition in the post transition phase. Moreover, we can ignore + /// any state which involves an active swap, i.e. `ActiveSwapInto{Pool, + /// Return}Currency`, as these must not exist if the in and out currency + /// is the same. To be safe and to not make any unhandled assumptions, we + /// throw `DispatchError::Other` for these states though we need to make + /// sure this can never occur! + fn handle_decrease_non_foreign( + &self, + swap: Swap, + ) -> Result { + if let Self::InvestmentOngoing { invest_amount } = &self { + if swap.amount < *invest_amount { + Ok(Self::SwapIntoForeignDoneAndInvestmentOngoing { + done_swap: swap, + invest_amount: invest_amount.ensure_sub(swap.amount)?, + }) + } else { + Ok(Self::SwapIntoForeignDone { done_swap: swap }) + } + } + // should never occur but let's be safe here + else { + Err(DispatchError::Other( + "Invalid invest state when transitioning a decreased swap order with the same in- \ + and outgoing currency", + )) + } + } + + /// Update or kill the state's unprocessed investing amount. + /// * If the state includes `InvestmentOngoing`, either update or remove the + /// invested amount. + /// * Else the unprocessed amount should be zero. If it is not, state is + /// corrupted as this reflects the investment was increased improperly. + fn handle_collect(&self, unprocessed_amount: T::Balance) -> Result { + match self { + Self::InvestmentOngoing { .. } => { + if unprocessed_amount.is_zero() { + Ok(Self::NoState) + } else { + Ok(Self::InvestmentOngoing { + invest_amount: unprocessed_amount, + }) + } + } + Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { swap, .. } => { + if unprocessed_amount.is_zero() { + Ok(Self::ActiveSwapIntoPoolCurrency { swap: *swap }) + } else { + Ok(Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + swap: *swap, + invest_amount: unprocessed_amount, + }) + } + } + Self::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { swap, .. } => { + if unprocessed_amount.is_zero() { + Ok(Self::ActiveSwapIntoForeignCurrency { swap: *swap }) + } else { + Ok(Self::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { + swap: *swap, + invest_amount: unprocessed_amount, + }) + } + } + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap, + done_amount, + .. + } => { + if unprocessed_amount.is_zero() { + Ok(Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDone { + swap: *swap, + done_amount: *done_amount, + }) + } else { + Ok( + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: *swap, + done_amount: *done_amount, + invest_amount: unprocessed_amount, + }, + ) + } + } + Self::SwapIntoForeignDoneAndInvestmentOngoing { done_swap, .. } => { + if unprocessed_amount.is_zero() { + Ok(Self::SwapIntoForeignDone { + done_swap: *done_swap, + }) + } else { + Ok(Self::SwapIntoForeignDoneAndInvestmentOngoing { + done_swap: *done_swap, + invest_amount: unprocessed_amount, + }) + } + } + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap, + done_amount, + .. + } => { + if unprocessed_amount.is_zero() { + Ok(Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: *swap, + done_amount: *done_amount, + }) + } else { + Ok(Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: *swap, + done_amount: *done_amount, + invest_amount: unprocessed_amount, + }) + } + } + state => { + if unprocessed_amount.is_zero() { + Ok(state.clone()) + } else { + Err(DispatchError::Other( + "Invalid invest state when transitioning epoch execution", + )) + } + } + } + } +} diff --git a/pallets/foreign-investments/src/impls/mod.rs b/pallets/foreign-investments/src/impls/mod.rs new file mode 100644 index 0000000000..4b82327e70 --- /dev/null +++ b/pallets/foreign-investments/src/impls/mod.rs @@ -0,0 +1,1199 @@ +// Copyright 2021 Centrifuge Foundation (centrifuge.io). +// This file is part of Centrifuge Chain project. + +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). + +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +#![allow(clippy::map_identity)] + +use cfg_traits::{ + investments::{ForeignInvestment, Investment, InvestmentCollector, TrancheCurrency}, + IdentityCurrencyConversion, PoolInspect, StatusNotificationHook, TokenSwaps, +}; +use cfg_types::investments::{ + CollectedAmount, ExecutedForeignCollectInvest, ExecutedForeignCollectRedeem, + ExecutedForeignDecreaseInvest, Swap, +}; +use frame_support::{ensure, traits::Get, transactional}; +use sp_runtime::{ + traits::{EnsureAdd, EnsureAddAssign, EnsureSub, Zero}, + DispatchError, DispatchResult, +}; + +use crate::{ + errors::{InvestError, RedeemError}, + types::{InvestState, InvestTransition, RedeemState, RedeemTransition, TokenSwapReason}, + CollectedInvestment, CollectedRedemption, Config, Error, Event, ForeignInvestmentInfo, + ForeignInvestmentInfoOf, InvestmentState, Pallet, RedemptionPayoutCurrency, RedemptionState, + SwapOf, TokenSwapOrderIds, +}; + +mod invest; +mod redeem; + +impl ForeignInvestment for Pallet { + type Amount = T::Balance; + type CollectInvestResult = ExecutedForeignCollectInvest; + type CurrencyId = T::CurrencyId; + type Error = DispatchError; + type InvestmentId = T::InvestmentId; + + #[transactional] + fn increase_foreign_investment( + who: &T::AccountId, + investment_id: T::InvestmentId, + amount: T::Balance, + foreign_currency: T::CurrencyId, + pool_currency: T::CurrencyId, + ) -> Result<(), DispatchError> { + ensure!( + !T::Investment::investment_requires_collect(who, investment_id), + Error::::InvestError(InvestError::CollectRequired) + ); + let amount_pool_denominated = + T::CurrencyConverter::stable_to_stable(pool_currency, foreign_currency, amount)?; + let pre_state = InvestmentState::::get(who, investment_id); + let post_state = pre_state + .transition(InvestTransition::IncreaseInvestOrder(Swap { + currency_in: pool_currency, + currency_out: foreign_currency, + amount: amount_pool_denominated, + })) + .map_err(|e| { + // Inner error holds finer granularity but should never occur + log::debug!("InvestState transition error: {:?}", e); + Error::::from(InvestError::IncreaseTransition) + })?; + Pallet::::apply_invest_state_transition(who, investment_id, post_state, true)?; + Ok(()) + } + + #[transactional] + fn decrease_foreign_investment( + who: &T::AccountId, + investment_id: T::InvestmentId, + amount: T::Balance, + foreign_currency: T::CurrencyId, + pool_currency: T::CurrencyId, + ) -> Result<(), DispatchError> { + ensure!( + !T::Investment::investment_requires_collect(who, investment_id), + Error::::InvestError(InvestError::CollectRequired) + ); + + let pre_state = InvestmentState::::get(who, investment_id); + ensure!( + pre_state.get_investing_amount() >= amount, + Error::::InvestError(InvestError::DecreaseAmountOverflow) + ); + + let post_state = pre_state + .transition(InvestTransition::DecreaseInvestOrder(Swap { + currency_in: foreign_currency, + currency_out: pool_currency, + amount, + })) + .map_err(|e| { + // Inner error holds finer granularity but should never occur + log::debug!("InvestState transition error: {:?}", e); + Error::::from(InvestError::DecreaseTransition) + })?; + Pallet::::apply_invest_state_transition(who, investment_id, post_state, true)?; + + Ok(()) + } + + #[transactional] + fn increase_foreign_redemption( + who: &T::AccountId, + investment_id: T::InvestmentId, + amount: T::Balance, + payout_currency: T::CurrencyId, + ) -> Result<(), DispatchError> { + let currency_matches = + RedemptionPayoutCurrency::::mutate(who, investment_id, |maybe_currency| { + if let Some(currency) = maybe_currency { + currency == &payout_currency + } else { + *maybe_currency = Some(payout_currency); + true + } + }); + ensure!( + currency_matches, + Error::::InvalidRedemptionPayoutCurrency + ); + ensure!( + !T::Investment::redemption_requires_collect(who, investment_id), + Error::::RedeemError(RedeemError::CollectRequired) + ); + + let pre_state = RedemptionState::::get(who, investment_id); + let post_state = pre_state + .transition(RedeemTransition::IncreaseRedeemOrder(amount)) + .map_err(|e| { + // Inner error holds finer granularity but should never occur + log::debug!("RedeemState transition error: {:?}", e); + Error::::from(RedeemError::IncreaseTransition) + })?; + Pallet::::apply_redeem_state_transition(who, investment_id, post_state)?; + + Ok(()) + } + + #[transactional] + fn decrease_foreign_redemption( + who: &T::AccountId, + investment_id: T::InvestmentId, + amount: T::Balance, + payout_currency: T::CurrencyId, + ) -> Result<(T::Balance, T::Balance), DispatchError> { + ensure!( + RedemptionPayoutCurrency::::get(who, investment_id) + .map(|currency| currency == payout_currency) + .unwrap_or_else(|| { + log::debug!("Redemption payout currency missing when calling decrease. Should never occur if redemption has been increased beforehand"); + false + }), + Error::::InvalidRedemptionPayoutCurrency + ); + ensure!( + !T::Investment::redemption_requires_collect(who, investment_id), + Error::::RedeemError(RedeemError::CollectRequired) + ); + + let pre_state = RedemptionState::::get(who, investment_id); + let post_state = pre_state + .transition(RedeemTransition::DecreaseRedeemOrder(amount)) + .map_err(|e| { + log::debug!("RedeemState transition error: {:?}", e); + Error::::from(RedeemError::DecreaseTransition) + })?; + Pallet::::apply_redeem_state_transition(who, investment_id, post_state)?; + + let remaining_amount = T::Investment::redemption(who, investment_id)?; + + Ok((amount, remaining_amount)) + } + + #[transactional] + fn collect_foreign_investment( + who: &T::AccountId, + investment_id: T::InvestmentId, + foreign_payout_currency: T::CurrencyId, + pool_currency: T::CurrencyId, + ) -> Result, DispatchError> { + // Note: We assume the configured Investment trait to notify about the collected + // amounts via the `CollectedInvestmentHook` which handles incrementing the + // `CollectedInvestment` amount. + T::Investment::collect_investment(who.clone(), investment_id)?; + + Self::transfer_collected_investment( + who, + investment_id, + foreign_payout_currency, + pool_currency, + ) + } + + #[transactional] + fn collect_foreign_redemption( + who: &T::AccountId, + investment_id: T::InvestmentId, + foreign_payout_currency: T::CurrencyId, + pool_currency: T::CurrencyId, + ) -> Result<(), DispatchError> { + ensure!( + RedemptionPayoutCurrency::::get(who, investment_id) + .map(|currency| currency == foreign_payout_currency) + .unwrap_or_else(|| { + log::debug!("Corruption: Redemption payout currency missing when calling decrease. Should never occur if redemption has been increased beforehand"); + false + }), + Error::::InvalidRedemptionPayoutCurrency + ); + ensure!(T::PoolInspect::currency_for(investment_id.of_pool()) + .map(|currency| currency == pool_currency) + .unwrap_or_else(|| { + log::debug!("Corruption: Failed to derive pool currency from investment id when collecting foreign redemption. Should never occur if redemption has been increased beforehand"); + false + }), + DispatchError::Corruption + ); + + // Note: We assume the configured Investment trait to notify about the collected + // amounts via the `CollectedRedemptionHook` which handles incrementing the + // `CollectedRedemption` amount. + T::Investment::collect_redemption(who.clone(), investment_id)?; + + Ok(()) + } + + fn investment( + who: &T::AccountId, + investment_id: T::InvestmentId, + ) -> Result { + T::Investment::investment(who, investment_id) + } + + fn redemption( + who: &T::AccountId, + investment_id: T::InvestmentId, + ) -> Result { + T::Investment::redemption(who, investment_id) + } + + fn accepted_payment_currency(investment_id: T::InvestmentId, currency: T::CurrencyId) -> bool { + if T::Investment::accepted_payment_currency(investment_id, currency) { + true + } else { + T::PoolInspect::currency_for(investment_id.of_pool()) + .map(|pool_currency| T::TokenSwaps::valid_pair(pool_currency, currency)) + .unwrap_or(false) + } + } + + fn accepted_payout_currency(investment_id: T::InvestmentId, currency: T::CurrencyId) -> bool { + if T::Investment::accepted_payout_currency(investment_id, currency) { + true + } else { + T::PoolInspect::currency_for(investment_id.of_pool()) + .map(|pool_currency| T::TokenSwaps::valid_pair(currency, pool_currency)) + .unwrap_or(false) + } + } +} + +impl Pallet { + /// Applies in-memory transitions of `InvestState` to chain storage. Always + /// updates/removes `InvestmentState` and the current investment. Depending + /// on the state, also kills/updates the current token swap order as well as + /// notifies `ExecutedDecreasedHook`. + /// + /// The following execution order must not be changed: + /// + /// 1. If the `InvestState` includes `SwapIntoForeignDone` without + /// `ActiveSwapIntoForeignCurrency`: Prepare "executed decrease" hook & + /// transition state into its form without `SwapIntoForeignDone`. If the + /// state is just `SwapIntoForeignDone`, kill it. + /// + /// 2. Update the `InvestmentState` storage. This step is required as the + /// next step reads this storage entry. + /// + /// 3. Handle the token swap order by either creating, updating or killing + /// it. Depending on the current swap order and the previous and current + /// reason to update it, both the current `InvestmentState` as well as + /// `RedemptionState` might require an update. + /// + /// 4. If the token swap handling resulted in a new `InvestState`, update + /// `InvestmentState` again. Additionally, emit `ForeignInvestmentUpdate` or + /// `ForeignInvestmentCleared`. + /// + /// 5. If the token swap handling resulted in a new `RedeemState`, update + /// `RedemptionState` again. If the result includes `SwapIntoForeignDone` + /// without `ActiveSwapIntoForeignCurrency`, remove the + /// `SwapIntoForeignDone` part or kill it. Additionally, emit + /// `ForeignRedemptionUpdate` or `ForeignRedemptionCleared`. + /// + /// 6. Update the investment. This also includes setting it to zero. We + /// assume the impl of `::Investment` handles this case. + /// + /// 7. If "executed decrease" happened, send notification. + /// + /// NOTES: + /// * Must be called after transitioning any `InvestState` via + /// `transition` to update the chain storage. + /// * When updating token swap orders, only `handle_swap_order` should + /// be called. + #[transactional] + pub(crate) fn apply_invest_state_transition( + who: &T::AccountId, + investment_id: T::InvestmentId, + state: InvestState, + update_swap_order: bool, + ) -> DispatchResult { + // Must not send executed decrease notification before updating redemption + let mut maybe_executed_decrease: Option<(T::CurrencyId, T::Balance)> = None; + // Do first round of updates and forward state, swap as well as invest amount + + match state { + InvestState::NoState => { + InvestmentState::::remove(who, investment_id); + + Ok((InvestState::NoState, None, Zero::zero())) + }, + InvestState::InvestmentOngoing { invest_amount } => { + InvestmentState::::insert(who, investment_id, state.clone()); + + Ok((state, None, invest_amount)) + }, + InvestState::ActiveSwapIntoPoolCurrency { swap } | + InvestState::ActiveSwapIntoForeignCurrency { swap } | + // We don't care about `done_amount` until swap into foreign is fulfilled + InvestState::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { swap, .. } => { + InvestmentState::::insert(who, investment_id, state.clone()); + Ok((state, Some(swap), Zero::zero())) + }, + InvestState::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { swap, invest_amount } | + InvestState::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { swap, invest_amount } | + // We don't care about `done_amount` until swap into foreign is fulfilled + InvestState::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { swap,invest_amount, .. } => { + InvestmentState::::insert(who, investment_id, state.clone()); + Ok((state, Some(swap), invest_amount)) + }, + InvestState::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDone { swap, done_amount } => { + maybe_executed_decrease = Some((swap.currency_out, done_amount)); + + let new_state = InvestState::ActiveSwapIntoPoolCurrency { swap }; + InvestmentState::::insert(who, investment_id, new_state.clone()); + + Ok((new_state, Some(swap), Zero::zero())) + }, + InvestState::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { swap, done_amount, invest_amount } => { + maybe_executed_decrease = Some((swap.currency_out, done_amount)); + + let new_state = InvestState::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { swap, invest_amount }; + InvestmentState::::insert(who, investment_id, new_state.clone()); + + Ok((new_state, Some(swap), invest_amount)) + }, + InvestState::SwapIntoForeignDone { done_swap } => { + maybe_executed_decrease = Some((done_swap.currency_in, done_swap.amount)); + + InvestmentState::::remove(who, investment_id); + + Ok((InvestState::NoState, None, Zero::zero())) + }, + InvestState::SwapIntoForeignDoneAndInvestmentOngoing { done_swap, invest_amount } => { + maybe_executed_decrease = Some((done_swap.currency_in, done_swap.amount)); + + let new_state = InvestState::InvestmentOngoing { invest_amount }; + InvestmentState::::insert(who, investment_id, new_state.clone()); + + Ok((new_state, None, invest_amount)) + }, + } + .map(|(invest_state, maybe_swap, invest_amount)| { + // Must update investment amount before handling swap as in case of decrease, + // updating the swap transfers the currency from the investment account to the + // investor which is required for placing the swap order + if T::Investment::investment(who, investment_id)? != invest_amount { + T::Investment::update_investment(who, investment_id, invest_amount)?; + } + + // No need to handle swap order, if redeem state transition is applied afterwards + let final_invest_state = if update_swap_order { + Self::handle_swap_order(who, investment_id, maybe_swap, TokenSwapReason::Investment).map(|(maybe_invest_state, maybe_redeem_state)| { + Self::deposit_redemption_event(who, investment_id, maybe_redeem_state); + maybe_invest_state.unwrap_or(invest_state) + })? + } else { + invest_state + }; + Self::deposit_investment_event(who, investment_id, Some(final_invest_state)); + + // Send notification after updating invest as else funds are still locked in investment account + if let Some((foreign_currency, decreased_amount)) = maybe_executed_decrease { + Self::notify_executed_decrease_invest(who, investment_id, foreign_currency, decreased_amount)?; + } + + Ok(()) + }) + .map_err(|e: DispatchError| e)? + } + + /// Applies in-memory transitions of `RedeemState` to chain storage. Always + /// updates/removes `RedemptionState` and the current redemption. Depending + /// on the state, also kills/updates the current token swap order. + /// + /// The following execution order must not be changed: + /// + /// 1. If the `RedeemState` includes `SwapIntoForeignDone` without + /// `ActiveSwapIntoForeignCurrency`, remove the `SwapIntoForeignDone` part + /// or kill it. + /// + /// 2. Update the `RedemptionState` storage. This step is required as the + /// next step reads this storage entry. + /// + /// 3. Handle the token swap order by either creating, updating or killing + /// it. Depending on the current swap order and the previous and current + /// reason to update it, both the current `RedemptionState` as well as + /// `RedemptionState` might require an update. + /// + /// 4. If the token swap handling resulted in a new `RedeemState`, update + /// `RedemptionState` again. If the result includes `SwapIntoForeignDone` + /// without `ActiveSwapIntoForeignCurrency`, remove the + /// `SwapIntoForeignDone` part or kill it. Additionally, emit + /// `ForeignRedemptionUpdate` or `ForeignRedemptionCleared`. + /// + /// 5. If the token swap handling resulted in a new `InvestState`, + /// update `InvestmentState`. Additionally, emit `ForeignInvestmentUpdate` + /// or `ForeignInvestmentCleared`. + /// + /// 6. Update the redemption. This also includes setting it to zero. We + /// assume the impl of `::Investment` handles this case. + /// + /// NOTES: + /// * Must be called after transitioning g any `RedeemState` via + /// `transition` to update the chain storage. + /// * When updating token swap orders, only `handle_swap_order` should + /// be called. + #[transactional] + pub(crate) fn apply_redeem_state_transition( + who: &T::AccountId, + investment_id: T::InvestmentId, + state: RedeemState, + ) -> DispatchResult { + let redeeming_amount = state.get_redeeming_amount(); + + // Do first round of updates and forward state as well as swap + match state { + RedeemState::NoState => { + RedemptionState::::remove(who, investment_id); + Ok((Some(RedeemState::NoState), None)) + } + RedeemState::Redeeming { .. } => { + RedemptionState::::insert(who, investment_id, state); + Ok((Some(state), None)) + } + RedeemState::RedeemingAndActiveSwapIntoForeignCurrency { swap, .. } + | RedeemState::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap, + .. + } + | RedeemState::ActiveSwapIntoForeignCurrency { swap, .. } + | RedeemState::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { swap, .. } => { + RedemptionState::::insert(who, investment_id, state); + Ok((Some(state), Some(swap))) + } + // Only states left include `SwapIntoForeignDone` without + // `ActiveSwapIntoForeignCurrency` such that we can notify collect + swap_done_state => { + let maybe_new_state = + Self::apply_collect_redeem_transition(who, investment_id, swap_done_state)?; + Ok((maybe_new_state, None)) + } + } + .map(|(maybe_new_state, maybe_swap)| { + let (maybe_new_invest_state, maybe_new_state_prio) = Self::handle_swap_order( + who, + investment_id, + maybe_swap, + TokenSwapReason::Redemption, + )?; + + // Dispatch transition event, post swap state has priority if it exists as it is + // the result of the latest update + if let Some(redeem_state_post_swap) = maybe_new_state_prio { + Self::deposit_redemption_event(who, investment_id, Some(redeem_state_post_swap)); + } else { + Self::deposit_redemption_event(who, investment_id, maybe_new_state); + } + Self::deposit_investment_event(who, investment_id, maybe_new_invest_state); + + if T::Investment::redemption(who, investment_id)? != redeeming_amount { + // Finally, update redemption after all states have been updated + T::Investment::update_redemption(who, investment_id, redeeming_amount)?; + } + + Ok(()) + }) + .map_err(|e: DispatchError| e)? + } + + /// Emits an event indicating the corresponding `InvestState` was either + /// updated or cleared. + /// + /// NOTE: Noop if the provided state is `None`. + fn deposit_investment_event( + who: &T::AccountId, + investment_id: T::InvestmentId, + maybe_state: Option>, + ) { + match maybe_state { + Some(state) if state == InvestState::NoState => { + Self::deposit_event(Event::::ForeignInvestmentCleared { + investor: who.clone(), + investment_id, + }) + } + Some(state) => Self::deposit_event(Event::::ForeignInvestmentUpdated { + investor: who.clone(), + investment_id, + state, + }), + _ => {} + } + } + + /// Emits an event indicating the corresponding `InvestState` was either + /// updated or cleared. + /// + /// NOTE: Noop if the provided state is `None`. + fn deposit_redemption_event( + who: &T::AccountId, + investment_id: T::InvestmentId, + maybe_state: Option>, + ) { + match maybe_state { + Some(state) if state == RedeemState::NoState => { + Self::deposit_event(Event::::ForeignRedemptionCleared { + investor: who.clone(), + investment_id, + }) + } + Some(state) => Self::deposit_event(Event::::ForeignRedemptionUpdated { + investor: who.clone(), + investment_id, + state, + }), + None => {} + } + } + + /// Terminates a redeem collection which required swapping into foreign + /// currency. + /// + /// Only acts upon redeem states which include `SwapIntoForeignDone` + /// without `ActiveSwapIntoForeignCurrency`. Other states are ignored. + /// Either updates the corresponding `RedemptionState` or drops it entirely. + /// + /// Emits `notify_executed_collect_redeem`. + /// + /// Returning... + /// * `Some(RedeemState::NoState)` indicates a `ForeignRedemptionCleared` + /// event can be deposited + /// * `Some(state)` indicates a `ForeignRedemptionUpdated` event can be + /// deposited + /// * `None` indicates no state mutation occurred + #[allow(clippy::type_complexity)] + #[transactional] + fn apply_collect_redeem_transition( + who: &T::AccountId, + investment_id: T::InvestmentId, + state: RedeemState, + ) -> Result>, DispatchError> { + let CollectedAmount:: { + amount_payment: amount_payment_tranche_tokens, + .. + } = CollectedRedemption::::get(who, investment_id); + + // Send notification and kill `CollectedRedemptionTrancheTokens` iff the state + // includes `SwapIntoForeignDone` without `ActiveSwapIntoForeignCurrency` + match state { + RedeemState::SwapIntoForeignDone { done_swap, .. } + | RedeemState::RedeemingAndSwapIntoForeignDone { done_swap, .. } => { + Self::notify_executed_collect_redeem( + who, + investment_id, + done_swap.currency_in, + CollectedAmount { + amount_collected: done_swap.amount, + amount_payment: amount_payment_tranche_tokens, + }, + )?; + CollectedRedemption::::remove(who, investment_id); + Ok(()) + } + _ => Ok(()), + } + .map_err(|e: DispatchError| e)?; + + // Update state iff the state includes `SwapIntoForeignDone` without + // `ActiveSwapIntoForeignCurrency` + match state { + RedeemState::SwapIntoForeignDone { .. } => { + RedemptionState::::remove(who, investment_id); + Ok(Some(RedeemState::NoState)) + } + RedeemState::RedeemingAndSwapIntoForeignDone { redeem_amount, .. } => { + let new_state = RedeemState::Redeeming { redeem_amount }; + RedemptionState::::insert(who, investment_id, new_state); + Ok(Some(new_state)) + } + _ => Ok(None), + } + } + + /// Updates or kills a token swap order. If the final swap amount is zero, + /// kills the swap order and all associated storage. Else, creates or + /// updates an existing swap order. + /// + /// If the provided reason does not match the latest one stored in + /// `ForeignInvestmentInfo`, also resolves the _merge conflict_ resulting + /// from updating and thus overwriting opposite swaps. See + /// [Self::handle_concurrent_swap_orders] for details. If this results in + /// either an altered invest state and/or an altered redeem state, the + /// corresponding storage is updated and the new states returned. The latter + /// is required for emitting events. + /// + /// NOTE: Must not call any other swap order updating function. + #[allow(clippy::type_complexity)] + #[transactional] + fn handle_swap_order( + who: &T::AccountId, + investment_id: T::InvestmentId, + maybe_swap: Option>, + reason: TokenSwapReason, + ) -> Result< + ( + Option>, + Option>, + ), + DispatchError, + > { + // check for concurrent conflicting swap orders + if TokenSwapOrderIds::::get(who, investment_id).is_some() { + let (maybe_updated_swap, maybe_invest_state, maybe_redeem_state, swap_reason) = + Self::handle_concurrent_swap_orders(who, investment_id)?; + + // Update or kill swap order with updated order having priority in case it was + // overwritten + if let Some(swap_order) = maybe_updated_swap { + Self::place_swap_order(who, investment_id, swap_order, swap_reason)?; + } else { + Self::kill_swap_order(who, investment_id)?; + } + + // Update invest state and kill if NoState + InvestmentState::::mutate_exists(who, investment_id, |current_invest_state| { + match &maybe_invest_state { + Some(state) if state != &InvestState::NoState => { + *current_invest_state = Some(state.clone()); + } + Some(state) if state == &InvestState::NoState => { + *current_invest_state = None; + } + _ => (), + } + }); + + // Need to check if `SwapReturnDone` is part of state without + // `ActiveSwapIntoForeignCurrency` as this implies the successful termination of + // a collect (with swap into foreign currency). If this is the case, the + // returned redeem state needs to be updated or killed as well. + let returning_redeem_state = Self::apply_collect_redeem_transition( + who, + investment_id, + maybe_redeem_state.unwrap_or_default(), + )? + .map(Some) + .unwrap_or(maybe_redeem_state) + .map(|redeem_state| { + RedemptionState::::mutate_exists(who, investment_id, |current_redeem_state| { + if redeem_state != RedeemState::NoState { + *current_redeem_state = Some(redeem_state); + } else { + *current_redeem_state = None; + } + }); + redeem_state + }); + + Ok((maybe_invest_state, returning_redeem_state)) + } + // Update to provided value, if not none + else if let Some(swap_order) = maybe_swap { + Self::place_swap_order(who, investment_id, swap_order, Some(reason))?; + Ok((None, None)) + } else { + Ok((None, None)) + } + } + + /// Kills all storage associated with token swaps and cancels the + /// potentially active swap order. + #[transactional] + fn kill_swap_order(who: &T::AccountId, investment_id: T::InvestmentId) -> DispatchResult { + if let Some(swap_order_id) = TokenSwapOrderIds::::take(who, investment_id) { + if T::TokenSwaps::is_active(swap_order_id) { + T::TokenSwaps::cancel_order(swap_order_id)?; + } + ForeignInvestmentInfo::::remove(swap_order_id); + } + Ok(()) + } + + /// Sets up `TokenSwapOrderIds` and `ForeignInvestmentInfo` storages, if the + /// order does not exist yet. + /// + /// NOTE: Must only be called in `handle_swap_order`. + #[transactional] + fn place_swap_order( + who: &T::AccountId, + investment_id: T::InvestmentId, + swap: SwapOf, + reason: Option, + ) -> DispatchResult { + if swap.amount.is_zero() { + return Self::kill_swap_order(who, investment_id); + } + + // Determine whether swap order direction changed which would require the order + // to be cancelled and all associated storage to be killed + let maybe_swap_order_id = TokenSwapOrderIds::::get(who, investment_id); + let cancel_swap_order = maybe_swap_order_id + .map(|swap_order_id| { + let cancel_swap_order = T::TokenSwaps::get_order_details(swap_order_id) + .map(|swap_order| { + swap_order.currency_in != swap.currency_in + || swap_order.currency_out != swap.currency_out + }) + .unwrap_or(false); + + if cancel_swap_order { + Self::kill_swap_order(who, investment_id)?; + } + + Ok::(cancel_swap_order) + }) + .transpose()? + .unwrap_or(false); + + match maybe_swap_order_id { + // Swap order is active and matches the swap direction + Some(swap_order_id) + if T::TokenSwaps::is_active(swap_order_id) && !cancel_swap_order => + { + T::TokenSwaps::update_order( + who.clone(), + swap_order_id, + swap.amount, + // The max accepted sell rate is independent of the asset type for now + T::DefaultTokenSellRate::get(), + // The minimum fulfillment must be everything + swap.amount, + )?; + ForeignInvestmentInfo::::insert( + swap_order_id, + ForeignInvestmentInfoOf:: { + owner: who.clone(), + id: investment_id, + last_swap_reason: reason, + }, + ); + } + // Edge case: Only occurs as result of implicit collect when fulfilling a swap + // order. At this point, swap is fulfilled but not propagated to the state yet as + // collecting has to happen beforehand. + Some(swap_order_id) + if !T::TokenSwaps::is_active(swap_order_id) && !cancel_swap_order => + { + Self::kill_swap_order(who, investment_id)?; + } + // Swap order either has not existed at all or was just cancelled + _ => { + let swap_order_id = T::TokenSwaps::place_order( + who.clone(), + swap.currency_in, + swap.currency_out, + swap.amount, + // The max accepted sell rate is independent of the asset type for now + T::DefaultTokenSellRate::get(), + // The minimum fulfillment must be everything + swap.amount, + )?; + TokenSwapOrderIds::::insert(who, investment_id, swap_order_id); + ForeignInvestmentInfo::::insert( + swap_order_id, + ForeignInvestmentInfoOf:: { + owner: who.clone(), + id: investment_id, + last_swap_reason: reason, + }, + ); + } + }; + Ok(()) + } + + /// Determines the correct amount of a token swap based on the current + /// `InvestState` and `RedeemState` corresponding to the `TokenSwapOrderId`. + /// + /// Returns a tuple of the total swap order amount as well as potentially + /// altered invest and redeem states. Any returning tuple element which is + /// `None`, reflects that no change is required for this element. Else, it + /// needs to be applied to the storage. + /// + /// NOTE: Required since there exists at most one swap per `(AccountId, + /// InvestmentId)` pair whereas investments and redemptions can both mutate + /// orders. Assume, as a result of an `InvestState` transition, a token swap + /// order into pool currency is initialized. Then, as a result of a + /// `RedeemState` transition, a token swap order into foreign currency is + /// needed. This handler resolves the _merge conflict_ in situations where + /// the reason to create/update a swap order does not match the previous + /// reason. + /// + /// * Is noop, if the the current reason equals the previous one. + /// * If both states are swapping into foreign currency, i.e. their invest + /// and redeem states include `ActiveSwapIntoForeignCurrency`, the states + /// stay the same. However the total order amount needs to be updated by + /// summing up both swap order amounts. + /// * If the `InvestState` includes swapping into pool currency, i.e. + /// `ActiveSwapIntoPoolCurrency`, whereas the `RedeemState` is swapping + /// into the opposite direction, i.e. `ActiveSwapIntoForeignCurrency`, we + /// need to resolve the delta between both swap order amounts and update + /// the states accordingly. + #[allow(clippy::type_complexity)] + fn handle_concurrent_swap_orders( + who: &T::AccountId, + investment_id: T::InvestmentId, + ) -> Result< + ( + Option>, + Option>, + Option>, + Option, + ), + DispatchError, + > { + // Read states from storage and determine amounts in possible denominations + let invest_state = InvestmentState::::get(who, investment_id); + let redeem_state = RedemptionState::::get(who, investment_id); + let active_invest_swap = invest_state.get_active_swap(); + let active_redeem_swap = redeem_state.get_active_swap(); + + // Exit early if neither or only a single swap is active such that no merging is + // necessary + if active_invest_swap.is_none() && active_redeem_swap.is_none() { + return Ok((None, None, None, None)); + } else if active_invest_swap.is_none() { + return Ok(( + active_redeem_swap, + None, + Some(redeem_state), + Some(TokenSwapReason::Redemption), + )); + } else if active_redeem_swap.is_none() { + return Ok(( + active_invest_swap, + Some(invest_state), + None, + Some(TokenSwapReason::Investment), + )); + } + + let invest_swap_amount_pool_deno = + invest_state.get_active_swap_amount_pool_denominated()?; + let invest_swap_amount_foreign_deno = + invest_state.get_active_swap_amount_foreign_denominated()?; + let (redeem_swap_amount_foreign_deno, redeem_swap_amount_pool_deno) = redeem_state + .get_active_swap() + .map(|swap| { + // Redemptions can only swap into foreign + let amount_pool_denominated = T::CurrencyConverter::stable_to_stable( + swap.currency_out, + swap.currency_in, + swap.amount, + )?; + Ok::<(T::Balance, T::Balance), DispatchError>(( + swap.amount, + amount_pool_denominated, + )) + }) + .transpose()? + .unwrap_or_default(); + let resolved_amount_pool_deno = + invest_swap_amount_pool_deno.min(redeem_swap_amount_pool_deno); + let swap_amount_opposite_direction_pool_deno = invest_swap_amount_pool_deno + .max(redeem_swap_amount_pool_deno) + .ensure_sub(resolved_amount_pool_deno)?; + let swap_amount_opposite_direction_foreign_deno = invest_swap_amount_foreign_deno + .max(redeem_swap_amount_foreign_deno) + .ensure_sub(invest_swap_amount_foreign_deno.min(redeem_swap_amount_foreign_deno))?; + + let (maybe_token_swap, maybe_new_invest_state, maybe_new_redeem_state, swap_reason) = + match (active_invest_swap, active_redeem_swap) { + // same swap direction + (Some(invest_swap), Some(redeem_swap)) + if invest_swap.currency_in == redeem_swap.currency_in => + { + invest_swap.ensure_currencies_match(&redeem_swap, true)?; + let token_swap = Swap { + amount: invest_swap.amount.ensure_add(redeem_swap.amount)?, + ..invest_swap + }; + Ok(( + Some(token_swap), + None, + None, + Some(TokenSwapReason::InvestmentAndRedemption), + )) + } + // opposite swap direction + (Some(invest_swap), Some(redeem_swap)) + if invest_swap.currency_in == redeem_swap.currency_out => + { + invest_swap.ensure_currencies_match(&redeem_swap, false)?; + let new_redeem_state = redeem_state.fulfill_active_swap_amount( + redeem_swap_amount_foreign_deno.min(invest_swap_amount_foreign_deno), + )?; + + let new_invest_state = match invest_state.clone() { + InvestState::ActiveSwapIntoPoolCurrency { swap: pool_swap } + | InvestState::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + swap: pool_swap, + .. + } => { + let new_pool_swap = Swap { + amount: pool_swap.amount.ensure_sub(resolved_amount_pool_deno)?, + ..pool_swap + }; + let new_invest_amount = invest_state + .get_investing_amount() + .ensure_add(resolved_amount_pool_deno)?; + + if pool_swap.amount == resolved_amount_pool_deno { + Ok(InvestState::InvestmentOngoing { + invest_amount: new_invest_amount, + }) + } else { + Ok( + InvestState::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + invest_amount: new_invest_amount, + swap: new_pool_swap, + }, + ) + } + } + state => Ok(state), + } + .map_err(|e: DispatchError| e)?; + + if invest_swap_amount_foreign_deno > redeem_swap_amount_foreign_deno { + let swap = Swap { + amount: swap_amount_opposite_direction_pool_deno, + ..invest_swap + }; + Ok(( + Some(swap), + Some(new_invest_state), + Some(new_redeem_state), + Some(TokenSwapReason::Investment), + )) + } else { + let swap = Swap { + amount: swap_amount_opposite_direction_foreign_deno, + ..redeem_swap + }; + Ok(( + Some(swap), + Some(new_invest_state), + Some(new_redeem_state), + Some(TokenSwapReason::Redemption), + )) + } + } + _ => Err(DispatchError::Other( + "Uncaught short circuit when merging concurrent swap orders", + )), + } + .map_err(|e: DispatchError| e)?; + + let new_invest_state = match maybe_new_invest_state { + Some(state) if state == invest_state => None, + state => state, + }; + let new_redeem_state = match maybe_new_redeem_state { + Some(state) if state == redeem_state => None, + state => state, + }; + + Ok(( + maybe_token_swap, + new_invest_state, + new_redeem_state, + swap_reason, + )) + } + + /// Increments the collected investment amount and transitions investment + /// state as a result of collecting the investment. + /// + /// NOTE: Does not transfer back the collected tranche tokens. This happens + /// in `transfer_collected_investment`. + pub(crate) fn denote_collected_investment( + who: &T::AccountId, + investment_id: T::InvestmentId, + collected: CollectedAmount, + ) -> DispatchResult { + // Increment by previously stored amounts (via `CollectedInvestmentHook`) + CollectedInvestment::::mutate(who, investment_id, |collected_before| { + collected_before + .amount_collected + .ensure_add_assign(collected.amount_collected)?; + collected_before + .amount_payment + .ensure_add_assign(collected.amount_payment)?; + Ok::<(), DispatchError>(()) + })?; + + // Update invest state to decrease the unprocessed investing amount + let investing_amount = T::Investment::investment(who, investment_id)?; + let pre_state = InvestmentState::::get(who, investment_id); + let post_state = + pre_state.transition(InvestTransition::CollectInvestment(investing_amount))?; + Self::apply_invest_state_transition(who, investment_id, post_state, true).map_err(|e| { + log::debug!("InvestState transition error: {:?}", e); + Error::::from(InvestError::CollectTransition) + })?; + + Ok(()) + } + + /// Consumes the `CollectedInvestment` amounts and returns these. + /// + /// NOTE: Converts the collected pool currency payment amount to foreign + /// currency via the `CurrencyConverter` trait. + pub(crate) fn transfer_collected_investment( + who: &T::AccountId, + investment_id: T::InvestmentId, + foreign_payout_currency: T::CurrencyId, + pool_currency: T::CurrencyId, + ) -> Result, DispatchError> { + let collected = CollectedInvestment::::take(who, investment_id); + + // Determine payout and remaining amounts in foreign currency instead of current + // pool currency denomination + let amount_currency_payout = T::CurrencyConverter::stable_to_stable( + foreign_payout_currency, + pool_currency, + collected.amount_payment, + )?; + let remaining_amount_pool_denominated = T::Investment::investment(who, investment_id)?; + let amount_remaining_invest_foreign_denominated = T::CurrencyConverter::stable_to_stable( + foreign_payout_currency, + pool_currency, + remaining_amount_pool_denominated, + )?; + + Ok(ExecutedForeignCollectInvest { + amount_currency_payout, + amount_tranche_tokens_payout: collected.amount_collected, + amount_remaining_invest: amount_remaining_invest_foreign_denominated, + }) + } + + /// Increments the collected redemption amount and transitions redemption + /// state as a result of collecting the redemption. + /// + /// NOTE: Neither initiates a swap from the collected pool currency into + /// foreign currency nor transfers back any currency to the investor. This + /// happens in `transfer_collected_redemption`. + pub(crate) fn denote_collected_redemption( + who: &T::AccountId, + investment_id: T::InvestmentId, + collected: CollectedAmount, + ) -> DispatchResult { + let foreign_payout_currency = RedemptionPayoutCurrency::::get(who, investment_id) + .ok_or(Error::::RedeemError( + RedeemError::CollectPayoutCurrencyNotFound, + ))?; + let pool_currency = T::PoolInspect::currency_for(investment_id.of_pool()) + .expect("Impossible to collect redemption for non existing pool at this point"); + + // Increment by previously stored amounts (via `CollectedInvestmentHook`) + CollectedRedemption::::mutate(who, investment_id, |old| { + old.amount_collected + .ensure_add_assign(collected.amount_collected)?; + old.amount_payment + .ensure_add_assign(collected.amount_payment)?; + Ok::<(), DispatchError>(()) + })?; + + // Transition state to initiate swap from pool to foreign currency + let pre_state = RedemptionState::::get(who, investment_id); + let amount_unprocessed_redemption = T::Investment::redemption(who, investment_id)?; + // Amount needs to be denominated in foreign currency as it will be swapped into + // foreign currency such that the swap order amount is in the incoming currency + let amount_collected_foreign_denominated = T::CurrencyConverter::stable_to_stable( + foreign_payout_currency, + pool_currency, + collected.amount_collected, + )?; + let post_state = pre_state + .transition(RedeemTransition::CollectRedemption( + amount_unprocessed_redemption, + SwapOf:: { + amount: amount_collected_foreign_denominated, + currency_in: foreign_payout_currency, + currency_out: pool_currency, + }, + )) + .map_err(|e| { + // Inner error holds finer granularity but should never occur + log::debug!("RedeemState transition error: {:?}", e); + Error::::from(RedeemError::CollectTransition) + })?; + + Pallet::::apply_redeem_state_transition(who, investment_id, post_state)?; + + Ok(()) + } + + /// Sends `DecreasedForeignInvestOrderHook` notification such that any + /// potential consumer could act upon that, e.g. Liquidity Pools for + /// `ExecutedDecreaseInvestOrder`. + #[transactional] + pub(crate) fn notify_executed_decrease_invest( + who: &T::AccountId, + investment_id: T::InvestmentId, + foreign_currency: T::CurrencyId, + amount_decreased: T::Balance, + ) -> DispatchResult { + let pool_currency = T::PoolInspect::currency_for(investment_id.of_pool()) + .expect("Pool must exist if decrease was executed; qed."); + let amount_remaining_pool_denominated = T::Investment::investment(who, investment_id)?; + let amount_remaining_foreign_denominated = T::CurrencyConverter::stable_to_stable( + foreign_currency, + pool_currency, + amount_remaining_pool_denominated, + )?; + + T::DecreasedForeignInvestOrderHook::notify_status_change( + cfg_types::investments::ForeignInvestmentInfo:: { + owner: who.clone(), + id: investment_id, + // not relevant here + last_swap_reason: None, + }, + ExecutedForeignDecreaseInvest { + amount_decreased, + foreign_currency, + amount_remaining: amount_remaining_foreign_denominated, + }, + ) + } + + /// Sends `CollectedForeignRedemptionHook` notification such that any + /// potential consumer could act upon that, e.g. Liquidity Pools for + /// `ExecutedCollectRedeemOrder`. + #[transactional] + pub(crate) fn notify_executed_collect_redeem( + who: &T::AccountId, + investment_id: T::InvestmentId, + currency: T::CurrencyId, + collected: CollectedAmount, + ) -> DispatchResult { + T::CollectedForeignRedemptionHook::notify_status_change( + cfg_types::investments::ForeignInvestmentInfo:: { + owner: who.clone(), + id: investment_id, + // not relevant here + last_swap_reason: None, + }, + ExecutedForeignCollectRedeem { + currency, + amount_currency_payout: collected.amount_collected, + amount_tranche_tokens_payout: collected.amount_payment, + amount_remaining_redeem: T::Investment::redemption(who, investment_id)?, + }, + ) + } +} diff --git a/pallets/foreign-investments/src/impls/redeem.rs b/pallets/foreign-investments/src/impls/redeem.rs new file mode 100644 index 0000000000..8c7fc9e6d2 --- /dev/null +++ b/pallets/foreign-investments/src/impls/redeem.rs @@ -0,0 +1,586 @@ +// Copyright 2021 Centrifuge Foundation (centrifuge.io). +// This file is part of Centrifuge Chain project. + +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). + +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +use cfg_types::investments::Swap; +use frame_support::{dispatch::fmt::Debug, ensure}; +use sp_runtime::{ + traits::{EnsureAdd, EnsureSub}, + DispatchError, +}; + +use crate::types::{RedeemState, RedeemTransition}; + +impl RedeemState +where + Balance: Clone + Copy + EnsureAdd + EnsureSub + Ord + Debug, + Currency: Clone + Copy + PartialEq + Debug, +{ + /// Solely apply state machine to transition one `RedeemState` into another + /// based on the transition, see + /// + /// NOTE: MUST call `apply_redeem_state_transition` on the post state to + /// actually mutate storage. + pub fn transition( + &self, + transition: RedeemTransition, + ) -> Result { + match transition { + RedeemTransition::IncreaseRedeemOrder(amount) => Self::handle_increase(self, amount), + RedeemTransition::DecreaseRedeemOrder(amount) => Self::handle_decrease(self, amount), + RedeemTransition::FulfillSwapOrder(swap) => { + Self::handle_fulfilled_swap_order(self, swap) + } + RedeemTransition::CollectRedemption(amount_redeeming, swap) => { + Self::handle_collect(self, amount_redeeming, swap) + } + } + } + + /// Returns the potentially existing active swap into foreign currency: + /// * If the state includes `ActiveSwapIntoForeignCurrency`, it returns the + /// corresponding `Some(swap)`. + /// * Else, it returns `None`. + pub(crate) fn get_active_swap(&self) -> Option> { + match *self { + Self::ActiveSwapIntoForeignCurrency { swap } + | Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { swap, .. } + | Self::RedeemingAndActiveSwapIntoForeignCurrency { swap, .. } + | Self::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap, .. + } => Some(swap), + _ => None, + } + } + + /// Returns the redeeming amount if existent. Else returns zero. + pub(crate) fn get_redeeming_amount(&self) -> Balance { + match *self { + Self::Redeeming { redeem_amount } + | Self::RedeemingAndActiveSwapIntoForeignCurrency { redeem_amount, .. } + | Self::RedeemingAndSwapIntoForeignDone { redeem_amount, .. } + | Self::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + redeem_amount, + .. + } => redeem_amount, + _ => Balance::zero(), + } + } + + /// Either adds a non existing redeeming amount to the state or overwrites + /// it. + /// * If the value is not zero and the state involves `Redeeming`: Sets the + /// amount. + /// * Else if the value is not zero and the state does not involve + /// `Redeeming`: Adds `Redeeming` to the state with the corresponding + /// amount. + /// * If the value is zero and the state includes `Redeeming`: Removes + /// `Redeeming` from the state. + /// * Else throws. + fn set_redeem_amount(&self, amount: Balance) -> Result { + if amount.is_zero() { + return Self::remove_redeem_amount(self); + } + match *self { + Self::NoState | Self::Redeeming { .. } => Ok(Self::Redeeming { + redeem_amount: amount, + }), + Self::RedeemingAndActiveSwapIntoForeignCurrency { swap, .. } => { + Ok(Self::RedeemingAndActiveSwapIntoForeignCurrency { + redeem_amount: amount, + swap, + }) + } + Self::RedeemingAndSwapIntoForeignDone { done_swap, .. } => { + Ok(Self::RedeemingAndSwapIntoForeignDone { + redeem_amount: amount, + done_swap, + }) + } + Self::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap, + done_amount, + .. + } => Ok( + Self::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + redeem_amount: amount, + swap, + done_amount, + }, + ), + Self::ActiveSwapIntoForeignCurrency { swap } => { + Ok(Self::RedeemingAndActiveSwapIntoForeignCurrency { + swap, + redeem_amount: amount, + }) + } + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { swap, done_amount } => Ok( + Self::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap, + done_amount, + redeem_amount: amount, + }, + ), + Self::SwapIntoForeignDone { done_swap } => Ok(Self::RedeemingAndSwapIntoForeignDone { + done_swap, + redeem_amount: amount, + }), + } + } + + /// Removes `Redeeming` from the state. + fn remove_redeem_amount(&self) -> Result { + match *self { + Self::Redeeming { .. } => Ok(Self::NoState), + Self::RedeemingAndActiveSwapIntoForeignCurrency { swap, .. } => { + Ok(Self::ActiveSwapIntoForeignCurrency { swap }) + } + Self::RedeemingAndSwapIntoForeignDone { done_swap, .. } => { + Ok(Self::SwapIntoForeignDone { done_swap }) + } + Self::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap, + done_amount, + .. + } => Ok(Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { swap, done_amount }), + // Throw for states without `Redeeming` + _ => Err(DispatchError::Other( + "Cannot remove redeeming amount of redeem state which does not include `Redeeming`", + )), + } + } + + /// Reduce the amount of an active swap (into foreign currency) by the + /// provided value: + /// * Throws if there is no active swap, i.e. the state does not include + /// `ActiveSwapIntoForeignCurrency` or if the reducible amount exceeds the + /// swap amount + /// * If the provided value equals the swap amount, the state is + /// transitioned into `*AndSwapIntoForeignDone`. + /// * Else, it is transitioned into + /// `*ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone`. + pub(crate) fn fulfill_active_swap_amount( + &self, + amount: Balance, + ) -> Result { + match self { + Self::ActiveSwapIntoForeignCurrency { swap } => { + if amount == swap.amount { + Ok(Self::SwapIntoForeignDone { done_swap: *swap }) + } else { + Ok(Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: Swap { + amount: swap.amount.ensure_sub(amount)?, + ..*swap + }, + done_amount: amount, + }) + } + } + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { swap, done_amount } => { + let done_amount = done_amount.ensure_add(amount)?; + + if amount == swap.amount { + Ok(Self::SwapIntoForeignDone { + done_swap: Swap { + amount: done_amount, + ..*swap + }, + }) + } else { + Ok(Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: Swap { + amount: swap.amount.ensure_sub(amount)?, + ..*swap + }, + done_amount, + }) + } + } + Self::RedeemingAndActiveSwapIntoForeignCurrency { + redeem_amount, + swap, + } => { + if amount == swap.amount { + Ok(Self::RedeemingAndSwapIntoForeignDone { + done_swap: Swap { amount, ..*swap }, + redeem_amount: *redeem_amount, + }) + } else { + Ok( + Self::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: Swap { + amount: swap.amount.ensure_sub(amount)?, + ..*swap + }, + done_amount: amount, + redeem_amount: *redeem_amount, + }, + ) + } + } + Self::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + redeem_amount, + swap, + done_amount, + } => { + let done_amount = done_amount.ensure_add(amount)?; + + if amount == swap.amount { + Ok(Self::RedeemingAndSwapIntoForeignDone { + done_swap: Swap { + amount: done_amount, + ..*swap + }, + redeem_amount: *redeem_amount, + }) + } else { + Ok( + Self::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: Swap { + amount: swap.amount.ensure_sub(amount)?, + ..*swap + }, + done_amount, + redeem_amount: *redeem_amount, + }, + ) + } + } + _ => Err(DispatchError::Other( + "Invalid redeem state when fulfilling active swap amount", + )), + } + } + + /// Transition all states which include `ActiveSwapIntoForeignCurrency`. + /// + /// The resulting transitioned state either includes `*SwapIntoForeignDone` + /// or `*ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone`. + /// + /// Also supports non-foreign swaps, i.e. those with matching in and out + /// currency. + /// + /// Throws if the fulfilled swap direction is not into foreign currency or + /// if the amount exceeds the states active swap amount. + fn transition_fulfilled_swap_order( + &self, + fulfilled_swap: Swap, + ) -> Result { + ensure!( + self.get_active_swap() + .map(|swap| { + swap.amount >= fulfilled_swap.amount + && swap.currency_in == fulfilled_swap.currency_in + && swap.currency_out == fulfilled_swap.currency_out + }) + .unwrap_or(true), + DispatchError::Other("Invalid redeem state when transitioning fulfilled swap order") + ); + + let Swap { amount, .. } = fulfilled_swap; + + // Edge case: if currency_in matches currency_out, we can immediately fulfill + // the swap + match *self { + Self::ActiveSwapIntoForeignCurrency { swap } => { + if amount < swap.amount && swap.currency_in != swap.currency_out { + Ok(Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: Swap { + amount: swap.amount - amount, + ..swap + }, + done_amount: amount, + }) + } else { + Ok(Self::SwapIntoForeignDone { done_swap: swap }) + } + } + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { swap, done_amount } => { + let done_amount = done_amount.ensure_add(amount)?; + if amount < swap.amount && swap.currency_in != swap.currency_out { + Ok(Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: Swap { + amount: swap.amount - amount, + ..swap + }, + done_amount, + }) + } else { + Ok(Self::SwapIntoForeignDone { + done_swap: Swap { + amount: done_amount, + ..swap + }, + }) + } + } + Self::RedeemingAndActiveSwapIntoForeignCurrency { + redeem_amount, + swap, + } => { + if amount < swap.amount && swap.currency_in != swap.currency_out { + Ok( + Self::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: Swap { + amount: swap.amount - amount, + ..swap + }, + done_amount: amount, + redeem_amount, + }, + ) + } else { + Ok(Self::RedeemingAndSwapIntoForeignDone { + done_swap: swap, + redeem_amount, + }) + } + } + Self::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + redeem_amount, + swap, + done_amount, + } => { + let done_amount = done_amount.ensure_add(amount)?; + if amount < swap.amount && swap.currency_in != swap.currency_out { + Ok( + Self::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: Swap { + amount: swap.amount - amount, + ..swap + }, + done_amount, + redeem_amount, + }, + ) + } else { + Ok(Self::RedeemingAndSwapIntoForeignDone { + done_swap: Swap { + amount: done_amount, + ..swap + }, + redeem_amount, + }) + } + } + _ => Err(DispatchError::Other( + "Invalid redeem state when transitioning fulfilled swap order", + )), + } + } + + /// Either update or remove the redeeming amount and add + /// `SwapIntoForeignDone` for the provided collected swap. + fn transition_collect_non_foreign( + &self, + amount_redeeming: Balance, + collected_swap: Swap, + ) -> Result { + match *self { + Self::Redeeming { .. } => { + if amount_redeeming.is_zero() { + Ok(Self::SwapIntoForeignDone { + done_swap: collected_swap, + }) + } else { + Ok(Self::RedeemingAndSwapIntoForeignDone { + redeem_amount: amount_redeeming, + done_swap: collected_swap, + }) + } + } + Self::RedeemingAndSwapIntoForeignDone { done_swap, .. } => { + let swap = Swap { + amount: done_swap.amount.ensure_add(collected_swap.amount)?, + ..collected_swap + }; + + if amount_redeeming.is_zero() { + Ok(Self::SwapIntoForeignDone { done_swap: swap }) + } else { + Ok(Self::RedeemingAndSwapIntoForeignDone { + redeem_amount: amount_redeeming, + done_swap: swap, + }) + } + } + _ => Err(DispatchError::Other( + "Invalid pre redeem state when transitioning non-foreign collect", + )), + } + } + + /// Apply the transition of the state after collecting a redemption: + /// * Either remove or update the redeeming amount + /// * Either add or update an active swap into foreign currency (or note a + /// fulfilled swap if the in and out currencies are the same). + /// + /// Throws if any of the following holds true + /// * The current state includes an active/done swap and in and out + /// currencies do not match the provided ones + /// * The collected amount is zero but there is a mismatch between the + /// redeeming amounts (which can only be possible if something was + /// collected) + /// * The state does not include `Redeeming` + fn transition_collect( + &self, + amount_redeeming: Balance, + collected_swap: Swap, + ) -> Result { + let redeeming_amount = self.get_redeeming_amount(); + + ensure!( + self.get_active_swap() + .map(|swap| (swap.currency_in, swap.currency_out) + == (collected_swap.currency_in, collected_swap.currency_out)) + .unwrap_or(true), + DispatchError::Other("Invalid swap currencies when transitioning collect redemption") + ); + + // Nothing changed in the executed epoch + if collected_swap.amount.is_zero() { + if redeeming_amount == amount_redeeming { + return Ok(*self); + } else { + return Err(DispatchError::Other( + "Corruption: Redeeming amount changed but nothing was collected", + )); + } + } + + // Take shortcut for same currencies + if collected_swap.currency_in == collected_swap.currency_out { + return Self::transition_collect_non_foreign(self, amount_redeeming, collected_swap); + } + + // Either remove or update redeeming amount and add/update swap into foreign + // currency + match *self { + Self::Redeeming { .. } => { + if amount_redeeming.is_zero() { + Ok(Self::ActiveSwapIntoForeignCurrency { + swap: collected_swap, + }) + } else { + Ok(Self::RedeemingAndActiveSwapIntoForeignCurrency { + redeem_amount: amount_redeeming, + swap: collected_swap, + }) + } + } + Self::RedeemingAndActiveSwapIntoForeignCurrency { swap, .. } => { + let new_swap = Swap { + amount: swap.amount.ensure_add(collected_swap.amount)?, + ..collected_swap + }; + if amount_redeeming.is_zero() { + Ok(Self::ActiveSwapIntoForeignCurrency { swap: new_swap }) + } else { + Ok(Self::RedeemingAndActiveSwapIntoForeignCurrency { + redeem_amount: amount_redeeming, + swap: new_swap, + }) + } + } + Self::RedeemingAndSwapIntoForeignDone { done_swap, .. } => { + if amount_redeeming.is_zero() { + Ok(Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: collected_swap, + done_amount: done_swap.amount, + }) + } else { + Ok( + Self::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + redeem_amount: amount_redeeming, + swap: collected_swap, + done_amount: done_swap.amount, + }, + ) + } + } + Self::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap, + done_amount, + .. + } => { + let new_swap = Swap { + amount: swap.amount.ensure_add(collected_swap.amount)?, + ..collected_swap + }; + if amount_redeeming.is_zero() { + Ok(Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: new_swap, + done_amount, + }) + } else { + Ok( + Self::RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + redeem_amount: amount_redeeming, + swap: new_swap, + done_amount, + }, + ) + } + } + _ => Err(DispatchError::Other( + "Invalid pre redeem state when transitioning foreign collect", + )), + } + } +} + +// Actual impl of transition +impl RedeemState +where + Balance: Clone + Copy + EnsureAdd + EnsureSub + Ord + Debug, + Currency: Clone + Copy + PartialEq + Debug, +{ + /// Increments the unprocessed redeeming amount or adds `Redeeming*` to the + /// state with the provided amount. + fn handle_increase(&self, amount: Balance) -> Result { + Self::set_redeem_amount(self, Self::get_redeeming_amount(self).ensure_add(amount)?) + } + + /// Decrement the unprocessed redeeming amount. I.e., if the state includes + /// `Redeeming*`, decreases the redeeming amount. + fn handle_decrease(&self, amount: Balance) -> Result { + Self::set_redeem_amount(self, Self::get_redeeming_amount(self).ensure_sub(amount)?) + } + + /// Update the state if it includes `ActiveSwapIntoForeignCurrency`. + fn handle_fulfilled_swap_order( + &self, + swap: Swap, + ) -> Result { + match self { + Self::NoState => Err(DispatchError::Other( + "Invalid redeem state when transitioning a fulfilled order", + )), + state => state.transition_fulfilled_swap_order(swap), + } + } + + /// Update the state if it includes `Redeeming`. + fn handle_collect( + &self, + amount_redeeming: Balance, + swap: Swap, + ) -> Result { + match self { + Self::NoState => Err(DispatchError::Other( + "Invalid redeem state when transitioning collect", + )), + state => state.transition_collect(amount_redeeming, swap), + } + } +} diff --git a/pallets/foreign-investments/src/lib.rs b/pallets/foreign-investments/src/lib.rs new file mode 100644 index 0000000000..04721d6aee --- /dev/null +++ b/pallets/foreign-investments/src/lib.rs @@ -0,0 +1,428 @@ +// Copyright 2021 Centrifuge Foundation (centrifuge.io). +// This file is part of Centrifuge Chain project. + +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). + +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +//! # Foreign Investment pallet +//! +//! Enables investing, redeeming and collecting in foreign and non-foreign +//! currencies. Can be regarded as an extension of `pallet-investment` which +//! provides the same toolset for pool (non-foreign) currencies. +//! +//! - [`Pallet`] +//! +//! ## Assumptions +//! +//! - The implementer of the pallet's associated `Investment` type sends +//! notifications for collected investments via `CollectedInvestmentHook` and +//! for collected redemptions via `CollectedRedemptionHook`]. Otherwise the +//! payment and collected amounts for foreign investments/redemptions are +//! never incremented. +//! - The implementer of the pallet's associated `TokenSwaps` type sends +//! notifications for fulfilled swap orders via the `FulfilledSwapOrderHook`. +//! Otherwise investment/redemption states can never advance the +//! `ActiveSwapInto*Currency` state. +//! - The implementer of the pallet's associated `TokenSwaps` type sends +//! notifications for fulfilled swap orders via the `FulfilledSwapOrderHook`. +//! Otherwise investment/redemption states can never advance the +//! `ActiveSwapInto*Currency` state. +//! - The implementer of the pallet's associated +//! `DecreasedForeignInvestOrderHook` type handles the refund of the decreased +//! amount to the investor. +//! - The implementer of the pallet's associated +//! `CollectedForeignRedemptionHook` type handles the transfer of the +//! collected amount in foreign currency to the investor. + +#![cfg_attr(not(feature = "std"), no_std)] + +use cfg_types::investments::Swap; +/// Edit this file to define custom logic or remove it if it is not needed. +/// Learn more about FRAME and the core library of Substrate FRAME pallets: +/// +pub use pallet::*; + +pub mod errors; +pub mod hooks; +pub mod impls; +pub mod types; + +pub type SwapOf = Swap<::Balance, ::CurrencyId>; +pub type ForeignInvestmentInfoOf = cfg_types::investments::ForeignInvestmentInfo< + ::AccountId, + ::InvestmentId, + crate::types::TokenSwapReason, +>; + +#[frame_support::pallet] +pub mod pallet { + use cfg_traits::{ + investments::{Investment as InvestmentT, InvestmentCollector, TrancheCurrency}, + PoolInspect, StatusNotificationHook, TokenSwaps, + }; + use cfg_types::investments::{ + CollectedAmount, ExecutedForeignCollectRedeem, ExecutedForeignDecreaseInvest, + }; + use errors::{InvestError, RedeemError}; + use frame_support::{dispatch::HasCompact, pallet_prelude::*}; + use sp_runtime::traits::AtLeast32BitUnsigned; + use types::{InvestState, RedeemState}; + + use super::*; + + #[pallet::pallet] + pub struct Pallet(_); + + /// Configure the pallet by specifying the parameters and types on which it + /// depends. + #[pallet::config] + pub trait Config: frame_system::Config { + /// Because this pallet emits events, it depends on the runtime's + /// definition of an event. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + /// Type representing the weight of this pallet + type WeightInfo: frame_system::WeightInfo; + + /// The source of truth for the balance of accounts + type Balance: Parameter + + Member + + AtLeast32BitUnsigned + + Default + + Copy + + MaybeSerializeDeserialize + + MaxEncodedLen; + + /// The currency type of transferrable tokens + type CurrencyId: Parameter + + Member + + Copy + + MaybeSerializeDeserialize + + Ord + + TypeInfo + + MaxEncodedLen; + + /// The pool id type required for the investment identifier + type PoolId: Member + + Parameter + + Default + + Copy + + HasCompact + + MaxEncodedLen + + core::fmt::Debug; + + /// The tranche id type required for the investment identifier + type TrancheId: Member + + Parameter + + Default + + Copy + + MaxEncodedLen + + TypeInfo + + From<[u8; 16]>; + + /// The investment identifying type required for the investment type + type InvestmentId: TrancheCurrency + + Into + + Clone + + Member + + Parameter + + Copy + + MaxEncodedLen; + + /// The internal investment type which handles the actual investment on + /// top of the wrapper implementation of this Pallet + type Investment: InvestmentT< + Self::AccountId, + Amount = Self::Balance, + CurrencyId = Self::CurrencyId, + Error = DispatchError, + InvestmentId = Self::InvestmentId, + > + InvestmentCollector< + Self::AccountId, + Error = DispatchError, + InvestmentId = Self::InvestmentId, + Result = (), + >; + + /// Type for price ratio for cost of incoming currency relative to + /// outgoing + type Rate: Parameter + + Member + + sp_runtime::FixedPointNumber + + sp_runtime::traits::EnsureMul + + sp_runtime::traits::EnsureDiv + + MaybeSerializeDeserialize + + TypeInfo + + MaxEncodedLen; + + /// The default sell rate for token swaps which will be applied to all + /// swaps created/updated through Foreign Investments. + /// + /// Example: Say this rate is set to 3/2, then the incoming currency + /// should never cost more than 1.5 of the outgoing currency. + /// + /// NOTE: Can be removed once we implement a + /// more sophisticated swap price discovery. For now, this should be set + /// to one. + #[pallet::constant] + type DefaultTokenSellRate: Get; + + /// The token swap order identifying type + type TokenSwapOrderId: Parameter + + Member + + Copy + + MaybeSerializeDeserialize + + Ord + + TypeInfo + + MaxEncodedLen; + + /// The type which exposes token swap order functionality such as + /// placing and cancelling orders + type TokenSwaps: TokenSwaps< + Self::AccountId, + CurrencyId = Self::CurrencyId, + Balance = Self::Balance, + OrderId = Self::TokenSwapOrderId, + OrderDetails = Swap, + SellRatio = Self::Rate, + >; + + /// The hook type which acts upon a finalized investment decrement. + type DecreasedForeignInvestOrderHook: StatusNotificationHook< + Id = cfg_types::investments::ForeignInvestmentInfo< + Self::AccountId, + Self::InvestmentId, + (), + >, + Status = ExecutedForeignDecreaseInvest, + Error = DispatchError, + >; + + /// The hook type which acts upon a finalized redemption collection. + type CollectedForeignRedemptionHook: StatusNotificationHook< + Id = cfg_types::investments::ForeignInvestmentInfo< + Self::AccountId, + Self::InvestmentId, + (), + >, + Status = ExecutedForeignCollectRedeem, + Error = DispatchError, + >; + + /// Type which provides a conversion from one currency amount to another + /// currency amount. + /// + /// NOTE: Restricting to `IdentityCurrencyConversion` is solely a + /// short-term MVP solution. In the near future, this type must be + /// restricted to a more sophisticated trait which provides + /// unidirectional conversions based on an oracle, dynamic prices or at + /// least conversion ratios based on specific currency pairs. + type CurrencyConverter: cfg_traits::IdentityCurrencyConversion< + Balance = Self::Balance, + Currency = Self::CurrencyId, + Error = DispatchError, + >; + + /// The source of truth for pool currencies. + type PoolInspect: PoolInspect< + Self::AccountId, + Self::CurrencyId, + PoolId = Self::PoolId, + TrancheId = Self::TrancheId, + >; + } + + /// Maps an investor and their `InvestmentId` to the corresponding + /// `InvestState`. + /// + /// NOTE: The lifetime of this storage starts with initializing a currency + /// swap into the required pool currency and ends upon fully processing the + /// investment after the potential swap. In case a swap is not required, the + /// investment starts with `InvestState::InvestmentOngoing`. + #[pallet::storage] + pub type InvestmentState = StorageDoubleMap< + _, + Blake2_128Concat, + T::AccountId, + Blake2_128Concat, + T::InvestmentId, + InvestState, + ValueQuery, + >; + + /// Maps an investor and their `InvestmentId` to the corresponding + /// `RedeemState`. + /// + /// NOTE: The lifetime of this storage starts with increasing a redemption + /// which requires owning at least the amount of tranche tokens by which the + /// redemption shall be increased by. It ends with transferring back + /// the swapped return currency to the corresponding source domain from + /// which the investment originated. The lifecycle must go through the + /// following stages: + /// 1. Increase redemption --> Initialize storage + /// 2. Fully process pending redemption + /// 3. Collect redemption + /// 4. Trigger swap from pool to return currency + /// 5. Completely fulfill swap order + /// 6. Transfer back to source domain --> Kill storage entry + #[pallet::storage] + pub type RedemptionState = StorageDoubleMap< + _, + Blake2_128Concat, + T::AccountId, + Blake2_128Concat, + T::InvestmentId, + RedeemState, + ValueQuery, + >; + + /// Maps a token swap order id to the corresponding `ForeignInvestmentInfo` + /// to implicitly enable mapping to `InvestmentState` and `RedemptionState`. + /// + /// NOTE: The storage is immediately killed when the swap order is + /// completely fulfilled even if the corresponding investment and/or + /// redemption might not be fully processed. + #[pallet::storage] + #[pallet::getter(fn foreign_investment_info)] + pub(super) type ForeignInvestmentInfo = + StorageMap<_, Blake2_128Concat, T::TokenSwapOrderId, ForeignInvestmentInfoOf>; + + /// Maps an investor and their `InvestmentId` to the corresponding + /// `TokenSwapOrderId`. + /// + /// NOTE: The storage is immediately killed when the swap order is + /// completely fulfilled even if the investment might not be fully + /// processed. + #[pallet::storage] + #[pallet::getter(fn token_swap_order_ids)] + pub(super) type TokenSwapOrderIds = StorageDoubleMap< + _, + Blake2_128Concat, + T::AccountId, + Blake2_128Concat, + T::InvestmentId, + T::TokenSwapOrderId, + >; + + /// Maps an investor and their `InvestmentId` to the collected investment + /// amount, i.e., the payment amount of pool currency burned for the + /// conversion into collected amount of tranche tokens based on the + /// fulfillment price(s). + /// + /// NOTE: The lifetime of this storage starts with receiving a notification + /// of an executed investment via the `CollectedInvestmentHook`. It ends + /// with transferring the collected tranche tokens by executing + /// `transfer_collected_investment` which is part of + /// `collect_foreign_investment`. + #[pallet::storage] + pub type CollectedInvestment = StorageDoubleMap< + _, + Blake2_128Concat, + T::AccountId, + Blake2_128Concat, + T::InvestmentId, + CollectedAmount, + ValueQuery, + >; + + /// Maps an investor and their `InvestmentId` to the collected redemption + /// amount, i.e., the payment amount of tranche tokens burned for the + /// conversion into collected pool currency based on the + /// fulfillment price(s). + /// + /// NOTE: The lifetime of this storage starts with receiving a notification + /// of an executed redemption collection into pool currency via the + /// `CollectedRedemptionHook`. It ends with having swapped the entire amount + /// to foreign currency which is assumed to be asynchronous. + #[pallet::storage] + pub type CollectedRedemption = StorageDoubleMap< + _, + Blake2_128Concat, + T::AccountId, + Blake2_128Concat, + T::InvestmentId, + CollectedAmount, + ValueQuery, + >; + + /// Maps an investor and their investment id to the foreign payout currency + /// requested on the initial redemption increment. + /// + /// TODO(future): The lifetime of this storage is currently defensively + /// indefinite. It should most likely mirror the one of `RedemptionState` + /// though right now it + #[pallet::storage] + pub type RedemptionPayoutCurrency = StorageDoubleMap< + _, + Blake2_128Concat, + T::AccountId, + Blake2_128Concat, + T::InvestmentId, + T::CurrencyId, + >; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + ForeignInvestmentUpdated { + investor: T::AccountId, + investment_id: T::InvestmentId, + state: InvestState, + }, + ForeignInvestmentCleared { + investor: T::AccountId, + investment_id: T::InvestmentId, + }, + ForeignRedemptionUpdated { + investor: T::AccountId, + investment_id: T::InvestmentId, + state: RedeemState, + }, + ForeignRedemptionCleared { + investor: T::AccountId, + investment_id: T::InvestmentId, + }, + } + + #[pallet::error] + pub enum Error { + /// Failed to retrieve the `TokenSwapReason` from the given + /// `TokenSwapOrderId`. + InvestmentInfoNotFound, + /// The provided currency does not match the one provided when the first + /// redemption increase was triggered. + /// + /// NOTE: As long as the `RedemptionState` has not been cleared, the + /// payout currency cannot change from the initially provided one. + InvalidRedemptionPayoutCurrency, + /// Failed to retrieve the `TokenSwapReason` from the given + /// `TokenSwapOrderId`. + TokenSwapReasonNotFound, + /// The fulfilled token swap amount exceeds the sum of active swap + /// amounts of the corresponding `InvestmentState` and + /// `RedemptionState`. + FulfilledTokenSwapAmountOverflow, + /// Failed to transition the `InvestState`. + InvestError(InvestError), + /// Failed to transition the `RedeemState.` + RedeemError(RedeemError), + } + + // impl From for Error { + // fn from(error: InvestError) -> Self { + // Error::::InvestError(error) + // } + // } + + // impl From for Error { + // fn from(error: RedeemError) -> Self { + // Error::::RedeemError(error) + // } + // } +} diff --git a/pallets/foreign-investments/src/types.rs b/pallets/foreign-investments/src/types.rs new file mode 100644 index 0000000000..4b2de308c1 --- /dev/null +++ b/pallets/foreign-investments/src/types.rs @@ -0,0 +1,362 @@ +// Copyright 2021 Centrifuge Foundation (centrifuge.io). +// This file is part of Centrifuge Chain project. + +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). + +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +use cfg_traits::IdentityCurrencyConversion; +use cfg_types::investments::Swap; +use codec::{Decode, Encode, MaxEncodedLen}; +use frame_support::{dispatch::fmt::Debug, RuntimeDebugNoBound}; +use scale_info::TypeInfo; +use sp_runtime::traits::{EnsureAdd, EnsureSub, Zero}; + +use crate::Config; + +/// Reflects the reason for the last token swap update such that it can be +/// updated accordingly if the last and current reason mismatch. +#[derive( + Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Debug, Encode, Decode, TypeInfo, MaxEncodedLen, +)] +pub enum TokenSwapReason { + Investment, + Redemption, + InvestmentAndRedemption, +} + +/// Restriction of `pallet_foreign_investments::Config` trait to support +/// currency conversion in the `InvestState`. +pub trait InvestStateConfig { + type Balance: Clone + Copy + EnsureAdd + EnsureSub + Ord + Debug + Zero; + type CurrencyId: Clone + Copy + PartialEq + Debug; + type CurrencyConverter: IdentityCurrencyConversion< + Balance = Self::Balance, + Currency = Self::CurrencyId, + Error = sp_runtime::DispatchError, + >; +} + +impl InvestStateConfig for T { + type Balance = T::Balance; + type CurrencyConverter = T::CurrencyConverter; + type CurrencyId = T::CurrencyId; +} + +/// Reflects all states a foreign investment can have until it is processed as +/// an investment via `::Investment`. This includes swapping it +/// into a pool currency or back, if the investment is decreased before it is +/// fully processed. +#[derive( + PartialOrd, Ord, PartialEq, Eq, RuntimeDebugNoBound, Encode, Decode, TypeInfo, MaxEncodedLen, +)] +#[scale_info(skip_type_params(T))] +pub enum InvestState { + /// Default state for initialization which will never be actively put into + /// chain state, i.e. if this state is the result of applying transition(s), + /// then the corresponding `InvestmentState` will be cleared. + NoState, + /// The investment is waiting to be processed. + InvestmentOngoing { invest_amount: T::Balance }, + /// The investment is currently swapped into the required pool currency. + ActiveSwapIntoPoolCurrency { + swap: Swap, + }, + /// The unprocessed investment was fully decreased and is currently swapped + /// back into the corresponding foreign currency. + ActiveSwapIntoForeignCurrency { + swap: Swap, + }, + /// The investment is not fully swapped into pool currency and thus split + /// into two parts: + /// * One part is still being swapped. + /// * The remainder is already waiting to be processed as investment. + ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + swap: Swap, + invest_amount: T::Balance, + }, + /// The investment is split into two parts: + /// * One part is waiting to be processed as investment. + /// * The remainder is swapped back into the foreign currency as a result of + /// decreasing the invested amount before being processed. + ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { + swap: Swap, + invest_amount: T::Balance, + }, + /// The investment is split into two parts: + /// * The one part is swapping into pool currency. + /// * The remainder was swapped back into the foreign currency as a result + /// of decreasing the invested amount before being processed. + /// + /// NOTE: This state is transitioned into `ActiveSwapIntoPoolCurrency` + /// in the post-processing `apply_invest_state_transition` as the done part + /// invokes `ExecutedDecreaseInvestOrder` dispatch. + ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDone { + swap: Swap, + done_amount: T::Balance, + }, + /// The investment is swapped back into the foreign currency and was already + /// partially fulfilled. + ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: Swap, + done_amount: T::Balance, + }, + /// The investment is split into three parts: + /// * One part is currently swapping into the pool currency. + /// * The second is already waiting to be processed as investment. + /// * The remainder was swapped back into the foreign currency as a result + /// of decreasing the invested amount before being processed. + /// + /// NOTE: This state is transitioned into + /// `ActiveSwapIntoPoolCurrencyAndInvestmentOngoing` in the post-processing + /// `apply_invest_state_transition` as the done part invokes + /// `ExecutedDecreaseInvestOrder` dispatch. + ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: Swap, + done_amount: T::Balance, + invest_amount: T::Balance, + }, + /// The investment is split into three parts: + /// * One is waiting to be processed as investment. + /// * The second is swapped back into the foreign currency as a result of + /// decreasing the invested amount before being processed. + /// * The remainder was already swapped back into the foreign currency. + /// + /// NOTE: This state must not be transitioned by applying the trigger for + /// the done part but wait until the active swap is fulfilled. + ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: Swap, + done_amount: T::Balance, + invest_amount: T::Balance, + }, + /// The unprocessed investment was swapped back into foreign currency. + /// + /// NOTE: This state is killed in the post-processing + /// `apply_invest_state_transition` as it invokes + /// `ExecutedDecreaseInvestOrder` dispatch. + SwapIntoForeignDone { + done_swap: Swap, + }, + /// The investment is split into two parts: + /// * One part is waiting to be processed as an investment + /// * The swapped back into the foreign currency as a result of decreasing + /// the invested amount before being processed. + /// + /// NOTE: This state is transitioned into `InvestmentOngoing` in the + /// post-processing `apply_invest_state_transition` as the done part invokes + /// `ExecutedDecreaseInvestOrder` dispatch. + SwapIntoForeignDoneAndInvestmentOngoing { + done_swap: Swap, + invest_amount: T::Balance, + }, +} +// NOTE: Needed because `T` of `InvestState` cannot be restricted to impl +// Default +impl Default for InvestState { + fn default() -> Self { + Self::NoState + } +} + +// NOTE: Needed because `T` of `InvestState` cannot be restricted to impl +// Copy +impl Clone for InvestState +where + T::Balance: Clone, + T::CurrencyId: Clone, + Swap: Clone, +{ + fn clone(&self) -> Self { + match self { + Self::NoState => Self::NoState, + Self::InvestmentOngoing { invest_amount } => Self::InvestmentOngoing { + invest_amount: *invest_amount, + }, + Self::ActiveSwapIntoPoolCurrency { swap } => { + Self::ActiveSwapIntoPoolCurrency { swap: *swap } + } + Self::ActiveSwapIntoForeignCurrency { swap } => { + Self::ActiveSwapIntoForeignCurrency { swap: *swap } + } + Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + swap, + invest_amount, + } => Self::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + swap: *swap, + invest_amount: *invest_amount, + }, + Self::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { + swap, + invest_amount, + } => Self::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { + swap: *swap, + invest_amount: *invest_amount, + }, + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDone { swap, done_amount } => { + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDone { + swap: *swap, + done_amount: *done_amount, + } + } + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { swap, done_amount } => { + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: *swap, + done_amount: *done_amount, + } + } + Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap, + done_amount, + invest_amount, + } => Self::ActiveSwapIntoPoolCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: *swap, + done_amount: *done_amount, + invest_amount: *invest_amount, + }, + Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap, + done_amount, + invest_amount, + } => Self::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDoneAndInvestmentOngoing { + swap: *swap, + done_amount: *done_amount, + invest_amount: *invest_amount, + }, + Self::SwapIntoForeignDone { done_swap } => Self::SwapIntoForeignDone { + done_swap: *done_swap, + }, + Self::SwapIntoForeignDoneAndInvestmentOngoing { + done_swap, + invest_amount, + } => Self::SwapIntoForeignDoneAndInvestmentOngoing { + done_swap: *done_swap, + invest_amount: *invest_amount, + }, + } + } +} + +/// Reflects all state transitions of an `InvestmentState` which can be +/// externally triggered, i.e. by (partially) fulfilling a token swap order or +/// updating an unprocessed investment. +#[derive(Clone, PartialOrd, Ord, PartialEq, Eq, Debug, Encode, Decode, TypeInfo, MaxEncodedLen)] +pub enum InvestTransition< + Balance: Clone + Copy + EnsureAdd + EnsureSub + Ord + Debug, + Currency: Clone + Copy + PartialEq + Debug, +> { + /// Assumes `swap.amount` to be denominated in pool currency and + /// `swap.currency_in` to be pool currency as we increase here. + IncreaseInvestOrder(Swap), + /// Assumes `swap.amount` to be denominated in foreign currency and + /// `swap.currency_in` to be foreign currency as we increase here. + DecreaseInvestOrder(Swap), + /// Implicitly derives `swap.currency_in` and `swap.currency_out` from + /// previous state: + /// * If the previous state includes `ActiveSwapIntoPoolCurrency`, + /// `currency_in` is the pool currency. + /// * If the previous state includes `ActiveSwapIntoForeignCurrency`, + /// `currency_in` is the foreign currency. + FulfillSwapOrder(Swap), + CollectInvestment(Balance), +} + +/// Reflects all states a foreign redemption can have until transferred to the +/// corresponding source domain. +/// +/// This includes swapping it into a pool currency or back, if the investment is +/// decreased before it is fully processed. +#[derive( + Clone, + Copy, + Default, + PartialOrd, + Ord, + PartialEq, + Eq, + Debug, + Encode, + Decode, + TypeInfo, + MaxEncodedLen, +)] +pub enum RedeemState< + Balance: Clone + Copy + EnsureAdd + EnsureSub + Ord + Debug, + Currency: Clone + Copy + PartialEq + Debug, +> { + #[default] + /// Default state for initialization which will never be actively put into + /// chain state, i.e. if this state is the result of applying transition(s), + /// then the corresponding `RedemptionState` will be cleared. + NoState, + /// The redemption is pending until it is processed during epoch execution. + Redeeming { redeem_amount: Balance }, + /// The redemption was fully processed and collected and is currently + /// swapping into the foreign currency. + ActiveSwapIntoForeignCurrency { swap: Swap }, + /// The redemption was fully processed, collected and partially swapped into + /// the foreign currency. It is split into two parts: + /// * One part is swapping back into the foreign currency. + /// * The remainder was already swapped back. + ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + swap: Swap, + done_amount: Balance, + }, + /// The redemption was fully processed, collected and swapped into the + /// foreign currency. + /// + /// NOTE: This state is automatically killed in the post-processing + /// `apply_collect_redeem_transition` as it prepares the dispatch of + /// `ExecutedCollectRedeem` message which needs to be triggered manually. + SwapIntoForeignDone { done_swap: Swap }, + /// The redemption is split into two parts: + /// * One part is waiting to be processed as redemption. + /// * The remainder is swapping back into the foreign currency as a result + /// of processing and collecting beforehand. + RedeemingAndActiveSwapIntoForeignCurrency { + redeem_amount: Balance, + swap: Swap, + }, + /// The redemption is split into two parts: + /// * One part is waiting to be processed as redemption. + /// * The remainder is swapping back into the foreign currency as a result + /// of processing and collecting beforehand. + /// + /// NOTE: This state is automatically transitioned into `Redeeming` in the + /// post-processing `apply_collect_redeem_transition` as the done part + /// prepares the dispatch of `ExecutedCollectRedeem` message which needs to + /// be triggered manually. + RedeemingAndSwapIntoForeignDone { + redeem_amount: Balance, + done_swap: Swap, + }, + /// The redemption is split into three parts: + /// * One part is waiting to be processed as redemption. + /// * The second is swapping back into the foreign currency as a result of + /// processing and collecting beforehand. + /// * The remainder was already swapped back. + RedeemingAndActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + redeem_amount: Balance, + swap: Swap, + done_amount: Balance, + }, +} + +/// Reflects all state transitions of a `RedeemState` which can be +/// externally triggered, i.e. by (partially) fulfilling a token swap order or +/// updating an unprocessed redemption. +#[derive(Clone, PartialOrd, Ord, PartialEq, Eq, Debug, Encode, Decode, TypeInfo, MaxEncodedLen)] +pub enum RedeemTransition< + Balance: Clone + Copy + EnsureAdd + EnsureSub + Ord + Debug, + Currency: Clone + Copy + PartialEq + Debug, +> { + IncreaseRedeemOrder(Balance), + DecreaseRedeemOrder(Balance), + FulfillSwapOrder(Swap), + CollectRedemption(Balance, Swap), +} diff --git a/pallets/foreign-investments/src/weights.rs b/pallets/foreign-investments/src/weights.rs new file mode 100644 index 0000000000..f309c029a3 --- /dev/null +++ b/pallets/foreign-investments/src/weights.rs @@ -0,0 +1,103 @@ +// Copyright 2021 Centrifuge Foundation (centrifuge.io). +// This file is part of Centrifuge Chain project. + +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). + +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// //! Autogenerated weights for pallet_template +// //! +// //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev +// //! DATE: 2023-04-06, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +// //! WORST CASE MAP SIZE: `1000000` +// //! HOSTNAME: `Alexs-MacBook-Pro-2.local`, CPU: `` +// //! EXECUTION: Some(Wasm), WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024 + +// // Executed Command: +// // ../../target/release/node-template +// // benchmark +// // pallet +// // --chain +// // dev +// // --pallet +// // pallet_template +// // --extrinsic +// // * +// // --steps=50 +// // --repeat=20 +// // --execution=wasm +// // --wasm-execution=compiled +// // --output +// // pallets/template/src/weights.rs +// // --template +// // ../../.maintain/frame-weight-template.hbs + +// #![cfg_attr(rustfmt, rustfmt_skip)] +// #![allow(unused_parens)] +// #![allow(unused_imports)] + +// use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +// use core::marker::PhantomData; + +// /// Weight functions needed for pallet_template. +// pub trait WeightInfo { +// fn do_something() -> Weight; +// fn cause_error() -> Weight; +// } + +// /// Weights for pallet_template using the Substrate node and recommended hardware. +// pub struct SubstrateWeight(PhantomData); +// impl WeightInfo for SubstrateWeight { +// /// Storage: TemplateModule Something (r:0 w:1) +// /// Proof: TemplateModule Something (max_values: Some(1), max_size: Some(4), added: 499, mode: MaxEncodedLen) +// fn do_something() -> Weight { +// // Proof Size summary in bytes: +// // Measured: `0` +// // Estimated: `0` +// // Minimum execution time: 8_000_000 picoseconds. +// Weight::from_parts(9_000_000, 0) +// .saturating_add(T::DbWeight::get().writes(1_u64)) +// } +// /// Storage: TemplateModule Something (r:1 w:1) +// /// Proof: TemplateModule Something (max_values: Some(1), max_size: Some(4), added: 499, mode: MaxEncodedLen) +// fn cause_error() -> Weight { +// // Proof Size summary in bytes: +// // Measured: `32` +// // Estimated: `1489` +// // Minimum execution time: 6_000_000 picoseconds. +// Weight::from_parts(6_000_000, 1489) +// .saturating_add(T::DbWeight::get().reads(1_u64)) +// .saturating_add(T::DbWeight::get().writes(1_u64)) +// } +// } + +// // For backwards compatibility and tests +// impl WeightInfo for () { +// /// Storage: TemplateModule Something (r:0 w:1) +// /// Proof: TemplateModule Something (max_values: Some(1), max_size: Some(4), added: 499, mode: MaxEncodedLen) +// fn do_something() -> Weight { +// // Proof Size summary in bytes: +// // Measured: `0` +// // Estimated: `0` +// // Minimum execution time: 8_000_000 picoseconds. +// Weight::from_parts(9_000_000, 0) +// .saturating_add(RocksDbWeight::get().writes(1_u64)) +// } +// /// Storage: TemplateModule Something (r:1 w:1) +// /// Proof: TemplateModule Something (max_values: Some(1), max_size: Some(4), added: 499, mode: MaxEncodedLen) +// fn cause_error() -> Weight { +// // Proof Size summary in bytes: +// // Measured: `32` +// // Estimated: `1489` +// // Minimum execution time: 6_000_000 picoseconds. +// Weight::from_parts(6_000_000, 1489) +// .saturating_add(RocksDbWeight::get().reads(1_u64)) +// .saturating_add(RocksDbWeight::get().writes(1_u64)) +// } +// } diff --git a/pallets/investments/src/lib.rs b/pallets/investments/src/lib.rs index d2a7283b87..ca4ef743c9 100644 --- a/pallets/investments/src/lib.rs +++ b/pallets/investments/src/lib.rs @@ -15,22 +15,29 @@ use cfg_primitives::OrderId; use cfg_traits::{ - Investment, InvestmentAccountant, InvestmentCollector, InvestmentProperties, - InvestmentsPortfolio, OrderManager, PreConditions, + investments::{ + Investment, InvestmentAccountant, InvestmentCollector, InvestmentProperties, + InvestmentsPortfolio, OrderManager, + }, + PreConditions, StatusNotificationHook, }; use cfg_types::{ fixed_point::FixedPointNumberExtension, - investments::InvestmentAccount, + investments::{ + CollectedAmount, ForeignInvestmentInfo, InvestCollection, InvestmentAccount, + RedeemCollection, + }, orders::{FulfillmentWithPrice, Order, TotalOrder}, }; use frame_support::{ + dispatch::{DispatchErrorWithPostInfo, PostDispatchInfo}, pallet_prelude::*, traits::tokens::fungibles::{Inspect, Mutate, Transfer}, }; use frame_system::pallet_prelude::*; pub use pallet::*; use sp_runtime::{ - traits::{AccountIdConversion, CheckedAdd, CheckedSub, One, Zero}, + traits::{AccountIdConversion, CheckedAdd, CheckedSub, EnsureAddAssign, One, Zero}, ArithmeticError, FixedPointNumber, }; use sp_std::{ @@ -56,84 +63,6 @@ type AccountInvestmentPortfolioOf = Vec<( ::Amount, )>; -/// The outstanding collections for an account -#[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo)] -pub struct InvestCollection { - /// This is the payout in the denomination currency - /// of an investment - /// -> investment in payment currency - /// -> payout in denomination currency - pub payout_investment_invest: Balance, - - /// This is the remaining investment in the payment currency - /// of an investment - /// -> investment in payment currency - /// -> payout in denomination currency - pub remaining_investment_invest: Balance, -} - -impl Default for InvestCollection { - fn default() -> Self { - InvestCollection { - payout_investment_invest: Zero::zero(), - remaining_investment_invest: Zero::zero(), - } - } -} - -impl InvestCollection { - /// Create a `InvestCollection` directly from an active invest order of - /// a user. - /// The field `remaining_investment_invest` is set to the - /// amount of the active invest order of the user and will - /// be subtracted from upon given fulfillment's - pub fn from_order(order: &Order) -> Self { - InvestCollection { - payout_investment_invest: Zero::zero(), - remaining_investment_invest: order.amount(), - } - } -} - -/// The outstanding collections for an account -#[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo)] -pub struct RedeemCollection { - /// This is the payout in the payment currency - /// of an investment - /// -> redemption in denomination currency - /// -> payout in payment currency - pub payout_investment_redeem: Balance, - - /// This is the remaining redemption in the denomination currency - /// of an investment - /// -> redemption in denomination currency - /// -> payout in payment currency - pub remaining_investment_redeem: Balance, -} - -impl Default for RedeemCollection { - fn default() -> Self { - RedeemCollection { - payout_investment_redeem: Zero::zero(), - remaining_investment_redeem: Zero::zero(), - } - } -} - -impl RedeemCollection { - /// Create a `RedeemCollection` directly from an active redeem order of - /// a user. - /// The field `remaining_investment_redeem` is set to the - /// amount of the active redeem order of the user and will - /// be subtracted from upon given fulfillment's - pub fn from_order(order: &Order) -> Self { - RedeemCollection { - payout_investment_redeem: Zero::zero(), - remaining_investment_redeem: order.amount(), - } - } -} - /// The enum we parse to `PreConditions` so the runtime /// can make an educated decision about this investment #[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo)] @@ -179,6 +108,7 @@ pub enum CollectType { #[frame_support::pallet] pub mod pallet { + use cfg_types::investments::ForeignInvestmentInfo; use sp_runtime::{traits::AtLeast32BitUnsigned, FixedPointNumber, FixedPointOperand}; use super::*; @@ -247,6 +177,24 @@ pub mod pallet { Result = DispatchResult, >; + /// The hook which acts upon a collected investment. + /// + /// NOTE: NOOP if the investment is not foreign. + type CollectedInvestmentHook: StatusNotificationHook< + Error = DispatchError, + Id = ForeignInvestmentInfo, + Status = CollectedAmount, + >; + + /// The hook which acts upon a (partially) fulfilled order + /// + /// NOTE: NOOP if the redemption is not foreign. + type CollectedRedemptionHook: StatusNotificationHook< + Error = DispatchError, + Id = ForeignInvestmentInfo, + Status = CollectedAmount, + >; + /// The weight information for this pallet extrinsics. type WeightInfo: weights::WeightInfo; } @@ -695,7 +643,7 @@ where } fn rm_empty(amount: T::Amount, storage_order: &mut Option>, on_not_empty: Event) { - if amount > T::Amount::zero() { + if !amount.is_zero() { Self::deposit_event(on_not_empty); } else { // In this case the user has no active position. @@ -713,10 +661,14 @@ where investment_id: T::InvestmentId, ) -> DispatchResultWithPostInfo { let info = T::Accountant::info(investment_id).map_err(|_| Error::::UnknownInvestment)?; - InvestOrders::::try_mutate( + let (collected_investment, post_dispatch_info) = InvestOrders::::try_mutate( &who, investment_id, - |maybe_order| -> DispatchResultWithPostInfo { + |maybe_order| -> Result< + (CollectedAmount, PostDispatchInfo), + DispatchErrorWithPostInfo, + > { + // Exit early if order does not exist let order = if let Some(order) = maybe_order.as_mut() { order } else { @@ -726,8 +678,9 @@ where }); // TODO: Return correct weight // - Accountant::info() + Storage::read() + Storage::write() - return Ok(().into()); + return Ok((Default::default(), ().into())); }; + let mut collection = InvestCollection::::from_order(order); let mut collected_ids = Vec::new(); let cur_order_id = InvestOrderId::::get(investment_id); @@ -738,7 +691,7 @@ where cur_order_id, ); - // The current order is not in processing + // Exit early if the current order is not in processing if order.submitted_at() == cur_order_id { Self::deposit_event(Event::::InvestCollectedForNonClearedOrderId { who: who.clone(), @@ -746,16 +699,25 @@ where }); // TODO: Return correct weight // - Accountant::info() + 2 * Storage::read() + Storage::write() - return Ok(().into()); + return Ok((Default::default(), ().into())); } + let mut amount_payment = T::Amount::zero(); for order_id in order.submitted_at()..last_processed_order_id { let fulfillment = ClearedInvestOrders::::try_get(investment_id, order_id) .map_err(|_| Error::::OrderNotCleared)?; - Pallet::::acc_payout_invest(&mut collection, &fulfillment)?; + let currency_payout = + Pallet::::acc_payout_invest(&mut collection, &fulfillment)?; Pallet::::acc_remaining_invest(&mut collection, &fulfillment)?; collected_ids.push(order_id); + + amount_payment.ensure_add_assign( + fulfillment + .price + .checked_mul_int_floor(currency_payout) + .ok_or(ArithmeticError::Overflow)?, + )?; } order.update_after_collect( @@ -782,6 +744,11 @@ where }, ); + let collected_investment = CollectedAmount { + amount_collected: collection.payout_investment_invest, + amount_payment, + }; + Self::deposit_event(Event::InvestOrdersCollected { investment_id, who: who.clone(), @@ -795,9 +762,23 @@ where }); // TODO: Actually weight with amount of collects here - Ok(().into()) + Ok((collected_investment, ().into())) }, - ) + )?; + + if collected_investment != Default::default() { + // Assumption: NOOP if investment is not foreign + T::CollectedInvestmentHook::notify_status_change( + ForeignInvestmentInfo { + owner: who, + id: investment_id, + last_swap_reason: None, + }, + collected_investment, + )?; + } + + Ok(post_dispatch_info) } #[allow(clippy::type_complexity)] @@ -806,10 +787,14 @@ where investment_id: T::InvestmentId, ) -> DispatchResultWithPostInfo { let info = T::Accountant::info(investment_id).map_err(|_| Error::::UnknownInvestment)?; - RedeemOrders::::try_mutate( + let (collected_redemption, post_dispatch_info) = RedeemOrders::::try_mutate( &who, investment_id, - |maybe_order| -> DispatchResultWithPostInfo { + |maybe_order| -> Result< + (CollectedAmount, PostDispatchInfo), + DispatchErrorWithPostInfo, + > { + // Exit early if order does not exist let order = if let Some(order) = maybe_order.as_mut() { order } else { @@ -820,8 +805,9 @@ where }); // TODO: Return correct weight // - Accountant::info() + Storage::read() + Storage::write() - return Ok(().into()); + return Ok((Default::default(), ().into())); }; + let mut collection = RedeemCollection::::from_order(order); let mut collected_ids = Vec::new(); let cur_order_id = RedeemOrderId::::get(investment_id); @@ -832,7 +818,7 @@ where cur_order_id, ); - // The current order is not in processing + // Exit early if the current order is not in processing if order.submitted_at() == cur_order_id { Self::deposit_event(Event::::RedeemCollectedForNonClearedOrderId { who: who.clone(), @@ -840,15 +826,29 @@ where }); // TODO: Return correct weight // - Accountant::info() + 2 * Storage::read() + Storage::write() - return Ok(().into()); + return Ok((Default::default(), ().into())); } + let mut amount_payment = T::Amount::zero(); for order_id in order.submitted_at()..last_processed_order_id { let fulfillment = ClearedRedeemOrders::::try_get(investment_id, order_id) .map_err(|_| Error::::OrderNotCleared)?; - Pallet::::acc_payout_redeem(&mut collection, &fulfillment)?; + let payout_tranche_tokens = + Pallet::::acc_payout_redeem(&mut collection, &fulfillment)?; Pallet::::acc_remaining_redeem(&mut collection, &fulfillment)?; collected_ids.push(order_id); + + // TODO(@mustermeiszer): We actually want the reciprocal without rounding, is + // this sufficient or should we use something like + // `reciprocal_with_rounding(SignedRounding::NearestPrefMajor)` + amount_payment.ensure_add_assign( + fulfillment + .price + .reciprocal_floor() + .ok_or(Error::::ZeroPricedInvestment)? + .checked_mul_int_floor(payout_tranche_tokens) + .ok_or(ArithmeticError::Overflow)?, + )?; } order.update_after_collect( @@ -879,6 +879,11 @@ where }, ); + let collected_redemption = CollectedAmount { + amount_collected: collection.payout_investment_redeem, + amount_payment, + }; + Self::deposit_event(Event::RedeemOrdersCollected { investment_id, who: who.clone(), @@ -892,9 +897,23 @@ where }); // TODO: Actually weight this with collected_ids - Ok(().into()) + Ok((collected_redemption, ().into())) }, - ) + )?; + + if collected_redemption != Default::default() { + // Assumption: NOOP if investment is not foreign + T::CollectedRedemptionHook::notify_status_change( + ForeignInvestmentInfo { + owner: who, + id: investment_id, + last_swap_reason: None, + }, + collected_redemption, + )?; + } + + Ok(post_dispatch_info) } pub(crate) fn do_update_invest_order( @@ -974,66 +993,74 @@ where } } + /// Increments an accounts' investment payout amount based on the remaining + /// amount and the fulfillment price. + /// + /// Returns the amount by which was incremented. pub fn acc_payout_invest( collection: &mut InvestCollection, fulfillment: &FulfillmentWithPrice, - ) -> DispatchResult { + ) -> Result { let remaining = collection.remaining_investment_invest; // NOTE: The checked_mul_int_floor and reciprocal_floor here ensure that for a - // given price the system side (i.e. the pallet-investments) will always - // have enough balance to satisfy all claims on payouts. + // given price the system side (i.e. the pallet-investments) will always + // have enough balance to satisfy all claims on payouts. // - // Importantly, the Accountant side (i.e. the pool and therefore an - // issuer) will still drain its reserve by the amount without rounding. So - // we neither favor issuer or investor but always the system. + // Importantly, the Accountant side (i.e. the pool and therefore an issuer) will + // still drain its reserve by the amount without rounding. So we neither favor + // issuer or investor but always the system. // - // TODO: Rounding always means, we might have issuance on tranche-tokens - // left, that are rounding leftovers. This will be of importance, - // once we remove tranches at some point. + // TODO: Rounding always means, we might have issuance on tranche-tokens + // left, that are rounding leftovers. This will be of importance, once we remove + // tranches at some point. + let payout_investment_invest = &fulfillment + .price + .reciprocal_floor() + .ok_or(Error::::ZeroPricedInvestment)? + .checked_mul_int_floor(fulfillment.of_amount.mul_floor(remaining)) + .ok_or(ArithmeticError::Overflow)?; collection.payout_investment_invest = collection .payout_investment_invest - .checked_add( - &fulfillment - .price - .reciprocal_floor() - .ok_or(Error::::ZeroPricedInvestment)? - .checked_mul_int_floor(fulfillment.of_amount.mul_floor(remaining)) - .ok_or(ArithmeticError::Overflow)?, - ) + .checked_add(payout_investment_invest) .ok_or(ArithmeticError::Overflow)?; - Ok(()) + Ok(*payout_investment_invest) } + /// Increments an accounts' redemption payout amount based on the remaining + /// amount and the fulfillment price. + /// + /// Returns the amount by which was incremented. pub fn acc_payout_redeem( collection: &mut RedeemCollection, fulfillment: &FulfillmentWithPrice, - ) -> DispatchResult { + ) -> Result { let remaining = collection.remaining_investment_redeem; // NOTE: The checked_mul_int_floor here ensures that for a given price // the system side (i.e. the pallet-investments) will always have // enough balance to satisfy all claims on payouts. // - // Importantly, the Accountant side (i.e. the pool and therefore an - // issuer) will still drain its reserve by the amount without rounding. So - // we neither favor issuer or investor but always the system. + // Importantly, the Accountant side (i.e. the pool and therefore an issuer) will + // still drain its reserve by the amount without rounding. So we neither favor + // issuer or investor but always the system. // - // TODO: Rounding always means, we might have issuance on tranche-tokens - // left, that are rounding leftovers. This will be of importance, - // once we remove tranches at some point. + // TODO: Rounding always means, we might have issuance on tranche-tokens left, + // that are rounding leftovers. This will be of importance, once we remove + // tranches at some point. + let payout_investment_redeem = &fulfillment + .price + .checked_mul_int_floor(fulfillment.of_amount.mul_floor(remaining)) + .ok_or(ArithmeticError::Overflow)?; collection.payout_investment_redeem = collection .payout_investment_redeem - .checked_add( - &fulfillment - .price - .checked_mul_int_floor(fulfillment.of_amount.mul_floor(remaining)) - .ok_or(ArithmeticError::Overflow)?, - ) + .checked_add(payout_investment_redeem) .ok_or(ArithmeticError::Overflow)?; - Ok(()) + Ok(*payout_investment_redeem) } + /// Decrements an accounts' remaining redemption amount based on the + /// fulfillment price. pub fn acc_remaining_redeem( collection: &mut RedeemCollection, fulfillment: &FulfillmentWithPrice, @@ -1047,6 +1074,8 @@ where Ok(()) } + /// Decrements an accounts' remaining investment amount based on the + /// fulfillment price. pub fn acc_remaining_invest( collection: &mut InvestCollection, fulfillment: &FulfillmentWithPrice, @@ -1187,6 +1216,30 @@ where Ok(RedeemOrders::::get(who, investment_id) .map_or_else(Zero::zero, |order| order.amount())) } + + fn investment_requires_collect( + investor: &T::AccountId, + investment_id: Self::InvestmentId, + ) -> bool { + InvestOrders::::get(investor, investment_id) + .map(|order| { + let cur_order_id = InvestOrderId::::get(investment_id); + order.submitted_at() != cur_order_id + }) + .unwrap_or(false) + } + + fn redemption_requires_collect( + investor: &T::AccountId, + investment_id: Self::InvestmentId, + ) -> bool { + RedeemOrders::::get(investor, investment_id) + .map(|order| { + let cur_order_id = RedeemOrderId::::get(investment_id); + order.submitted_at() != cur_order_id + }) + .unwrap_or(false) + } } impl OrderManager for Pallet @@ -1478,19 +1531,13 @@ where type InvestmentId = T::InvestmentId; type Result = (); - fn collect_investment( - who: T::AccountId, - investment_id: Self::InvestmentId, - ) -> Result { + fn collect_investment(who: T::AccountId, investment_id: T::InvestmentId) -> DispatchResult { Pallet::::do_collect_invest(who, investment_id) .map_err(|e| e.error) .map(|_| ()) } - fn collect_redemption( - who: T::AccountId, - investment_id: Self::InvestmentId, - ) -> Result { + fn collect_redemption(who: T::AccountId, investment_id: T::InvestmentId) -> DispatchResult { Pallet::::do_collect_redeem(who, investment_id) .map_err(|e| e.error) .map(|_| ()) diff --git a/pallets/investments/src/mock.rs b/pallets/investments/src/mock.rs index 16813c101d..f70b7e2f61 100644 --- a/pallets/investments/src/mock.rs +++ b/pallets/investments/src/mock.rs @@ -15,7 +15,7 @@ use std::ops::Add; pub use cfg_primitives::CFG as CURRENCY; use cfg_primitives::*; -use cfg_traits::{OrderManager, PreConditions}; +use cfg_traits::{investments::OrderManager, PreConditions}; use cfg_types::{ fixed_point::Rate, investments::InvestmentAccount, @@ -140,6 +140,17 @@ cfg_test_utils::mocks::accountant::impl_mock_accountant!( Balance ); +pub struct NoopCollectHook; +impl cfg_traits::StatusNotificationHook for NoopCollectHook { + type Error = sp_runtime::DispatchError; + type Id = cfg_types::investments::ForeignInvestmentInfo; + type Status = cfg_types::investments::CollectedAmount; + + fn notify_status_change(_id: Self::Id, _status: Self::Status) -> DispatchResult { + Ok(()) + } +} + parameter_types! { pub const MaxOutstandingCollect: u64 = 10; } @@ -148,6 +159,8 @@ impl pallet_investments::Config for MockRuntime { type Accountant = MockAccountant; type Amount = Balance; type BalanceRatio = Rate; + type CollectedInvestmentHook = NoopCollectHook; + type CollectedRedemptionHook = NoopCollectHook; type InvestmentId = InvestmentId; type MaxOutstandingCollects = MaxOutstandingCollect; type PreConditions = Always; @@ -391,7 +404,7 @@ pub(crate) fn price_of(full: Balance, dec_n: Balance, dec_d: Balance) -> Rate { full.add(decimals) } -/// Creates a fullfillment of given perc and price +/// Creates a fulfillment of given perc and price pub(crate) fn fulfillment_of(perc: Perquintill, price: Rate) -> FulfillmentWithPrice { FulfillmentWithPrice { of_amount: perc, diff --git a/pallets/investments/src/tests.rs b/pallets/investments/src/tests.rs index 0faa9308ce..fa0e9ddbe1 100644 --- a/pallets/investments/src/tests.rs +++ b/pallets/investments/src/tests.rs @@ -839,6 +839,16 @@ fn fulfillment_partially_works_low_price() { of_amount: PERC_REDEEM_FULFILL, price: PRICE, }; + #[allow(non_snake_case)] + let T_BALANCE_POST_COLLECT_INVEST = PRICE + .reciprocal_floor() + .unwrap() + .checked_mul_int_floor(PERC_INVEST_FULFILL.mul_floor(SINGLE_INVEST_AMOUNT)) + .unwrap(); + #[allow(non_snake_case)] + let AUSD_BALANCE_POST_COLLECT_REDEEM = PRICE + .checked_mul_int_floor(PERC_REDEEM_FULFILL.mul_floor(SINGLE_REDEEM_AMOUNT)) + .unwrap(); // Setup investments and redemptions. // We do not thoroughly check the events here, as we @@ -946,11 +956,7 @@ fn fulfillment_partially_works_low_price() { )); assert_eq!( free_balance_of(InvestorA::get(), INVESTMENT_0_0.into()), - PRICE - .reciprocal_floor() - .unwrap() - .checked_mul_int_floor(PERC_INVEST_FULFILL.mul_floor(SINGLE_INVEST_AMOUNT)) - .unwrap() + T_BALANCE_POST_COLLECT_INVEST ); assert_eq!( InvestOrders::::get(InvestorA::get(), INVESTMENT_0_0), @@ -1009,11 +1015,7 @@ fn fulfillment_partially_works_low_price() { )); assert_eq!( free_balance_of(InvestorA::get(), INVESTMENT_0_0.into()), - PRICE - .reciprocal_floor() - .unwrap() - .checked_mul_int_floor(PERC_INVEST_FULFILL.mul_floor(SINGLE_INVEST_AMOUNT)) - .unwrap() + T_BALANCE_POST_COLLECT_INVEST ); assert_eq!( InvestOrders::::get(InvestorA::get(), INVESTMENT_0_0), @@ -1051,11 +1053,7 @@ fn fulfillment_partially_works_low_price() { )); assert_eq!( free_balance_of(InvestorB::get(), INVESTMENT_0_0.into()), - PRICE - .reciprocal_floor() - .unwrap() - .checked_mul_int_floor(PERC_INVEST_FULFILL.mul_floor(SINGLE_INVEST_AMOUNT)) - .unwrap() + T_BALANCE_POST_COLLECT_INVEST ); assert_eq!( InvestOrders::::get(InvestorB::get(), INVESTMENT_0_0), @@ -1114,11 +1112,7 @@ fn fulfillment_partially_works_low_price() { )); assert_eq!( free_balance_of(InvestorB::get(), INVESTMENT_0_0.into()), - PRICE - .reciprocal_floor() - .unwrap() - .checked_mul_int_floor(PERC_INVEST_FULFILL.mul_floor(SINGLE_INVEST_AMOUNT)) - .unwrap() + T_BALANCE_POST_COLLECT_INVEST ); assert_eq!( InvestOrders::::get(InvestorB::get(), INVESTMENT_0_0), @@ -1179,9 +1173,7 @@ fn fulfillment_partially_works_low_price() { )); assert_eq!( free_balance_of(TrancheHolderA::get(), AUSD_CURRENCY_ID), - PRICE - .checked_mul_int_floor(PERC_REDEEM_FULFILL.mul_floor(SINGLE_REDEEM_AMOUNT)) - .unwrap() + AUSD_BALANCE_POST_COLLECT_REDEEM ); assert_eq!( RedeemOrders::::get(TrancheHolderA::get(), INVESTMENT_0_0), @@ -1238,9 +1230,7 @@ fn fulfillment_partially_works_low_price() { )); assert_eq!( free_balance_of(TrancheHolderA::get(), AUSD_CURRENCY_ID), - PRICE - .checked_mul_int_floor(PERC_REDEEM_FULFILL.mul_floor(SINGLE_REDEEM_AMOUNT)) - .unwrap() + AUSD_BALANCE_POST_COLLECT_REDEEM ); assert_eq!( RedeemOrders::::get(TrancheHolderA::get(), INVESTMENT_0_0), @@ -1385,7 +1375,7 @@ fn fulfillment_partially_works_low_price() { INVESTMENT_0_0 )); // NOTE: InvestorD gets MINIMALLY more, as he had different fulfillments - // compared to the others. I.e. the first fullfillment not part of his. + // compared to the others. I.e. the first fulfillment not part of his. // We already floor round for everybody, but there is nothing we can do // about this. assert_eq!( @@ -1421,7 +1411,7 @@ fn fulfillment_partially_works_low_price() { INVESTMENT_0_0 )); // NOTE: TrancheHolderD gets MINIMALLY more, as he had different fulfillments - // compared to the others. I.e. the first fullfillment not part of his. + // compared to the others. I.e. the first fulfillment not part of his. // We already floor round for everybody, but there is nothing we can do // about this. assert_eq!( @@ -1534,6 +1524,16 @@ fn fulfillment_partially_works_high_price() { of_amount: PERC_REDEEM_FULFILL, price: PRICE, }; + #[allow(non_snake_case)] + let T_BALANCE_POST_COLLECT_INVEST = PRICE + .reciprocal_floor() + .unwrap() + .checked_mul_int_floor(PERC_INVEST_FULFILL.mul_floor(SINGLE_INVEST_AMOUNT)) + .unwrap(); + #[allow(non_snake_case)] + let AUSD_BALANCE_POST_COLLECT_REDEEM = PRICE + .checked_mul_int_floor(PERC_REDEEM_FULFILL.mul_floor(SINGLE_REDEEM_AMOUNT)) + .unwrap(); // Setup investments and redemptions. // We do not thoroughly check the events here, as we @@ -1641,11 +1641,7 @@ fn fulfillment_partially_works_high_price() { )); assert_eq!( free_balance_of(InvestorA::get(), INVESTMENT_0_0.into()), - PRICE - .reciprocal_floor() - .unwrap() - .checked_mul_int_floor(PERC_INVEST_FULFILL.mul_floor(SINGLE_INVEST_AMOUNT)) - .unwrap() + T_BALANCE_POST_COLLECT_INVEST ); assert_eq!( InvestOrders::::get(InvestorA::get(), INVESTMENT_0_0), @@ -1704,11 +1700,7 @@ fn fulfillment_partially_works_high_price() { )); assert_eq!( free_balance_of(InvestorA::get(), INVESTMENT_0_0.into()), - PRICE - .reciprocal_floor() - .unwrap() - .checked_mul_int_floor(PERC_INVEST_FULFILL.mul_floor(SINGLE_INVEST_AMOUNT)) - .unwrap() + T_BALANCE_POST_COLLECT_INVEST ); assert_eq!( InvestOrders::::get(InvestorA::get(), INVESTMENT_0_0), @@ -1746,11 +1738,7 @@ fn fulfillment_partially_works_high_price() { )); assert_eq!( free_balance_of(InvestorB::get(), INVESTMENT_0_0.into()), - PRICE - .reciprocal_floor() - .unwrap() - .checked_mul_int_floor(PERC_INVEST_FULFILL.mul_floor(SINGLE_INVEST_AMOUNT)) - .unwrap() + T_BALANCE_POST_COLLECT_INVEST ); assert_eq!( InvestOrders::::get(InvestorB::get(), INVESTMENT_0_0), @@ -1809,11 +1797,7 @@ fn fulfillment_partially_works_high_price() { )); assert_eq!( free_balance_of(InvestorB::get(), INVESTMENT_0_0.into()), - PRICE - .reciprocal_floor() - .unwrap() - .checked_mul_int_floor(PERC_INVEST_FULFILL.mul_floor(SINGLE_INVEST_AMOUNT)) - .unwrap() + T_BALANCE_POST_COLLECT_INVEST ); assert_eq!( InvestOrders::::get(InvestorB::get(), INVESTMENT_0_0), @@ -1874,9 +1858,7 @@ fn fulfillment_partially_works_high_price() { )); assert_eq!( free_balance_of(TrancheHolderA::get(), AUSD_CURRENCY_ID), - PRICE - .checked_mul_int_floor(PERC_REDEEM_FULFILL.mul_floor(SINGLE_REDEEM_AMOUNT)) - .unwrap() + AUSD_BALANCE_POST_COLLECT_REDEEM ); assert_eq!( RedeemOrders::::get(TrancheHolderA::get(), INVESTMENT_0_0), @@ -1933,9 +1915,7 @@ fn fulfillment_partially_works_high_price() { )); assert_eq!( free_balance_of(TrancheHolderA::get(), AUSD_CURRENCY_ID), - PRICE - .checked_mul_int_floor(PERC_REDEEM_FULFILL.mul_floor(SINGLE_REDEEM_AMOUNT)) - .unwrap() + AUSD_BALANCE_POST_COLLECT_REDEEM ); assert_eq!( RedeemOrders::::get(TrancheHolderA::get(), INVESTMENT_0_0), @@ -2072,7 +2052,7 @@ fn fulfillment_partially_works_high_price() { INVESTMENT_0_0 )); // NOTE: InvestorD gets MINIMALLY more, as he had different fulfillments - // compared to the others. I.e. the first fullfillment not part of his. + // compared to the others. I.e. the first fulfillment not part of his. // We already floor round for everybody, but there is nothing we can do // about this. assert_eq!( @@ -2108,7 +2088,7 @@ fn fulfillment_partially_works_high_price() { INVESTMENT_0_0 )); // NOTE: TrancheHolderD gets MINIMALLY less, as he had different fulfillments - // compared to the others. I.e. the first fullfillment not part of his. + // compared to the others. I.e. the first fulfillment not part of his. // We already floor round for everybody, but there is nothing we can do // about this. assert_eq!( diff --git a/pallets/liquidity-pools/src/hooks.rs b/pallets/liquidity-pools/src/hooks.rs new file mode 100644 index 0000000000..abf123b8b3 --- /dev/null +++ b/pallets/liquidity-pools/src/hooks.rs @@ -0,0 +1,111 @@ +// Copyright 2021 Centrifuge Foundation (centrifuge.io). +// This file is part of Centrifuge chain project. + +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). + +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +use cfg_traits::{ + investments::TrancheCurrency, liquidity_pools::OutboundQueue, StatusNotificationHook, +}; +use cfg_types::{ + domain_address::DomainAddress, + investments::{ExecutedForeignDecreaseInvest, ForeignInvestmentInfo}, +}; +use frame_support::{traits::fungibles::Mutate, transactional}; +use sp_core::Get; +use sp_runtime::{DispatchError, DispatchResult}; +use sp_std::marker::PhantomData; + +use crate::{pallet::Config, Message, MessageOf, Pallet}; + +/// The hook struct which acts upon a finalized investment decrement. +pub struct DecreasedForeignInvestOrderHook(PhantomData); + +impl StatusNotificationHook for DecreasedForeignInvestOrderHook +where + ::AccountId: Into<[u8; 32]>, +{ + type Error = DispatchError; + type Id = ForeignInvestmentInfo; + type Status = ExecutedForeignDecreaseInvest; + + #[transactional] + fn notify_status_change( + id: ForeignInvestmentInfo, + status: ExecutedForeignDecreaseInvest, + ) -> DispatchResult { + let ForeignInvestmentInfo { + id: investment_id, + owner: investor, + .. + } = id; + let currency = Pallet::::try_get_general_index(status.foreign_currency)?; + let wrapped_token = Pallet::::try_get_wrapped_token(&status.foreign_currency)?; + let domain_address: DomainAddress = wrapped_token.into(); + + T::Tokens::burn_from(status.foreign_currency, &investor, status.amount_decreased)?; + + let message: MessageOf = Message::ExecutedDecreaseInvestOrder { + pool_id: investment_id.of_pool(), + tranche_id: investment_id.of_tranche(), + investor: investor.into(), + currency, + currency_payout: status.amount_decreased, + remaining_invest_amount: status.amount_remaining, + }; + T::OutboundQueue::submit(T::TreasuryAccount::get(), domain_address.domain(), message)?; + + Ok(()) + } +} + +/// The hook struct which acts upon a finalized redemption collection. + +pub struct CollectedForeignRedemptionHook(PhantomData); + +impl StatusNotificationHook for CollectedForeignRedemptionHook +where + ::AccountId: Into<[u8; 32]>, +{ + type Error = DispatchError; + type Id = ForeignInvestmentInfo; + type Status = cfg_types::investments::ExecutedForeignCollectRedeem; + + #[transactional] + fn notify_status_change( + id: ForeignInvestmentInfo, + status: cfg_types::investments::ExecutedForeignCollectRedeem, + ) -> DispatchResult { + let ForeignInvestmentInfo { + id: investment_id, + owner: investor, + .. + } = id; + let currency = Pallet::::try_get_general_index(status.currency)?; + let wrapped_token = Pallet::::try_get_wrapped_token(&status.currency)?; + let domain_address: DomainAddress = wrapped_token.into(); + + T::Tokens::burn_from(status.currency, &investor, status.amount_currency_payout)?; + + let message: MessageOf = Message::ExecutedCollectRedeem { + pool_id: investment_id.of_pool(), + tranche_id: investment_id.of_tranche(), + investor: investor.into(), + currency, + currency_payout: status.amount_currency_payout, + tranche_tokens_payout: status.amount_tranche_tokens_payout, + remaining_redeem_amount: status.amount_remaining_redeem, + }; + + T::OutboundQueue::submit(T::TreasuryAccount::get(), domain_address.domain(), message)?; + + Ok(()) + } +} diff --git a/pallets/liquidity-pools/src/inbound.rs b/pallets/liquidity-pools/src/inbound.rs index ea13883b71..68d24cbc15 100644 --- a/pallets/liquidity-pools/src/inbound.rs +++ b/pallets/liquidity-pools/src/inbound.rs @@ -11,23 +11,30 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. -use cfg_traits::{Investment, InvestmentCollector, Permissions}; +use cfg_traits::{ + investments::ForeignInvestment, liquidity_pools::OutboundQueue, Permissions, PoolInspect, +}; use cfg_types::{ domain_address::{Domain, DomainAddress}, + investments::ExecutedForeignCollectInvest, permissions::{PermissionScope, PoolRole, Role}, }; use frame_support::{ ensure, traits::fungibles::{Mutate, Transfer}, }; +use sp_core::Get; use sp_runtime::{ - traits::{Convert, EnsureAdd, EnsureSub, Zero}, + traits::{Convert, Zero}, DispatchResult, }; -use crate::{pallet::Error, Config, GeneralCurrencyIndexOf, Pallet}; +use crate::{pallet::Error, Config, GeneralCurrencyIndexOf, Message, MessageOf, Pallet}; -impl Pallet { +impl Pallet +where + T::AccountId: Into<[u8; 32]>, +{ /// Executes a transfer from another domain exclusively for /// non-tranche-tokens. /// @@ -85,6 +92,9 @@ impl Pallet { /// /// Directly mints the additional investment amount into the investor /// account. + /// + /// If the provided currency does not match the pool currency, a token swap + /// is initiated. pub fn handle_increase_invest_order( pool_id: T::PoolId, tranche_id: T::TrancheId, @@ -92,28 +102,34 @@ impl Pallet { currency_index: GeneralCurrencyIndexOf, amount: ::Balance, ) -> DispatchResult { - // Retrieve investment details let invest_id: T::TrancheCurrency = Self::derive_invest_id(pool_id, tranche_id)?; - let currency = Self::try_get_payment_currency(invest_id.clone(), currency_index)?; - - // Determine post adjustment amount - let pre_amount = T::ForeignInvestment::investment(&investor, invest_id.clone())?; - let post_amount = pre_amount.ensure_add(amount)?; + let payment_currency = Self::try_get_payment_currency(invest_id.clone(), currency_index)?; + let pool_currency = + T::PoolInspect::currency_for(pool_id).ok_or(Error::::PoolNotFound)?; - // Mint additional amount - T::Tokens::mint_into(currency, &investor, amount)?; + // Mint additional amount of payment currency + T::Tokens::mint_into(payment_currency, &investor, amount)?; - T::ForeignInvestment::update_investment(&investor, invest_id, post_amount)?; + T::ForeignInvestment::increase_foreign_investment( + &investor, + invest_id, + amount, + payment_currency, + pool_currency, + )?; Ok(()) } - /// Decreases an existing investment order of the investor. + /// Initiates the decrement of an existing investment order of the investor. + /// + /// On success, the unprocessed investment amount is decremented and a swap + /// back into the provided foreign currency initiated. /// - /// Initiates a return `ExecutedDecreaseInvestOrder` message to refund the - /// decreased amount on the source domain. The dispatch of this message is - /// delayed until the execution of the investment, e.g. at least until the - /// next epoch transition. + /// The finalization of this call (fulfillment of the swap) is assumed to be + /// asynchronous. In any case, it is handled by + /// `DecreasedForeignInvestOrderHook` which burns the corresponding amount + /// in foreign currency and dispatches `ExecutedDecreaseInvestOrder`. pub fn handle_decrease_invest_order( pool_id: T::PoolId, tranche_id: T::TrancheId, @@ -121,50 +137,65 @@ impl Pallet { currency_index: GeneralCurrencyIndexOf, amount: ::Balance, ) -> DispatchResult { - // Retrieve investment details let invest_id: T::TrancheCurrency = Self::derive_invest_id(pool_id, tranche_id)?; - let _currency = Self::try_get_payment_currency(invest_id.clone(), currency_index)?; + let payment_currency = Self::try_get_payment_currency(invest_id.clone(), currency_index)?; + let pool_currency = + T::PoolInspect::currency_for(pool_id).ok_or(Error::::PoolNotFound)?; - // Determine post adjustment amount - let pre_amount = T::ForeignInvestment::investment(&investor, invest_id.clone())?; - let post_amount = pre_amount.ensure_sub(amount)?; + T::ForeignInvestment::decrease_foreign_investment( + &investor, + invest_id, + amount, + payment_currency, + pool_currency, + )?; - T::ForeignInvestment::update_investment(&investor, invest_id, post_amount)?; + Ok(()) + } - // TODO(subsequent PR): Handle response `ExecutedDecreaseInvestOrder` message to - // source destination which should refund the decreased amount. This includes - // burning it from the investor account. - // - // NOTES: - // * Blocked by https://github.com/centrifuge/centrifuge-chain/pull/1363 - // * Should be handled by `pallet-foreign-investments` + /// Cancels an invest order by decreasing by the entire unprocessed + /// investment amount. + /// + /// On success, initiates a swap back into the provided foreign currency. + /// + /// The finalization of this call (fulfillment of the swap) is assumed to be + /// asynchronous. In any case, it is handled by + /// `DecreasedForeignInvestOrderHook` which burns the corresponding amount + /// in foreign currency and dispatches `ExecutedDecreaseInvestOrder`. + pub fn handle_cancel_invest_order( + pool_id: T::PoolId, + tranche_id: T::TrancheId, + investor: T::AccountId, + currency_index: GeneralCurrencyIndexOf, + ) -> DispatchResult { + let invest_id: T::TrancheCurrency = Self::derive_invest_id(pool_id, tranche_id)?; + let amount = T::ForeignInvestment::investment(&investor, invest_id)?; - Ok(()) + Self::handle_decrease_invest_order(pool_id, tranche_id, investor, currency_index, amount) } /// Increases an existing redemption order of the investor. /// - /// Transfers the decreased redemption amount from the holdings of the + /// Transfers the increase redemption amount from the holdings of the /// `DomainLocator` account of origination domain of this message into the /// investor account. /// /// Assumes that the amount of tranche tokens has been locked in the /// `DomainLocator` account of the origination domain beforehand. - pub fn handle_increase_redemption( + pub fn handle_increase_redeem_order( pool_id: T::PoolId, tranche_id: T::TrancheId, investor: T::AccountId, amount: ::Balance, + currency_index: GeneralCurrencyIndexOf, sending_domain: DomainAddress, ) -> DispatchResult { - // Retrieve investment details let invest_id: T::TrancheCurrency = Self::derive_invest_id(pool_id, tranche_id)?; + let payout_currency = Self::try_get_payout_currency(invest_id.clone(), currency_index)?; - // Determine post adjustment amount - let pre_amount = T::ForeignInvestment::redemption(&investor, invest_id.clone())?; - let post_amount = pre_amount.ensure_add(amount)?; - - // Transfer tranche tokens from `DomainLocator` account of origination domain + // Transfer tranche tokens from `DomainLocator` account of + // origination domain + // TODO(@review): Should this rather be part of `increase_foreign_redemption`? T::Tokens::transfer( invest_id.clone().into(), &Domain::convert(sending_domain.domain()), @@ -173,7 +204,12 @@ impl Pallet { false, )?; - T::ForeignInvestment::update_redemption(&investor, invest_id, post_amount)?; + T::ForeignInvestment::increase_foreign_redemption( + &investor, + invest_id, + amount, + payout_currency, + )?; Ok(()) } @@ -181,72 +217,164 @@ impl Pallet { /// Decreases an existing redemption order of the investor. /// /// Initiates a return `ExecutedDecreaseRedemption` message to refund the - /// decreased amount on the source domain. The dispatch of this message is - /// delayed until the execution of the redemption, e.g. at least until the - /// next epoch transition. - pub fn handle_decrease_redemption( + /// decreased amount on the source domain. + /// + /// NOTE: In contrast to investments, redemption decrements happen + /// fully synchronously as they can only be called in between increasing a + /// redemption and its (full) processing. + pub fn handle_decrease_redeem_order( pool_id: T::PoolId, tranche_id: T::TrancheId, investor: T::AccountId, - currency_index: GeneralCurrencyIndexOf, amount: ::Balance, - _sending_domain: DomainAddress, + currency_index: GeneralCurrencyIndexOf, + destination: DomainAddress, ) -> DispatchResult { - // Retrieve investment details let invest_id: T::TrancheCurrency = Self::derive_invest_id(pool_id, tranche_id)?; - // NOTE: Required for relaying `ExecutedDecreaseRedemption` message - let _currency = Self::try_get_payment_currency(invest_id.clone(), currency_index)?; + let currency_u128 = currency_index.index; + let payout_currency = Self::try_get_payout_currency(invest_id.clone(), currency_index)?; - // Determine post adjustment amount - let pre_amount = T::ForeignInvestment::redemption(&investor, invest_id.clone())?; - let post_amount = pre_amount.ensure_sub(amount)?; + let (tranche_tokens_payout, remaining_redeem_amount) = + T::ForeignInvestment::decrease_foreign_redemption( + &investor, + invest_id.clone(), + amount, + payout_currency, + )?; + + T::Tokens::transfer( + invest_id.into(), + &investor, + &Domain::convert(destination.domain()), + tranche_tokens_payout, + false, + )?; - T::ForeignInvestment::update_redemption(&investor, invest_id, post_amount)?; + let message: MessageOf = Message::ExecutedDecreaseRedeemOrder { + pool_id, + tranche_id, + investor: investor.into(), + currency: currency_u128, + tranche_tokens_payout, + remaining_redeem_amount, + }; - // TODO(subsequent PR): Handle response `ExecutedDecreaseRedemption` message to - // source destination which should refund the decreased amount. This includes - // transferring the amount from the investor to the domain locator account of - // the origination domain. - // - // NOTES: - // * Blocked by https://github.com/centrifuge/centrifuge-chain/pull/1363 - // * Should be handled by `pallet-foreign-investments` + T::OutboundQueue::submit(T::TreasuryAccount::get(), destination.domain(), message)?; Ok(()) } + /// Cancels an existing redemption order of the investor by decreasing the + /// redemption by the entire unprocessed amount. + /// + /// Initiates a return `ExecutedDecreaseRedemption` message to refund the + /// decreased amount on the source domain. + pub fn handle_cancel_redeem_order( + pool_id: T::PoolId, + tranche_id: T::TrancheId, + investor: T::AccountId, + currency_index: GeneralCurrencyIndexOf, + destination: DomainAddress, + ) -> DispatchResult { + let invest_id: T::TrancheCurrency = Self::derive_invest_id(pool_id, tranche_id)?; + let amount = T::ForeignInvestment::redemption(&investor, invest_id)?; + + Self::handle_decrease_redeem_order( + pool_id, + tranche_id, + investor, + amount, + currency_index, + destination, + ) + } + /// Collect the results of a user's invest orders for the given investment /// id. If any amounts are not fulfilled, they are directly appended to the /// next active order for this investment. + /// + /// Transfers collected amount from investor's sovereign account to the + /// sending domain locator. + /// + /// NOTE: In contrast to collecting a redemption, investments can be + /// collected entirely synchronously as it does not involve swapping. It + /// simply transfers the tranche tokens from the pool to the sovereign + /// investor account on the local domain. pub fn handle_collect_investment( pool_id: T::PoolId, tranche_id: T::TrancheId, investor: T::AccountId, + currency_index: GeneralCurrencyIndexOf, + destination: DomainAddress, ) -> DispatchResult { let invest_id: T::TrancheCurrency = Self::derive_invest_id(pool_id, tranche_id)?; + let currency_index_u128 = currency_index.index; + let payout_currency = Self::try_get_payout_currency(invest_id.clone(), currency_index)?; + let pool_currency = + T::PoolInspect::currency_for(pool_id).ok_or(Error::::PoolNotFound)?; + + let ExecutedForeignCollectInvest:: { + amount_currency_payout, + amount_tranche_tokens_payout, + amount_remaining_invest, + } = T::ForeignInvestment::collect_foreign_investment( + &investor, + invest_id.clone(), + payout_currency, + pool_currency, + )?; + + T::Tokens::transfer( + invest_id.into(), + &investor, + &Domain::convert(destination.domain()), + amount_tranche_tokens_payout, + false, + )?; - T::ForeignInvestment::collect_investment(investor, invest_id)?; + let message: MessageOf = Message::ExecutedCollectInvest { + pool_id, + tranche_id, + investor: investor.into(), + currency: currency_index_u128, + currency_payout: amount_currency_payout, + tranche_tokens_payout: amount_tranche_tokens_payout, + remaining_invest_amount: amount_remaining_invest, + }; - // TODO(subsequent PR): Handle response `ExecutedCollectInvest` message to - // source destination. + T::OutboundQueue::submit(T::TreasuryAccount::get(), destination.domain(), message)?; Ok(()) } /// Collect the results of a user's redeem orders for the given investment - /// id. If any amounts are not fulfilled, they are directly appended to the - /// next active order for this investment. + /// id in the pool currency. If any amounts are not fulfilled, they are + /// directly appended to the next active order for this investment. + /// + /// On success, a swap will be initiated to exchange the (partially) + /// collected amount in pool currency into the desired foreign currency. + /// + /// The termination of this call (fulfillment of the swap) is assumed to be + /// asynchronous and handled by the `CollectedForeignRedemptionHook`. It + /// burns the return currency amount and dispatches + /// `Message::ExecutedCollectRedeem` to the destination domain. pub fn handle_collect_redemption( pool_id: T::PoolId, tranche_id: T::TrancheId, investor: T::AccountId, + currency_index: GeneralCurrencyIndexOf, ) -> DispatchResult { let invest_id: T::TrancheCurrency = Self::derive_invest_id(pool_id, tranche_id)?; + let payout_currency = Self::try_get_payout_currency(invest_id.clone(), currency_index)?; + let pool_currency = + T::PoolInspect::currency_for(pool_id).ok_or(Error::::PoolNotFound)?; - T::ForeignInvestment::collect_redemption(investor, invest_id)?; - - // TODO(subsequent PR): Handle response `ExecutedCollectRedeem` message to - // source destination. + T::ForeignInvestment::collect_foreign_redemption( + &investor, + invest_id, + payout_currency, + pool_currency, + )?; Ok(()) } diff --git a/pallets/liquidity-pools/src/lib.rs b/pallets/liquidity-pools/src/lib.rs index f385770b17..4ee732d2b8 100644 --- a/pallets/liquidity-pools/src/lib.rs +++ b/pallets/liquidity-pools/src/lib.rs @@ -10,12 +10,41 @@ // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. + +//! # Liquidity Pools pallet +//! +//! Provides the toolset to enable foreign investments on foreign domains. +//! +//! - [`Pallet`] +//! +//! ## Assumptions +//! - Sending/recipient domains handle cross-chain transferred currencies +//! properly on their side. This pallet only ensures correctness on the local +//! domain. +//! - The implementer of the pallet's associated `ForeignInvestment` type sends +//! notifications for completed investment decrements via the +//! `DecreasedForeignInvestOrderHook`. Otherwise the domain which initially +//! sent the `DecreaseInvestOrder` message will never be notified about the +//! completion. +//! - The implementer of the pallet's associated `ForeignInvestment` type sends +//! notifications for completed redemption collections via the +//! `CollectedForeignRedemptionHook`. Otherwise the domain which initially +//! sent the `CollectRedeem` message will never be notified about the +//! completion. +//! - The pallet's associated `TreasuryAccount` holds sufficient balance for the +//! corresponding fee currencies of all possible recipient domains for the +//! following outgoing messages: [`Message::ExecutedDecreaseInvestOrder`], +//! [`Message::ExecutedDecreaseRedeemOrder`], +//! [`Message::ExecutedCollectInvest`], [`Message::ExecutedCollectRedeem`], +//! [`Message::ScheduleUpgrade`]. + #![cfg_attr(not(feature = "std"), no_std)] use core::convert::TryFrom; use cfg_traits::liquidity_pools::{InboundQueue, OutboundQueue}; use cfg_types::{ domain_address::{Domain, DomainAddress}, + investments::ExecutedForeignCollectInvest, tokens::GeneralCurrencyIndex, }; use cfg_utils::vec_to_fixed_array; @@ -52,6 +81,7 @@ pub use routers::*; mod contract; pub use contract::*; +pub mod hooks; mod inbound; /// The Parachains that Centrifuge Liquidity Pools support. @@ -83,8 +113,8 @@ pub type GeneralCurrencyIndexOf = pub mod pallet { use cfg_primitives::Moment; use cfg_traits::{ - CurrencyInspect, Investment, InvestmentCollector, Permissions, PoolInspect, - TrancheCurrency, TrancheTokenPrice, + investments::{ForeignInvestment, TrancheCurrency}, + CurrencyInspect, Permissions, PoolInspect, TrancheTokenPrice, }; use cfg_types::{ permissions::{PermissionScope, PoolRole, Role}, @@ -94,7 +124,7 @@ pub mod pallet { use codec::HasCompact; use frame_support::{pallet_prelude::*, traits::UnixTime}; use frame_system::pallet_prelude::*; - use sp_runtime::traits::Zero; + use sp_runtime::{traits::Zero, DispatchError}; use xcm::latest::MultiLocation; use super::*; @@ -189,23 +219,16 @@ pub mod pallet { + Into> + Clone; - /// Enables investing and redeeming into investment classes. - /// - /// NOTE: For the time being, `pallet_investments` serves as the - /// implementor. However, eventually this should be provided by - /// `pallet_foreign_investments`. - type ForeignInvestment: Investment< - Self::AccountId, - Amount = ::Balance, - CurrencyId = CurrencyIdOf, - Error = DispatchError, - InvestmentId = ::TrancheCurrency, - > + InvestmentCollector< - Self::AccountId, - Error = DispatchError, - InvestmentId = ::TrancheCurrency, - Result = (), - >; + /// Enables investing and redeeming into investment classes with foreign + /// currencies. + type ForeignInvestment: ForeignInvestment< + Self::AccountId, + Amount = ::Balance, + CurrencyId = CurrencyIdOf, + Error = DispatchError, + InvestmentId = ::TrancheCurrency, + CollectInvestResult = ExecutedForeignCollectInvest, + >; /// The source of truth for the transferability of assets via the /// LiquidityPools feature. @@ -225,10 +248,14 @@ pub mod pallet { + MaxEncodedLen + TryInto, Error = DispatchError> + TryFrom, Error = DispatchError> + // Enables checking whether currency is tranche token + CurrencyInspect>; /// The converter from a DomainAddress to a Substrate AccountId. - type AccountConverter: Convert; + type DomainAddressToAccountId: Convert; + + /// The converter from a Domain 32 byte array to Substrate AccountId. + type DomainAccountToAccountId: Convert<(Domain, [u8; 32]), Self::AccountId>; /// The type for processing outgoing messages. type OutboundQueue: OutboundQueue< @@ -242,6 +269,12 @@ pub mod pallet { type GeneralCurrencyPrefix: Get<[u8; 12]>; #[pallet::constant] + /// The type for paying the transaction fees for the dispatch of + /// `Executed*` and `ScheduleUpgrade` messages. + /// + /// NOTE: We need to make sure to collect the appropriate amount + /// beforehand as part of receiving the corresponding investment + /// message. type TreasuryAccount: Get; type RuntimeEvent: From> + IsType<::RuntimeEvent>; @@ -302,9 +335,12 @@ pub mod pallet { InvalidDomain, /// The validity is in the past. InvalidTrancheInvestorValidity, - /// Failed to match the provided GeneralCurrencyIndex against the - /// investment currency of the pool. - InvalidInvestCurrency, + /// The derived currency from the provided GeneralCurrencyIndex is not + /// accepted as payment for the given pool. + InvalidPaymentCurrency, + /// The derived currency from the provided GeneralCurrencyIndex is not + /// accepted as payout for the given pool. + InvalidPayoutCurrency, /// The currency is not allowed to be transferred via LiquidityPools. InvalidTransferCurrency, /// The account derived from the [Domain] and [DomainAddress] has not @@ -317,7 +353,7 @@ pub mod pallet { #[pallet::call] impl Pallet where - ::AccountId: From<[u8; 32]>, + ::AccountId: From<[u8; 32]> + Into<[u8; 32]>, { /// Add a pool to a given domain #[pallet::weight(< T as Config >::WeightInfo::add_pool())] @@ -373,14 +409,11 @@ pub mod pallet { ); // Look up the metadata of the tranche token - let currency_id = Self::derive_invest_id(pool_id, tranche_id)?; - let metadata = T::AssetRegistry::metadata(¤cy_id.into()) + let investment_id = Self::derive_invest_id(pool_id, tranche_id)?; + let metadata = T::AssetRegistry::metadata(&investment_id.into()) .ok_or(Error::::TrancheMetadataNotFound)?; let token_name = vec_to_fixed_array(metadata.name); let token_symbol = vec_to_fixed_array(metadata.symbol); - let price = T::TrancheTokenPrice::get(pool_id, tranche_id) - .ok_or(Error::::MissingTranchePrice)? - .price; // Send the message to the domain T::OutboundQueue::submit( @@ -392,34 +425,54 @@ pub mod pallet { decimals: metadata.decimals.saturated_into(), token_name, token_symbol, - price, }, )?; Ok(()) } - /// Update the price of a tranche token + /// Update the price of a tranche token. + /// + /// By ensuring that registered currency location matches the specified + /// domain, this call origin can be permissionless. + /// + /// The `currency_id` parameter is necessary for the EVM side. #[pallet::weight(< T as Config >::WeightInfo::update_token_price())] #[pallet::call_index(4)] pub fn update_token_price( origin: OriginFor, pool_id: T::PoolId, tranche_id: T::TrancheId, - domain: Domain, + currency_id: CurrencyIdOf, + destination: Domain, ) -> DispatchResult { let who = ensure_signed(origin.clone())?; + // TODO(future): Once we diverge from 1-to-1 conversions for foreign and pool + // currencies, this price must be first converted into the currency_id and then + // re-denominated to 18 decimals (i.e. `Ratio` precision) let price = T::TrancheTokenPrice::get(pool_id, tranche_id) .ok_or(Error::::MissingTranchePrice)? .price; + // Check that the registered asset location matches the destination + match Self::try_get_wrapped_token(¤cy_id)? { + LiquidityPoolsWrappedToken::EVM { chain_id, .. } => { + ensure!( + Domain::EVM(chain_id) == destination, + Error::::InvalidDomain + ); + } + } + let currency = Self::try_get_general_index(currency_id)?; + T::OutboundQueue::submit( who, - domain, + destination, Message::UpdateTrancheTokenPrice { pool_id, tranche_id, + currency, price, }, )?; @@ -457,7 +510,7 @@ pub mod pallet { ensure!( T::Permission::has( PermissionScope::Pool(pool_id), - T::AccountConverter::convert(domain_address.clone()), + T::DomainAddressToAccountId::convert(domain_address.clone()), Role::PoolRole(PoolRole::TrancheInvestor(tranche_id, valid_until)) ), Error::::InvestorDomainAddressNotAMember @@ -498,7 +551,7 @@ pub mod pallet { ensure!( T::Permission::has( PermissionScope::Pool(pool_id), - T::AccountConverter::convert(domain_address.clone()), + T::DomainAddressToAccountId::convert(domain_address.clone()), Role::PoolRole(PoolRole::TrancheInvestor(tranche_id, Self::now())) ), Error::::UnauthorizedTransfer @@ -624,7 +677,7 @@ pub mod pallet { /// Allow a currency to be used as a pool currency and to invest in a /// pool on the domain derived from the given currency. - #[pallet::call_index(90)] + #[pallet::call_index(9)] #[pallet::weight(10_000 + T::DbWeight::get().writes(1).ref_time())] pub fn allow_pool_currency( origin: OriginFor, @@ -632,7 +685,7 @@ pub mod pallet { tranche_id: T::TrancheId, currency_id: CurrencyIdOf, ) -> DispatchResult { - // TODO(subsequent PR): In the future, should be permissioned by trait which + // TODO(future): In the future, should be permissioned by trait which // does not exist yet. // See spec: https://centrifuge.hackmd.io/SERpps-URlG4hkOyyS94-w?view#fn-add_pool_currency let who = ensure_signed(origin)?; @@ -641,7 +694,7 @@ pub mod pallet { let invest_id = Self::derive_invest_id(pool_id, tranche_id)?; ensure!( T::ForeignInvestment::accepted_payment_currency(invest_id, currency_id), - Error::::InvalidInvestCurrency + Error::::InvalidPaymentCurrency ); // Ensure the currency is enabled as pool_currency @@ -661,7 +714,7 @@ pub mod pallet { T::OutboundQueue::submit( who, Domain::EVM(chain_id), - Message::AllowPoolCurrency { pool_id, currency }, + Message::AllowInvestmentCurrency { pool_id, currency }, )?; Ok(()) @@ -683,6 +736,71 @@ pub mod pallet { Message::ScheduleUpgrade { contract }, ) } + + /// Schedule an upgrade of an EVM-based liquidity pool contract instance + #[pallet::weight(10_000)] + #[pallet::call_index(11)] + pub fn cancel_upgrade( + origin: OriginFor, + evm_chain_id: EVMChainId, + contract: [u8; 20], + ) -> DispatchResult { + ensure_root(origin)?; + + T::OutboundQueue::submit( + T::TreasuryAccount::get(), + Domain::EVM(evm_chain_id), + Message::CancelUpgrade { contract }, + ) + } + + /// Update the tranche token name and symbol on the specified domain + /// + /// NOTE: Pulls the metadata from the `AssetRegistry` and thus requires + /// the pool admin to have updated the tranche tokens metadata there + /// beforehand. + #[pallet::weight(10_000)] + #[pallet::call_index(12)] + pub fn update_tranche_token_metadata( + origin: OriginFor, + pool_id: T::PoolId, + tranche_id: T::TrancheId, + domain: Domain, + ) -> DispatchResult { + let who = ensure_signed(origin.clone())?; + + ensure!( + T::PoolInspect::tranche_exists(pool_id, tranche_id), + Error::::TrancheNotFound + ); + + ensure!( + T::Permission::has( + PermissionScope::Pool(pool_id), + who, + Role::PoolRole(PoolRole::PoolAdmin) + ), + Error::::NotPoolAdmin + ); + let investment_id = Self::derive_invest_id(pool_id, tranche_id)?; + let metadata = T::AssetRegistry::metadata(&investment_id.into()) + .ok_or(Error::::TrancheMetadataNotFound)?; + let token_name = vec_to_fixed_array(metadata.name); + let token_symbol = vec_to_fixed_array(metadata.symbol); + + T::OutboundQueue::submit( + T::TreasuryAccount::get(), + domain, + Message::UpdateTrancheTokenMetadata { + pool_id, + tranche_id, + token_name, + token_symbol, + }, + ) + } + + // TODO(@future): pub fn update_tranche_investment_limit } impl Pallet { @@ -778,8 +896,9 @@ pub mod pallet { Ok(TrancheCurrency::generate(pool_id, tranche_id)) } - /// Ensures that the payment currency of the given investment id matches - /// the derived currency and returns the latter. + /// Ensures that currency id can be derived from the + /// GeneralCurrencyIndex and that the former is an accepted payment + /// currency for the given investment id. pub fn try_get_payment_currency( invest_id: ::TrancheCurrency, currency_index: GeneralCurrencyIndexOf, @@ -789,16 +908,28 @@ pub mod pallet { ensure!( T::ForeignInvestment::accepted_payment_currency(invest_id, currency), - Error::::InvalidInvestCurrency + Error::::InvalidPaymentCurrency ); Ok(currency) } + + /// Ensures that currency id can be derived from the + /// GeneralCurrencyIndex and that the former is an accepted payout + /// currency for the given investment id. + /// + /// NOTE: Exactly the same as try_get_payment_currency for now. + pub fn try_get_payout_currency( + invest_id: ::TrancheCurrency, + currency_index: GeneralCurrencyIndexOf, + ) -> Result, DispatchError> { + Self::try_get_payment_currency(invest_id, currency_index) + } } impl InboundQueue for Pallet where - ::AccountId: From<[u8; 32]>, + ::AccountId: From<[u8; 32]> + Into<[u8; 32]>, { type Message = MessageOf; type Sender = DomainAddress; @@ -839,7 +970,7 @@ pub mod pallet { } => Self::handle_increase_invest_order( pool_id, tranche_id, - investor.into(), + T::DomainAccountToAccountId::convert((sender.domain(), investor)), currency.into(), amount, ), @@ -852,7 +983,7 @@ pub mod pallet { } => Self::handle_decrease_invest_order( pool_id, tranche_id, - investor.into(), + T::DomainAccountToAccountId::convert((sender.domain(), investor)), currency.into(), amount, ), @@ -861,12 +992,13 @@ pub mod pallet { tranche_id, investor, amount, - .. - } => Self::handle_increase_redemption( + currency, + } => Self::handle_increase_redeem_order( pool_id, tranche_id, - investor.into(), + T::DomainAccountToAccountId::convert((sender.domain(), investor)), amount, + currency.into(), sender, ), Message::DecreaseRedeemOrder { @@ -875,24 +1007,60 @@ pub mod pallet { investor, currency, amount, - } => Self::handle_decrease_redemption( + } => Self::handle_decrease_redeem_order( pool_id, tranche_id, - investor.into(), - currency.into(), + T::DomainAccountToAccountId::convert((sender.domain(), investor)), amount, + currency.into(), sender, ), Message::CollectInvest { pool_id, tranche_id, investor, - } => Self::handle_collect_investment(pool_id, tranche_id, investor.into()), + currency, + } => Self::handle_collect_investment( + pool_id, + tranche_id, + T::DomainAccountToAccountId::convert((sender.domain(), investor)), + currency.into(), + sender, + ), Message::CollectRedeem { pool_id, tranche_id, investor, - } => Self::handle_collect_redemption(pool_id, tranche_id, investor.into()), + currency, + } => Self::handle_collect_redemption( + pool_id, + tranche_id, + T::DomainAccountToAccountId::convert((sender.domain(), investor)), + currency.into(), + ), + Message::CancelInvestOrder { + pool_id, + tranche_id, + investor, + currency, + } => Self::handle_cancel_invest_order( + pool_id, + tranche_id, + T::DomainAccountToAccountId::convert((sender.domain(), investor)), + currency.into(), + ), + Message::CancelRedeemOrder { + pool_id, + tranche_id, + investor, + currency, + } => Self::handle_cancel_redeem_order( + pool_id, + tranche_id, + T::DomainAccountToAccountId::convert((sender.domain(), investor)), + currency.into(), + sender, + ), _ => Err(Error::::InvalidIncomingMessage.into()), }?; diff --git a/pallets/liquidity-pools/src/message.rs b/pallets/liquidity-pools/src/message.rs index ef0544567d..ceb93cf327 100644 --- a/pallets/liquidity-pools/src/message.rs +++ b/pallets/liquidity-pools/src/message.rs @@ -30,13 +30,13 @@ pub const TOKEN_SYMBOL_SIZE: usize = 32; /// corresponding receiver rejects it. #[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo)] #[cfg_attr(feature = "std", derive(Debug))] -pub enum Message +pub enum Message where Domain: Codec, PoolId: Encode + Decode, TrancheId: Encode + Decode, Balance: Encode + Decode, - Rate: Encode + Decode, + Ratio: Encode + Decode, { Invalid, /// Add a currency to a domain, i.e, register the mapping of a currency id @@ -56,7 +56,7 @@ where /// Allow a currency to be used as a pool currency and to invest in a pool. /// /// Directionality: Centrifuge -> EVM Domain. - AllowPoolCurrency { + AllowInvestmentCurrency { pool_id: PoolId, currency: u128, }, @@ -72,7 +72,6 @@ where token_name: [u8; TOKEN_NAME_SIZE], token_symbol: [u8; TOKEN_SYMBOL_SIZE], decimals: u8, - price: Rate, }, /// Update the price of a tranche token on the target domain. /// @@ -80,7 +79,8 @@ where UpdateTrancheTokenPrice { pool_id: PoolId, tranche_id: TrancheId, - price: Rate, + currency: u128, + price: Ratio, }, /// Whitelist an address for the specified pair of pool and tranche token on /// the target domain. @@ -185,6 +185,7 @@ where pool_id: PoolId, tranche_id: TrancheId, investor: Address, + currency: u128, }, /// Collect the proceeds for the specified pair of pool and /// tranche token. @@ -199,6 +200,7 @@ where pool_id: PoolId, tranche_id: TrancheId, investor: Address, + currency: u128, }, /// The message sent back to the domain from which a `DecreaseInvestOrder` /// message was received, ensuring the correct state update on said domain @@ -218,8 +220,10 @@ where /// `DecreaseInvestOrder` message, i.e., the amount by which the /// investment order was actually decreased by. currency_payout: Balance, - /// The outstanding order, in `currency` units - remaining_invest_order: Balance, + /// The remaining investment amount denominated in the `foreign` payment + /// currency. It reflects the sum of the unprocessed as well as the + /// processed but not yet collected amounts. + remaining_invest_amount: Balance, }, /// The message sent back to the domain from which a `DecreaseRedeemOrder` /// message was received, ensuring the correct state update on said domain @@ -239,9 +243,10 @@ where /// original `DecreaseRedeemOrder` message, i.e., the amount by which /// the redeem order was actually decreased by. tranche_tokens_payout: Balance, - /// The remaining amount of tranche tokens the investor still has locked - /// to redeem at a later epoch execution - remaining_redeem_order: Balance, + /// The remaining redemption amount. It reflects the sum of the + /// unprocessed as well as the processed but not yet collected amount of + /// tranche tokens. + remaining_redeem_amount: Balance, }, /// The message sent back to the domain from which a `CollectInvest` message /// has been received, which will ensure the `investor` gets the payout @@ -257,13 +262,14 @@ where investor: Address, /// The currency in which the investment was realised currency: u128, - /// The amount that was actually collected + /// The amount that was actually collected, in `currency` units currency_payout: Balance, /// The amount of tranche tokens received for the investment made tranche_tokens_payout: Balance, - /// The remaining amount of `currency` the investor still has locked to - /// invest at a later epoch execution - remaining_invest_order: Balance, + /// The remaining investment amount denominated in the `foreign` payment + /// currency. It reflects the sum of the unprocessed as well as the + /// processed but not yet collected amounts. + remaining_invest_amount: Balance, }, /// The message sent back to the domain from which a `CollectRedeem` message /// has been received, which will ensure the `investor` gets the payout @@ -283,9 +289,46 @@ where currency_payout: Balance, /// How many tranche tokens were actually redeemed tranche_tokens_payout: Balance, - /// The remaining amount of tranche tokens the investor still has locked - /// to redeem at a later epoch execution - remaining_redeem_order: Balance, + /// The remaining redemption amount. It reflects the sum of the + /// unprocessed as well as the processed but not yet collected amount of + /// tranche tokens. + remaining_redeem_amount: Balance, + }, + /// Cancel an unprocessed invest order for the specified pair of pool and + /// tranche token. + /// + /// Special instance of `DecreaseInvestOrder` where the amount is chosen + /// properly to cancel out the ongoing investment. Required for ERC4646. + /// + /// On success, triggers a message sent back to the sending domain. + /// The message will take care of re-funding the investor with the given + /// amount the order was reduced with. The `investor` address is used as + /// the receiver of that tokens. + /// + /// Directionality: Centrifuge <- EVM Domain. + CancelInvestOrder { + pool_id: PoolId, + tranche_id: TrancheId, + investor: Address, + currency: u128, + }, + /// Reduce the redeem order amount for the specified pair of pool and + /// tranche token. + /// + /// Special instance of `DecreaseRedeemOrder` where the amount is chosen + /// properly to cancel out the ongoing redemption. Required for ERC4646. + /// + /// On success, triggers a message sent back to the sending domain. + /// The message will take care of re-funding the investor with the given + /// amount the order was reduced with. The `investor` address is used as + /// the receiver of that tokens. + /// + /// Directionality: Centrifuge <- EVM Domain. + CancelRedeemOrder { + pool_id: PoolId, + tranche_id: TrancheId, + investor: Address, + currency: u128, }, /// Schedules an EVM address to become rely-able by the gateway. Intended to /// be used via governance to execute EVM spells. @@ -295,6 +338,35 @@ where /// The EVM contract address contract: [u8; 20], }, + /// Cancel the scheduled process for an EVM address to become rely-able by + /// the gateway. Intended to be used via governance to execute EVM spells. + /// + /// Directionality: Centrifuge -> EVM Domain. + CancelUpgrade { + /// The EVM contract address + contract: [u8; 20], + }, + /// Updates the name and symbol of a tranche token. + /// + /// NOTE: We do not allow updating the decimals as this would require + /// migrating all associated balances. + /// + /// Directionality: Centrifuge -> EVM Domain. + UpdateTrancheTokenMetadata { + pool_id: PoolId, + tranche_id: TrancheId, + token_name: [u8; TOKEN_NAME_SIZE], + token_symbol: [u8; TOKEN_SYMBOL_SIZE], + }, + /// Update the investment limit of the specified tranche token. Disables + /// investment if the amount is set to zero. + /// + /// Directionality: Centrifuge -> EVM Domain. + UpdateTrancheInvestmentLimit { + pool_id: PoolId, + tranche_id: TrancheId, + amount: Balance, + }, } impl< @@ -302,8 +374,8 @@ impl< PoolId: Encode + Decode, TrancheId: Encode + Decode, Balance: Encode + Decode, - Rate: Encode + Decode, - > Message + Ratio: Encode + Decode, + > Message { /// The call type that identifies a specific Message variant. This value is /// used to encode/decode a Message to/from a bytearray, whereas the head of @@ -316,7 +388,7 @@ impl< Self::Invalid { .. } => 0, Self::AddCurrency { .. } => 1, Self::AddPool { .. } => 2, - Self::AllowPoolCurrency { .. } => 3, + Self::AllowInvestmentCurrency { .. } => 3, Self::AddTranche { .. } => 4, Self::UpdateTrancheTokenPrice { .. } => 5, Self::UpdateMember { .. } => 6, @@ -332,7 +404,12 @@ impl< Self::ExecutedDecreaseRedeemOrder { .. } => 16, Self::ExecutedCollectInvest { .. } => 17, Self::ExecutedCollectRedeem { .. } => 18, + Self::CancelInvestOrder { .. } => 19, + Self::CancelRedeemOrder { .. } => 20, Self::ScheduleUpgrade { .. } => 21, + Self::CancelUpgrade { .. } => 22, + Self::UpdateTrancheTokenMetadata { .. } => 23, + Self::UpdateTrancheInvestmentLimit { .. } => 24, } } } @@ -342,8 +419,8 @@ impl< PoolId: Encode + Decode, TrancheId: Encode + Decode, Balance: Encode + Decode, - Rate: Encode + Decode, - > Codec for Message + Ratio: Encode + Decode, + > Codec for Message { fn serialize(&self) -> Vec { match self { @@ -358,7 +435,7 @@ impl< Message::AddPool { pool_id } => { encoded_message(self.call_type(), vec![encode_be(pool_id)]) } - Message::AllowPoolCurrency { pool_id, currency } => encoded_message( + Message::AllowInvestmentCurrency { pool_id, currency } => encoded_message( self.call_type(), vec![encode_be(pool_id), encode_be(currency)], ), @@ -368,7 +445,6 @@ impl< token_name, token_symbol, decimals, - price, } => encoded_message( self.call_type(), vec![ @@ -377,16 +453,21 @@ impl< token_name.encode(), token_symbol.encode(), decimals.encode(), - encode_be(price), ], ), Message::UpdateTrancheTokenPrice { pool_id, tranche_id, + currency, price, } => encoded_message( self.call_type(), - vec![encode_be(pool_id), tranche_id.encode(), encode_be(price)], + vec![ + encode_be(pool_id), + tranche_id.encode(), + encode_be(currency), + encode_be(price), + ], ), Message::UpdateMember { pool_id, @@ -502,17 +583,29 @@ impl< pool_id, tranche_id, investor, + currency, } => encoded_message( self.call_type(), - vec![encode_be(pool_id), tranche_id.encode(), investor.to_vec()], + vec![ + encode_be(pool_id), + tranche_id.encode(), + investor.to_vec(), + encode_be(currency), + ], ), Message::CollectRedeem { pool_id, tranche_id, investor, + currency, } => encoded_message( self.call_type(), - vec![encode_be(pool_id), tranche_id.encode(), investor.to_vec()], + vec![ + encode_be(pool_id), + tranche_id.encode(), + investor.to_vec(), + encode_be(currency), + ], ), Message::ExecutedDecreaseInvestOrder { pool_id, @@ -520,7 +613,7 @@ impl< investor, currency, currency_payout, - remaining_invest_order, + remaining_invest_amount, } => encoded_message( self.call_type(), vec![ @@ -529,7 +622,7 @@ impl< investor.to_vec(), encode_be(currency), encode_be(currency_payout), - encode_be(remaining_invest_order), + encode_be(remaining_invest_amount), ], ), Message::ExecutedDecreaseRedeemOrder { @@ -538,7 +631,7 @@ impl< investor, currency, tranche_tokens_payout, - remaining_redeem_order, + remaining_redeem_amount, } => encoded_message( self.call_type(), vec![ @@ -547,7 +640,7 @@ impl< investor.to_vec(), encode_be(currency), encode_be(tranche_tokens_payout), - encode_be(remaining_redeem_order), + encode_be(remaining_redeem_amount), ], ), Message::ExecutedCollectInvest { @@ -557,7 +650,7 @@ impl< currency, currency_payout, tranche_tokens_payout, - remaining_invest_order, + remaining_invest_amount, } => encoded_message( self.call_type(), vec![ @@ -567,7 +660,7 @@ impl< encode_be(currency), encode_be(currency_payout), encode_be(tranche_tokens_payout), - encode_be(remaining_invest_order), + encode_be(remaining_invest_amount), ], ), Message::ExecutedCollectRedeem { @@ -577,7 +670,7 @@ impl< currency, currency_payout, tranche_tokens_payout, - remaining_redeem_order, + remaining_redeem_amount, } => encoded_message( self.call_type(), vec![ @@ -587,12 +680,65 @@ impl< encode_be(currency), encode_be(currency_payout), encode_be(tranche_tokens_payout), - encode_be(remaining_redeem_order), + encode_be(remaining_redeem_amount), + ], + ), + Message::CancelInvestOrder { + pool_id, + tranche_id, + investor, + currency, + } => encoded_message( + self.call_type(), + vec![ + encode_be(pool_id), + tranche_id.encode(), + investor.to_vec(), + encode_be(currency), + ], + ), + Message::CancelRedeemOrder { + pool_id, + tranche_id, + investor, + currency, + } => encoded_message( + self.call_type(), + vec![ + encode_be(pool_id), + tranche_id.encode(), + investor.to_vec(), + encode_be(currency), ], ), Message::ScheduleUpgrade { contract } => { encoded_message(self.call_type(), vec![contract.to_vec()]) } + Message::CancelUpgrade { contract } => { + encoded_message(self.call_type(), vec![contract.to_vec()]) + } + Message::UpdateTrancheTokenMetadata { + pool_id, + tranche_id, + token_name, + token_symbol, + } => encoded_message( + self.call_type(), + vec![ + encode_be(pool_id), + tranche_id.encode(), + token_name.encode(), + token_symbol.encode(), + ], + ), + Message::UpdateTrancheInvestmentLimit { + pool_id, + tranche_id, + amount, + } => encoded_message( + self.call_type(), + vec![encode_be(pool_id), tranche_id.encode(), encode_be(amount)], + ), } } @@ -608,7 +754,7 @@ impl< 2 => Ok(Self::AddPool { pool_id: decode_be_bytes::<8, _, _>(input)?, }), - 3 => Ok(Self::AllowPoolCurrency { + 3 => Ok(Self::AllowInvestmentCurrency { pool_id: decode_be_bytes::<8, _, _>(input)?, currency: decode_be_bytes::<16, _, _>(input)?, }), @@ -618,11 +764,11 @@ impl< token_name: decode::(input)?, token_symbol: decode::(input)?, decimals: decode::<1, _, _>(input)?, - price: decode_be_bytes::<16, _, _>(input)?, }), 5 => Ok(Self::UpdateTrancheTokenPrice { pool_id: decode_be_bytes::<8, _, _>(input)?, tranche_id: decode::<16, _, _>(input)?, + currency: decode_be_bytes::<16, _, _>(input)?, price: decode_be_bytes::<16, _, _>(input)?, }), 6 => Ok(Self::UpdateMember { @@ -677,11 +823,13 @@ impl< pool_id: decode_be_bytes::<8, _, _>(input)?, tranche_id: decode::<16, _, _>(input)?, investor: decode::<32, _, _>(input)?, + currency: decode_be_bytes::<16, _, _>(input)?, }), 14 => Ok(Self::CollectRedeem { pool_id: decode_be_bytes::<8, _, _>(input)?, tranche_id: decode::<16, _, _>(input)?, investor: decode::<32, _, _>(input)?, + currency: decode_be_bytes::<16, _, _>(input)?, }), 15 => Ok(Self::ExecutedDecreaseInvestOrder { pool_id: decode_be_bytes::<8, _, _>(input)?, @@ -689,7 +837,7 @@ impl< investor: decode::<32, _, _>(input)?, currency: decode_be_bytes::<16, _, _>(input)?, currency_payout: decode_be_bytes::<16, _, _>(input)?, - remaining_invest_order: decode_be_bytes::<16, _, _>(input)?, + remaining_invest_amount: decode_be_bytes::<16, _, _>(input)?, }), 16 => Ok(Self::ExecutedDecreaseRedeemOrder { pool_id: decode_be_bytes::<8, _, _>(input)?, @@ -697,7 +845,7 @@ impl< investor: decode::<32, _, _>(input)?, currency: decode_be_bytes::<16, _, _>(input)?, tranche_tokens_payout: decode_be_bytes::<16, _, _>(input)?, - remaining_redeem_order: decode_be_bytes::<16, _, _>(input)?, + remaining_redeem_amount: decode_be_bytes::<16, _, _>(input)?, }), 17 => Ok(Self::ExecutedCollectInvest { pool_id: decode_be_bytes::<8, _, _>(input)?, @@ -706,7 +854,7 @@ impl< currency: decode_be_bytes::<16, _, _>(input)?, currency_payout: decode_be_bytes::<16, _, _>(input)?, tranche_tokens_payout: decode_be_bytes::<16, _, _>(input)?, - remaining_invest_order: decode_be_bytes::<16, _, _>(input)?, + remaining_invest_amount: decode_be_bytes::<16, _, _>(input)?, }), 18 => Ok(Self::ExecutedCollectRedeem { pool_id: decode_be_bytes::<8, _, _>(input)?, @@ -715,11 +863,37 @@ impl< currency: decode_be_bytes::<16, _, _>(input)?, currency_payout: decode_be_bytes::<16, _, _>(input)?, tranche_tokens_payout: decode_be_bytes::<16, _, _>(input)?, - remaining_redeem_order: decode_be_bytes::<16, _, _>(input)?, + remaining_redeem_amount: decode_be_bytes::<16, _, _>(input)?, + }), + 19 => Ok(Self::CancelInvestOrder { + pool_id: decode_be_bytes::<8, _, _>(input)?, + tranche_id: decode::<16, _, _>(input)?, + investor: decode::<32, _, _>(input)?, + currency: decode_be_bytes::<16, _, _>(input)?, + }), + 20 => Ok(Self::CancelRedeemOrder { + pool_id: decode_be_bytes::<8, _, _>(input)?, + tranche_id: decode::<16, _, _>(input)?, + investor: decode::<32, _, _>(input)?, + currency: decode_be_bytes::<16, _, _>(input)?, }), 21 => Ok(Self::ScheduleUpgrade { contract: decode::<20, _, _>(input)?, }), + 22 => Ok(Self::CancelUpgrade { + contract: decode::<20, _, _>(input)?, + }), + 23 => Ok(Self::UpdateTrancheTokenMetadata { + pool_id: decode_be_bytes::<8, _, _>(input)?, + tranche_id: decode::<16, _, _>(input)?, + token_name: decode::(input)?, + token_symbol: decode::(input)?, + }), + 24 => Ok(Self::UpdateTrancheInvestmentLimit { + pool_id: decode_be_bytes::<8, _, _>(input)?, + tranche_id: decode::<16, _, _>(input)?, + amount: decode_be_bytes::<16, _, _>(input)?, + }), _ => Err(codec::Error::from( "Unsupported decoding for this Message variant", )), @@ -748,7 +922,7 @@ fn encoded_message(call_type: u8, fields: Vec>) -> Vec { #[cfg(test)] mod tests { use cfg_primitives::{Balance, PoolId, TrancheId}; - use cfg_types::fixed_point::Rate; + use cfg_types::fixed_point::Ratio; use cfg_utils::vec_to_fixed_array; use hex::FromHex; use sp_runtime::traits::One; @@ -756,7 +930,7 @@ mod tests { use super::*; use crate::{Domain, DomainAddress}; - pub type LiquidityPoolsMessage = Message; + pub type LiquidityPoolsMessage = Message; const AMOUNT: Balance = 100000000000000000000000000; const POOL_ID: PoolId = 12378532; @@ -834,7 +1008,7 @@ mod tests { #[test] fn allow_pool_currency() { test_encode_decode_identity( - LiquidityPoolsMessage::AllowPoolCurrency { + LiquidityPoolsMessage::AllowInvestmentCurrency { currency: TOKEN_ID, pool_id: POOL_ID, }, @@ -845,7 +1019,7 @@ mod tests { #[test] fn allow_pool_currency_zero() { test_encode_decode_identity( - LiquidityPoolsMessage::AllowPoolCurrency { + LiquidityPoolsMessage::AllowInvestmentCurrency { currency: 0, pool_id: 0, }, @@ -862,9 +1036,8 @@ mod tests { token_name: vec_to_fixed_array("Some Name".to_string().into_bytes()), token_symbol: vec_to_fixed_array("SYMBOL".to_string().into_bytes()), decimals: 15, - price: Rate::one(), }, - "040000000000000001811acd5b3f17c06841c7e41e9e04cb1b536f6d65204e616d65000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000053594d424f4c00000000000000000000000000000000000000000000000000000f00000000033b2e3c9fd0803ce8000000", + "040000000000000001811acd5b3f17c06841c7e41e9e04cb1b536f6d65204e616d65000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000053594d424f4c00000000000000000000000000000000000000000000000000000f", ) } @@ -874,9 +1047,10 @@ mod tests { LiquidityPoolsMessage::UpdateTrancheTokenPrice { pool_id: 1, tranche_id: default_tranche_id(), - price: Rate::one(), + currency: TOKEN_ID, + price: Ratio::one(), }, - "050000000000000001811acd5b3f17c06841c7e41e9e04cb1b00000000033b2e3c9fd0803ce8000000", + "050000000000000001811acd5b3f17c06841c7e41e9e04cb1b0000000000000000000000000eb5ec7b00000000000000000de0b6b3a7640000", ) } @@ -979,6 +1153,19 @@ mod tests { ) } + #[test] + fn cancel_invest_order() { + test_encode_decode_identity( + LiquidityPoolsMessage::CancelInvestOrder { + pool_id: 1, + tranche_id: default_tranche_id(), + investor: default_address_32(), + currency: TOKEN_ID, + }, + "130000000000000001811acd5b3f17c06841c7e41e9e04cb1b45645645645645645645645645645645645645645645645645645645645645640000000000000000000000000eb5ec7b", + ) + } + #[test] fn increase_redeem_order() { test_encode_decode_identity( @@ -1007,6 +1194,19 @@ mod tests { ) } + #[test] + fn cancel_redeem_order() { + test_encode_decode_identity( + LiquidityPoolsMessage::CancelRedeemOrder { + pool_id: 1, + tranche_id: default_tranche_id(), + investor: default_address_32(), + currency: TOKEN_ID, + }, + "140000000000000001811acd5b3f17c06841c7e41e9e04cb1b45645645645645645645645645645645645645645645645645645645645645640000000000000000000000000eb5ec7b", + ) + } + #[test] fn collect_invest() { test_encode_decode_identity( @@ -1014,8 +1214,9 @@ mod tests { pool_id: 1, tranche_id: default_tranche_id(), investor: default_address_32(), + currency: TOKEN_ID, }, - "0d0000000000000001811acd5b3f17c06841c7e41e9e04cb1b4564564564564564564564564564564564564564564564564564564564564564", + "0d0000000000000001811acd5b3f17c06841c7e41e9e04cb1b45645645645645645645645645645645645645645645645645645645645645640000000000000000000000000eb5ec7b", ) } @@ -1026,8 +1227,9 @@ mod tests { pool_id: POOL_ID, tranche_id: default_tranche_id(), investor: default_address_32(), + currency: TOKEN_ID }, - "0e0000000000bce1a4811acd5b3f17c06841c7e41e9e04cb1b4564564564564564564564564564564564564564564564564564564564564564", + "0e0000000000bce1a4811acd5b3f17c06841c7e41e9e04cb1b45645645645645645645645645645645645645645645645645645645645645640000000000000000000000000eb5ec7b", ) } @@ -1040,9 +1242,9 @@ mod tests { investor: vec_to_fixed_array(default_address_20().to_vec()), currency: TOKEN_ID, currency_payout: AMOUNT / 2, - remaining_invest_order: AMOUNT * 2 + remaining_invest_amount: AMOUNT / 4, }, - "0f0000000000bce1a4811acd5b3f17c06841c7e41e9e04cb1b12312312312312312312312312312312312312310000000000000000000000000000000000000000000000000eb5ec7b0000000000295be96e640669720000000000000000a56fa5b99019a5c8000000", + "0f0000000000bce1a4811acd5b3f17c06841c7e41e9e04cb1b12312312312312312312312312312312312312310000000000000000000000000000000000000000000000000eb5ec7b0000000000295be96e64066972000000000000000014adf4b7320334b9000000", ) } @@ -1055,9 +1257,9 @@ mod tests { investor: vec_to_fixed_array(default_address_20().to_vec()), currency: TOKEN_ID, tranche_tokens_payout: AMOUNT / 2, - remaining_redeem_order: AMOUNT * 2 + remaining_redeem_amount: AMOUNT / 4, }, - "100000000000bce1a4811acd5b3f17c06841c7e41e9e04cb1b12312312312312312312312312312312312312310000000000000000000000000000000000000000000000000eb5ec7b0000000000295be96e640669720000000000000000a56fa5b99019a5c8000000", + "100000000000bce1a4811acd5b3f17c06841c7e41e9e04cb1b12312312312312312312312312312312312312310000000000000000000000000000000000000000000000000eb5ec7b0000000000295be96e64066972000000000000000014adf4b7320334b9000000", ) } @@ -1071,9 +1273,9 @@ mod tests { currency: TOKEN_ID, currency_payout: AMOUNT, tranche_tokens_payout: AMOUNT / 2, - remaining_invest_order: AMOUNT * 3, + remaining_invest_amount: AMOUNT / 4, }, - "110000000000bce1a4811acd5b3f17c06841c7e41e9e04cb1b12312312312312312312312312312312312312310000000000000000000000000000000000000000000000000eb5ec7b000000000052b7d2dcc80cd2e40000000000000000295be96e640669720000000000000000f8277896582678ac000000", + "110000000000bce1a4811acd5b3f17c06841c7e41e9e04cb1b12312312312312312312312312312312312312310000000000000000000000000000000000000000000000000eb5ec7b000000000052b7d2dcc80cd2e40000000000000000295be96e64066972000000000000000014adf4b7320334b9000000", ) } @@ -1087,9 +1289,9 @@ mod tests { currency: TOKEN_ID, currency_payout: AMOUNT, tranche_tokens_payout: AMOUNT / 2, - remaining_redeem_order: AMOUNT * 3, + remaining_redeem_amount: AMOUNT / 4, }, - "120000000000bce1a4811acd5b3f17c06841c7e41e9e04cb1b12312312312312312312312312312312312312310000000000000000000000000000000000000000000000000eb5ec7b000000000052b7d2dcc80cd2e40000000000000000295be96e640669720000000000000000f8277896582678ac000000", + "120000000000bce1a4811acd5b3f17c06841c7e41e9e04cb1b12312312312312312312312312312312312312310000000000000000000000000000000000000000000000000eb5ec7b000000000052b7d2dcc80cd2e40000000000000000295be96e64066972000000000000000014adf4b7320334b9000000", ) } @@ -1103,17 +1305,56 @@ mod tests { ) } + #[test] + fn cancel_upgrade() { + test_encode_decode_identity( + LiquidityPoolsMessage::CancelUpgrade { + contract: default_address_20(), + }, + "161231231231231231231231231231231231231231", + ) + } + + #[test] + fn update_tranche_token_metadata() { + test_encode_decode_identity( + LiquidityPoolsMessage::UpdateTrancheTokenMetadata { + pool_id: 1, + tranche_id: default_tranche_id(), + token_name: vec_to_fixed_array("Some Name".to_string().into_bytes()), + token_symbol: vec_to_fixed_array("SYMBOL".to_string().into_bytes()), + }, + "170000000000000001811acd5b3f17c06841c7e41e9e04cb1b536f6d65204e616d65000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000053594d424f4c0000000000000000000000000000000000000000000000000000", + ) + } + + #[test] + fn update_tranche_investment_limit() { + test_encode_decode_identity( + LiquidityPoolsMessage::UpdateTrancheInvestmentLimit { + pool_id: 1, + tranche_id: default_tranche_id(), + amount: AMOUNT, + }, + "180000000000000001811acd5b3f17c06841c7e41e9e04cb1b000000000052b7d2dcc80cd2e4000000", + ) + } + /// Verify the identity property of decode . encode on a Message value and /// that it in fact encodes to and can be decoded from a given hex string. fn test_encode_decode_identity( - msg: Message, + msg: Message, expected_hex: &str, ) { let encoded = msg.serialize(); assert_eq!(hex::encode(encoded.clone()), expected_hex); - let decoded: Message = - Message::deserialize(&mut hex::decode(expected_hex).expect("").as_slice()).expect(""); + let decoded: Message = Message::deserialize( + &mut hex::decode(expected_hex) + .expect("Decode should work") + .as_slice(), + ) + .expect("Deserialization should work"); assert_eq!(msg, decoded); } diff --git a/pallets/order-book/Cargo.toml b/pallets/order-book/Cargo.toml index 76143e8d6e..5f6dee466a 100644 --- a/pallets/order-book/Cargo.toml +++ b/pallets/order-book/Cargo.toml @@ -1,18 +1,18 @@ [package] name = "pallet-order-book" -description = "Pallet to add order book for currency exchanges. Initially based off Zeitgeist orderbook found here: https://github.com/zeitgeistpm/zeitgeist/tree/main/zrml/orderbook-v1" +description = "Pallet to add order book for currency exchanges. Initially based off Zeitgeist orderbook found here: https://github.com/zeitgeistpm/zeitgeist/tree/main/zrml/orderbook-v1" version = "0.1.0" authors = ["Centrifuge "] -homepage = 'https://centrifuge.io' +homepage = "https://centrifuge.io" license = "LGPL-3.0" -repository = 'https://github.com/centrifuge/centrifuge-chain' +repository = "https://github.com/centrifuge/centrifuge-chain" edition = "2021" [package.metadata.docs.rs] -targets = ['x86_64-unknown-linux-gnu'] +targets = ["x86_64-unknown-linux-gnu"] [dependencies] -codec = { package = 'parity-scale-codec', version = '3.0.0', features = ['derive'], default-features = false } +codec = { package = "parity-scale-codec", version = "3.0.0", features = ["derive"], default-features = false } frame-support = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.38" } frame-system = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.38" } scale-info = { version = "2.3.0", default-features = false, features = ["derive"] } @@ -26,9 +26,8 @@ cfg-primitives = { path = "../../libs/primitives", default-features = false } cfg-traits = { path = "../../libs/traits", default-features = false } cfg-types = { path = "../../libs/types", default-features = false } -## Benchmark dependencies -# Orml crates - +# Benchmark dependencies +## Orml crates orml-asset-registry = { git = "https://github.com/open-web3-stack/open-runtime-module-library", default-features = false, optional = true, branch = "polkadot-v0.9.38" } orml-traits = { git = "https://github.com/open-web3-stack/open-runtime-module-library", default-features = false, branch = "polkadot-v0.9.38" } @@ -37,53 +36,50 @@ frame-benchmarking = { git = "https://github.com/paritytech/substrate", default- [dev-dependencies] cfg-mocks = { path = "../../libs/mocks" } cfg-test-utils = { path = "../../libs/test-utils", default-features = true } +pallet-restricted-tokens = { path = "../restricted-tokens", default-features = false } # Orml crates orml-asset-registry = { git = "https://github.com/open-web3-stack/open-runtime-module-library", default-features = false, branch = "polkadot-v0.9.38" } orml-tokens = { git = "https://github.com/open-web3-stack/open-runtime-module-library", default-features = true, branch = "polkadot-v0.9.38" } # Parity crates pallet-balances = { git = "https://github.com/paritytech/substrate", default-features = true, branch = "polkadot-v0.9.38" } -pallet-restricted-tokens = { path = "../restricted-tokens", default-features = false } sp-io = { git = "https://github.com/paritytech/substrate", default-features = true, branch = "polkadot-v0.9.38" } xcm = { git = "https://github.com/paritytech/polkadot", default-features = false, branch = "release-v0.9.38" } [features] -default = ['std'] - -runtime-benchmarks = [ - 'frame-benchmarking/runtime-benchmarks', - 'frame-support/runtime-benchmarks', - 'frame-system/runtime-benchmarks', - 'sp-runtime/runtime-benchmarks', - 'cfg-types/runtime-benchmarks', - 'cfg-traits/runtime-benchmarks', - 'cfg-primitives/runtime-benchmarks', - 'orml-asset-registry/runtime-benchmarks', - 'cfg-mocks/runtime-benchmarks', - 'cfg-test-utils/runtime-benchmarks', - -] - +default = ["std"] std = [ - 'codec/std', - 'frame-support/std', - 'frame-system/std', - 'frame-benchmarking/std', - 'scale-info/std', - 'serde/std', - 'sp-core/std', - 'sp-arithmetic/std', - 'sp-runtime/std', - 'sp-std/std', - 'orml-traits/std', - 'cfg-primitives/std', - 'cfg-types/std', - 'cfg-traits/std', + "codec/std", + "frame-support/std", + "frame-system/std", + "frame-benchmarking/std", + "scale-info/std", + "serde/std", + "sp-core/std", + "sp-arithmetic/std", + "sp-runtime/std", + "sp-std/std", + "orml-traits/std", + "cfg-primitives/std", + "cfg-types/std", + "cfg-traits/std", +] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", + "cfg-types/runtime-benchmarks", + "cfg-traits/runtime-benchmarks", + "cfg-primitives/runtime-benchmarks", + "orml-asset-registry/runtime-benchmarks", + "cfg-mocks/runtime-benchmarks", + "cfg-test-utils/runtime-benchmarks", ] try-runtime = [ - 'frame-support/try-runtime', - 'frame-system/try-runtime', - 'sp-runtime/try-runtime', - 'cfg-primitives/try-runtime', - 'cfg-types/try-runtime', - 'cfg-traits/try-runtime', + "frame-support/try-runtime", + "frame-system/try-runtime", + "sp-runtime/try-runtime", + "cfg-primitives/try-runtime", + "cfg-types/try-runtime", + "cfg-traits/try-runtime", ] diff --git a/pallets/order-book/src/lib.rs b/pallets/order-book/src/lib.rs index 2bbed407ac..1b97fe1c57 100644 --- a/pallets/order-book/src/lib.rs +++ b/pallets/order-book/src/lib.rs @@ -16,7 +16,7 @@ #![cfg_attr(not(feature = "std"), no_std)] -//! This module adds an orderbook pallet, allowing oders for currency swaps to +//! This module adds an orderbook pallet, allowing orders for currency swaps to //! be placed and fulfilled for currencies in an asset registry. #[cfg(test)] @@ -40,7 +40,8 @@ pub mod pallet { use core::fmt::Debug; use cfg_primitives::conversion::convert_balance_decimals; - use cfg_types::tokens::CustomMetadata; + use cfg_traits::StatusNotificationHook; + use cfg_types::{investments::Swap, tokens::CustomMetadata}; use codec::{Decode, Encode, MaxEncodedLen}; use frame_support::{ pallet_prelude::{DispatchResult, Member, StorageDoubleMap, StorageValue, *}, @@ -150,6 +151,13 @@ pub mod pallet { #[pallet::constant] type OrderPairVecSize: Get; + /// The hook which acts upon a (partially) fulfilled order + type FulfilledOrderHook: StatusNotificationHook< + Id = Self::OrderIdNonce, + Status = Swap, + Error = DispatchError, + >; + /// The admin origin of this pallet type AdminOrigin: EnsureOrigin; @@ -177,7 +185,7 @@ pub mod pallet { pub max_sell_rate: SellRatio, /// Minimum amount of an order that can be fulfilled /// for partial fulfillment - pub min_fullfillment_amount: ForeignCurrencyBalance, + pub min_fulfillment_amount: ForeignCurrencyBalance, /// Maximum amount of outgoing currency that can be sold pub max_sell_amount: ForeignCurrencyBalance, } @@ -216,6 +224,8 @@ pub mod pallet { /// Map of Vec containing OrderIds of same asset in/out pairs. /// Allows looking up orders available corresponding pairs. + /// + /// NOTE: The key order is (currency_in, currency_out). #[pallet::storage] pub type AssetPairOrders = StorageDoubleMap< _, @@ -255,7 +265,7 @@ pub mod pallet { currency_in: T::AssetCurrencyId, currency_out: T::AssetCurrencyId, buy_amount: T::Balance, - min_fullfillment_amount: T::Balance, + min_fulfillment_amount: T::Balance, sell_rate_limit: T::SellRatio, }, /// Event emitted when an order is cancelled. @@ -269,7 +279,7 @@ pub mod pallet { account: T::AccountId, buy_amount: T::Balance, sell_rate_limit: T::SellRatio, - min_fullfillment_amount: T::Balance, + min_fulfillment_amount: T::Balance, }, /// Event emitted when an order is fulfilled. /// Can be for either partial or total fulfillment. @@ -377,7 +387,7 @@ pub mod pallet { order.asset_out_id, order.buy_amount, order.max_sell_rate, - order.min_fullfillment_amount, + order.min_fulfillment_amount, min_amount, ) }, @@ -415,7 +425,7 @@ pub mod pallet { order.asset_out_id, order.buy_amount, order.max_sell_rate, - order.min_fullfillment_amount, + order.min_fulfillment_amount, min_amount, ) }, @@ -471,11 +481,21 @@ pub mod pallet { false, )?; Self::remove_order(order.order_id)?; + + T::FulfilledOrderHook::notify_status_change( + order_id, + Swap { + amount: order.buy_amount, + currency_in: order.asset_in_id, + currency_out: order.asset_out_id, + }, + )?; + Self::deposit_event(Event::OrderFulfillment { order_id, placing_account: order.placing_account, fulfilling_account: account_id, - partial_fulfillment: true, + partial_fulfillment: false, currency_in: order.asset_in_id, currency_out: order.asset_out_id, fulfillment_amount: order.buy_amount, @@ -613,6 +633,7 @@ pub mod pallet { let to_decimals = T::AssetRegistry::metadata(¤cy_to) .ok_or(Error::::InvalidAssetId)? .decimals; + convert_balance_decimals(from_decimals, to_decimals, ratio.ensure_mul_int(amount)?) .map_err(DispatchError::from) } @@ -624,7 +645,7 @@ pub mod pallet { currency_out: T::AssetCurrencyId, buy_amount: T::Balance, sell_rate_limit: T::SellRatio, - min_fullfillment_amount: T::Balance, + min_fulfillment_amount: T::Balance, min_order_amount: T::Balance, ) -> DispatchResult { ensure!(currency_in != currency_out, Error::::ConflictingAssetIds); @@ -635,7 +656,7 @@ pub mod pallet { ); ensure!( - min_fullfillment_amount != ::zero(), + min_fulfillment_amount != ::zero(), Error::::InvalidMinimumFulfillment ); ensure!( @@ -644,7 +665,7 @@ pub mod pallet { ); ensure!( - buy_amount >= min_fullfillment_amount, + buy_amount >= min_fulfillment_amount, Error::::InvalidBuyAmount ); @@ -661,7 +682,7 @@ pub mod pallet { order_id: T::OrderIdNonce, buy_amount: T::Balance, sell_rate_limit: T::SellRatio, - min_fullfillment_amount: T::Balance, + min_fulfillment_amount: T::Balance, validate: impl FnOnce(&OrderOf) -> DispatchResult, ) -> DispatchResult { let max_sell_amount = >::try_mutate_exists( @@ -701,7 +722,7 @@ pub mod pallet { }; order.buy_amount = buy_amount; order.max_sell_rate = sell_rate_limit; - order.min_fullfillment_amount = min_fullfillment_amount; + order.min_fulfillment_amount = min_fulfillment_amount; order.max_sell_amount = max_sell_amount; validate(order)?; @@ -717,7 +738,7 @@ pub mod pallet { let mut order = maybe_order.as_mut().ok_or(Error::::OrderNotFound)?; order.buy_amount = buy_amount; order.max_sell_rate = sell_rate_limit; - order.min_fullfillment_amount = min_fullfillment_amount; + order.min_fulfillment_amount = min_fulfillment_amount; order.max_sell_amount = max_sell_amount; Ok(()) }, @@ -727,7 +748,7 @@ pub mod pallet { order_id, buy_amount, sell_rate_limit, - min_fullfillment_amount, + min_fulfillment_amount, }); Ok(()) @@ -739,7 +760,7 @@ pub mod pallet { currency_out: T::AssetCurrencyId, buy_amount: T::Balance, sell_rate_limit: T::SellRatio, - min_fullfillment_amount: T::Balance, + min_fulfillment_amount: T::Balance, validate: impl FnOnce(&OrderOf) -> DispatchResult, ) -> Result { >::try_mutate(|n| { @@ -761,7 +782,7 @@ pub mod pallet { buy_amount, max_sell_rate: sell_rate_limit, initial_buy_amount: buy_amount, - min_fullfillment_amount, + min_fulfillment_amount, max_sell_amount, }; @@ -782,7 +803,7 @@ pub mod pallet { buy_amount, currency_in, currency_out, - min_fullfillment_amount, + min_fulfillment_amount, }); Ok(order_id) @@ -795,6 +816,7 @@ pub mod pallet { { type Balance = T::Balance; type CurrencyId = T::AssetCurrencyId; + type OrderDetails = Swap; type OrderId = T::OrderIdNonce; type SellRatio = T::SellRatio; @@ -808,7 +830,7 @@ pub mod pallet { currency_out: T::AssetCurrencyId, buy_amount: T::Balance, sell_rate_limit: T::SellRatio, - min_fullfillment_amount: T::Balance, + min_fulfillment_amount: T::Balance, ) -> Result { Self::inner_place_order( account, @@ -816,7 +838,7 @@ pub mod pallet { currency_out, buy_amount, sell_rate_limit, - min_fullfillment_amount, + min_fulfillment_amount, |order| { // We only check if the trading pair exists not if the minimum amount is // reached. @@ -826,7 +848,7 @@ pub mod pallet { order.asset_out_id, order.buy_amount, order.max_sell_rate, - order.min_fullfillment_amount, + order.min_fulfillment_amount, T::Balance::zero(), ) }, @@ -857,14 +879,14 @@ pub mod pallet { order_id: Self::OrderId, buy_amount: T::Balance, sell_rate_limit: T::SellRatio, - min_fullfillment_amount: T::Balance, + min_fulfillment_amount: T::Balance, ) -> DispatchResult { Self::inner_update_order( account, order_id, buy_amount, sell_rate_limit, - min_fullfillment_amount, + min_fulfillment_amount, |order| { // We only check if the trading pair exists not if the minimum amount is // reached. @@ -875,7 +897,7 @@ pub mod pallet { order.asset_out_id, order.buy_amount, order.max_sell_rate, - order.min_fullfillment_amount, + order.min_fulfillment_amount, T::Balance::zero(), ) }, @@ -887,7 +909,17 @@ pub mod pallet { >::contains_key(order) } - fn valid_pair(currency_out: Self::CurrencyId, currency_in: Self::CurrencyId) -> bool { + fn get_order_details(order: Self::OrderId) -> Option> { + Orders::::get(order) + .map(|order| Swap { + amount: order.buy_amount, + currency_in: order.asset_in_id, + currency_out: order.asset_out_id, + }) + .ok() + } + + fn valid_pair(currency_in: Self::CurrencyId, currency_out: Self::CurrencyId) -> bool { TradingPair::::get(currency_in, currency_out).is_ok() } } diff --git a/pallets/order-book/src/mock.rs b/pallets/order-book/src/mock.rs index 1a744f1c59..d558b87aea 100644 --- a/pallets/order-book/src/mock.rs +++ b/pallets/order-book/src/mock.rs @@ -12,8 +12,13 @@ use cfg_mocks::pallet_mock_fees; use cfg_primitives::CFG; -use cfg_types::tokens::{CurrencyId, CustomMetadata}; +use cfg_traits::StatusNotificationHook; +use cfg_types::{ + investments::Swap, + tokens::{CurrencyId, CustomMetadata}, +}; use frame_support::{ + pallet_prelude::DispatchResult, parameter_types, traits::{ConstU128, ConstU32, GenesisBuild}, }; @@ -180,6 +185,17 @@ parameter_types! { pub const OrderPairVecSize: u32 = 1_000_000u32; } +pub struct DummyHook; +impl StatusNotificationHook for DummyHook { + type Error = sp_runtime::DispatchError; + type Id = u64; + type Status = Swap; + + fn notify_status_change(_id: u64, _status: Self::Status) -> DispatchResult { + Ok(()) + } +} + parameter_type_with_key! { pub MinimumOrderAmount: |pair: (CurrencyId, CurrencyId)| -> Option { match pair { @@ -199,6 +215,7 @@ impl order_book::Config for Runtime { type AssetCurrencyId = CurrencyId; type AssetRegistry = RegistryMock; type Balance = Balance; + type FulfilledOrderHook = DummyHook; type OrderIdNonce = u64; type OrderPairVecSize = OrderPairVecSize; type RuntimeEvent = RuntimeEvent; diff --git a/pallets/order-book/src/tests.rs b/pallets/order-book/src/tests.rs index 947aa0b5c7..67f6f4b32b 100644 --- a/pallets/order-book/src/tests.rs +++ b/pallets/order-book/src/tests.rs @@ -31,6 +31,10 @@ fn adding_trading_pair_works() { TradingPair::::get(DEV_AUSD_CURRENCY_ID, DEV_USDT_CURRENCY_ID).unwrap(), 100 * CURRENCY_AUSD_DECIMALS ); + assert!(OrderBook::valid_pair( + DEV_AUSD_CURRENCY_ID, + DEV_USDT_CURRENCY_ID + )); }) } @@ -50,6 +54,10 @@ fn adding_trading_pair_fails() { TradingPair::::get(DEV_AUSD_CURRENCY_ID, DEV_USDT_CURRENCY_ID), Error::::InvalidTradingPair ); + assert!(!OrderBook::valid_pair( + DEV_AUSD_CURRENCY_ID, + DEV_USDT_CURRENCY_ID + )); }) } @@ -118,6 +126,10 @@ fn updating_min_order_fails() { TradingPair::::get(DEV_AUSD_CURRENCY_ID, DEV_USDT_CURRENCY_ID).unwrap(), 5 * CURRENCY_AUSD_DECIMALS ); + assert!(OrderBook::valid_pair( + DEV_AUSD_CURRENCY_ID, + DEV_USDT_CURRENCY_ID + )); }) } @@ -157,7 +169,7 @@ fn create_order_works() { buy_amount: 100 * CURRENCY_AUSD_DECIMALS, initial_buy_amount: 100 * CURRENCY_AUSD_DECIMALS, max_sell_rate: FixedU128::checked_from_rational(3u32, 2u32).unwrap(), - min_fullfillment_amount: 100 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 100 * CURRENCY_AUSD_DECIMALS, max_sell_amount: 150 * CURRENCY_USDT_DECIMALS }) ); @@ -171,7 +183,7 @@ fn create_order_works() { buy_amount: 100 * CURRENCY_AUSD_DECIMALS, initial_buy_amount: 100 * CURRENCY_AUSD_DECIMALS, max_sell_rate: FixedU128::checked_from_rational(3u32, 2u32).unwrap(), - min_fullfillment_amount: 100 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 100 * CURRENCY_AUSD_DECIMALS, max_sell_amount: 150 * CURRENCY_USDT_DECIMALS }) ); @@ -210,7 +222,7 @@ fn user_update_order_works() { buy_amount: 15 * CURRENCY_AUSD_DECIMALS, initial_buy_amount: 10 * CURRENCY_AUSD_DECIMALS, max_sell_rate: FixedU128::checked_from_integer(2u32).unwrap(), - min_fullfillment_amount: 15 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 15 * CURRENCY_AUSD_DECIMALS, max_sell_amount: 30 * CURRENCY_USDT_DECIMALS }) ); @@ -299,7 +311,7 @@ fn user_cancel_order_only_works_for_valid_account() { buy_amount: 100 * CURRENCY_AUSD_DECIMALS, initial_buy_amount: 100 * CURRENCY_AUSD_DECIMALS, max_sell_rate: FixedU128::checked_from_rational(3u32, 2u32).unwrap(), - min_fullfillment_amount: 100 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 100 * CURRENCY_AUSD_DECIMALS, max_sell_amount: 150 * CURRENCY_USDT_DECIMALS }) ); @@ -410,7 +422,7 @@ fn place_order_works() { buy_amount: 100 * CURRENCY_AUSD_DECIMALS, initial_buy_amount: 100 * CURRENCY_AUSD_DECIMALS, max_sell_rate: FixedU128::checked_from_rational(3u32, 2u32).unwrap(), - min_fullfillment_amount: 100 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 100 * CURRENCY_AUSD_DECIMALS, max_sell_amount: 150 * CURRENCY_USDT_DECIMALS }) ); @@ -425,7 +437,7 @@ fn place_order_works() { buy_amount: 100 * CURRENCY_AUSD_DECIMALS, initial_buy_amount: 100 * CURRENCY_AUSD_DECIMALS, max_sell_rate: FixedU128::checked_from_rational(3u32, 2u32).unwrap(), - min_fullfillment_amount: 100 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 100 * CURRENCY_AUSD_DECIMALS, max_sell_amount: 150 * CURRENCY_USDT_DECIMALS }) ); @@ -451,7 +463,7 @@ fn place_order_works() { currency_in: DEV_AUSD_CURRENCY_ID, currency_out: DEV_USDT_CURRENCY_ID, buy_amount: 100 * CURRENCY_AUSD_DECIMALS, - min_fullfillment_amount: 100 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 100 * CURRENCY_AUSD_DECIMALS, sell_rate_limit: FixedU128::checked_from_rational(3u32, 2u32).unwrap(), }) ); @@ -480,7 +492,7 @@ fn place_order_bases_max_sell_off_buy() { buy_amount: 100 * CURRENCY_AUSD_DECIMALS, initial_buy_amount: 100 * CURRENCY_AUSD_DECIMALS, max_sell_rate: FixedU128::checked_from_rational(3u32, 2u32).unwrap(), - min_fullfillment_amount: 10 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 10 * CURRENCY_AUSD_DECIMALS, max_sell_amount: 150 * CURRENCY_USDT_DECIMALS }) ); @@ -493,7 +505,7 @@ fn place_order_bases_max_sell_off_buy() { currency_in: DEV_AUSD_CURRENCY_ID, currency_out: DEV_USDT_CURRENCY_ID, buy_amount: 100 * CURRENCY_AUSD_DECIMALS, - min_fullfillment_amount: 10 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 10 * CURRENCY_AUSD_DECIMALS, sell_rate_limit: FixedU128::checked_from_rational(3u32, 2u32).unwrap(), }) ); @@ -699,7 +711,7 @@ fn update_order_works_with_order_increase() { buy_amount: 15 * CURRENCY_AUSD_DECIMALS, initial_buy_amount: 10 * CURRENCY_AUSD_DECIMALS, max_sell_rate: FixedU128::checked_from_integer(2u32).unwrap(), - min_fullfillment_amount: 5 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 5 * CURRENCY_AUSD_DECIMALS, max_sell_amount: 30 * CURRENCY_USDT_DECIMALS }) ); @@ -714,7 +726,7 @@ fn update_order_works_with_order_increase() { buy_amount: 15 * CURRENCY_AUSD_DECIMALS, initial_buy_amount: 10 * CURRENCY_AUSD_DECIMALS, max_sell_rate: FixedU128::checked_from_integer(2u32).unwrap(), - min_fullfillment_amount: 5 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 5 * CURRENCY_AUSD_DECIMALS, max_sell_amount: 30 * CURRENCY_USDT_DECIMALS }) ); @@ -745,7 +757,7 @@ fn update_order_works_with_order_increase() { order_id, account: ACCOUNT_0, buy_amount: 15 * CURRENCY_AUSD_DECIMALS, - min_fullfillment_amount: 5 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 5 * CURRENCY_AUSD_DECIMALS, sell_rate_limit: FixedU128::checked_from_integer(2u32).unwrap() }) ); @@ -784,7 +796,7 @@ fn update_order_updates_min_fulfillment() { initial_buy_amount: 10 * CURRENCY_AUSD_DECIMALS, max_sell_rate: FixedU128::checked_from_rational(3u32, 2u32).unwrap(), - min_fullfillment_amount: 6 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 6 * CURRENCY_AUSD_DECIMALS, max_sell_amount: 15 * CURRENCY_USDT_DECIMALS }) ); @@ -800,7 +812,7 @@ fn update_order_updates_min_fulfillment() { initial_buy_amount: 10 * CURRENCY_AUSD_DECIMALS, max_sell_rate: FixedU128::checked_from_rational(3u32, 2u32).unwrap(), - min_fullfillment_amount: 6 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 6 * CURRENCY_AUSD_DECIMALS, max_sell_amount: 15 * CURRENCY_USDT_DECIMALS }) ); @@ -813,7 +825,7 @@ fn update_order_updates_min_fulfillment() { order_id, account: ACCOUNT_0, buy_amount: 10 * CURRENCY_AUSD_DECIMALS, - min_fullfillment_amount: 6 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 6 * CURRENCY_AUSD_DECIMALS, sell_rate_limit: FixedU128::checked_from_rational(3u32, 2u32).unwrap() }) ); @@ -849,7 +861,7 @@ fn update_order_works_with_order_decrease() { buy_amount: 10 * CURRENCY_AUSD_DECIMALS, initial_buy_amount: 15 * CURRENCY_AUSD_DECIMALS, max_sell_rate: FixedU128::checked_from_integer(1u32).unwrap(), - min_fullfillment_amount: 5 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 5 * CURRENCY_AUSD_DECIMALS, max_sell_amount: 10 * CURRENCY_USDT_DECIMALS }) ); @@ -864,7 +876,7 @@ fn update_order_works_with_order_decrease() { buy_amount: 10 * CURRENCY_AUSD_DECIMALS, initial_buy_amount: 15 * CURRENCY_AUSD_DECIMALS, max_sell_rate: FixedU128::checked_from_integer(1u32).unwrap(), - min_fullfillment_amount: 5 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 5 * CURRENCY_AUSD_DECIMALS, max_sell_amount: 10 * CURRENCY_USDT_DECIMALS }) ); @@ -895,7 +907,7 @@ fn update_order_works_with_order_decrease() { order_id, account: ACCOUNT_0, buy_amount: 10 * CURRENCY_AUSD_DECIMALS, - min_fullfillment_amount: 5 * CURRENCY_AUSD_DECIMALS, + min_fulfillment_amount: 5 * CURRENCY_AUSD_DECIMALS, sell_rate_limit: FixedU128::checked_from_integer(1u32).unwrap() }) ); @@ -1023,6 +1035,30 @@ fn update_order_requires_non_zero_price() { }) } +#[test] +fn get_order_details_works() { + new_test_ext().execute_with(|| { + assert_ok!(OrderBook::place_order( + ACCOUNT_0, + DEV_AUSD_CURRENCY_ID, + DEV_USDT_CURRENCY_ID, + 15 * CURRENCY_AUSD_DECIMALS, + FixedU128::checked_from_rational(3u32, 2u32).unwrap(), + 5 * CURRENCY_AUSD_DECIMALS + )); + let (order_id, _) = get_account_orders(ACCOUNT_0).unwrap()[0]; + assert_eq!( + OrderBook::get_order_details(order_id), + Some(cfg_types::investments::Swap { + amount: 15 * CURRENCY_AUSD_DECIMALS, + currency_in: DEV_AUSD_CURRENCY_ID, + currency_out: DEV_USDT_CURRENCY_ID + }) + ); + assert!(OrderBook::get_order_details(order_id + 1).is_none()); + }); +} + pub fn get_account_orders( account_id: ::AccountId, ) -> Result::OrderIdNonce, OrderOf)>, Error> diff --git a/pallets/pool-registry/src/benchmarking.rs b/pallets/pool-registry/src/benchmarking.rs index ceea49e11b..98d52bc558 100644 --- a/pallets/pool-registry/src/benchmarking.rs +++ b/pallets/pool-registry/src/benchmarking.rs @@ -13,7 +13,7 @@ //! Module provides benchmarking for Loan Pallet use cfg_primitives::{Moment, PoolEpochId}; -use cfg_traits::{InvestmentAccountant, InvestmentProperties, TrancheCurrency as _}; +use cfg_traits::investments::{InvestmentAccountant, InvestmentProperties, TrancheCurrency as _}; use cfg_types::{ pools::TrancheMetadata, tokens::{CurrencyId, TrancheCurrency}, diff --git a/pallets/pool-registry/src/lib.rs b/pallets/pool-registry/src/lib.rs index 070fc5c928..b31c9cc546 100644 --- a/pallets/pool-registry/src/lib.rs +++ b/pallets/pool-registry/src/lib.rs @@ -13,7 +13,9 @@ #![cfg_attr(not(feature = "std"), no_std)] use cfg_primitives::Moment; -use cfg_traits::{Permissions, PoolMutate, PoolWriteOffPolicyMutate, TrancheCurrency, UpdateState}; +use cfg_traits::{ + investments::TrancheCurrency, Permissions, PoolMutate, PoolWriteOffPolicyMutate, UpdateState, +}; use cfg_types::{ permissions::{PermissionScope, PoolRole, Role}, pools::{PoolMetadata, PoolRegistrationStatus}, diff --git a/pallets/pool-registry/src/mock.rs b/pallets/pool-registry/src/mock.rs index c3ac07cf36..2347dd0878 100644 --- a/pallets/pool-registry/src/mock.rs +++ b/pallets/pool-registry/src/mock.rs @@ -13,7 +13,9 @@ use std::marker::PhantomData; use cfg_mocks::pallet_mock_write_off_policy; use cfg_primitives::{BlockNumber, CollectionId, Moment, PoolEpochId, TrancheWeight}; -use cfg_traits::{OrderManager, PoolMutate, PoolUpdateGuard, PreConditions, UpdateState}; +use cfg_traits::{ + investments::OrderManager, PoolMutate, PoolUpdateGuard, PreConditions, UpdateState, +}; use cfg_types::{ fixed_point::{Quantity, Rate}, permissions::{PermissionScope, Role}, @@ -300,6 +302,16 @@ impl orml_tokens::Config for Test { type WeightInfo = (); } +pub struct NoopCollectHook; +impl cfg_traits::StatusNotificationHook for NoopCollectHook { + type Error = DispatchError; + type Id = cfg_types::investments::ForeignInvestmentInfo; + type Status = cfg_types::investments::CollectedAmount; + + fn notify_status_change(_id: Self::Id, _status: Self::Status) -> DispatchResult { + Ok(()) + } +} parameter_types! { pub const MaxOutstandingCollects: u32 = 10; } @@ -307,6 +319,8 @@ impl pallet_investments::Config for Test { type Accountant = PoolSystem; type Amount = Balance; type BalanceRatio = Quantity; + type CollectedInvestmentHook = NoopCollectHook; + type CollectedRedemptionHook = NoopCollectHook; type InvestmentId = TrancheCurrency; type MaxOutstandingCollects = MaxOutstandingCollects; type PreConditions = Always; diff --git a/pallets/pool-system/src/benchmarking.rs b/pallets/pool-system/src/benchmarking.rs index 254599e3dc..b4b5f44c15 100644 --- a/pallets/pool-system/src/benchmarking.rs +++ b/pallets/pool-system/src/benchmarking.rs @@ -13,7 +13,10 @@ //! Module provides benchmarking for Loan Pallet use cfg_primitives::PoolEpochId; -use cfg_traits::{InvestmentAccountant, InvestmentProperties, TrancheCurrency as _, UpdateState}; +use cfg_traits::{ + investments::{InvestmentAccountant, InvestmentProperties, TrancheCurrency as _}, + UpdateState, +}; use cfg_types::{ pools::TrancheMetadata, tokens::{CurrencyId, CustomMetadata, TrancheCurrency}, diff --git a/pallets/pool-system/src/impls.rs b/pallets/pool-system/src/impls.rs index 439e81b41b..413cc37d9b 100644 --- a/pallets/pool-system/src/impls.rs +++ b/pallets/pool-system/src/impls.rs @@ -11,8 +11,9 @@ // GNU General Public License for more details. use cfg_traits::{ - changes::ChangeGuard, CurrencyPair, InvestmentAccountant, PoolUpdateGuard, PriceValue, - TrancheCurrency, TrancheTokenPrice, UpdateState, + changes::ChangeGuard, + investments::{InvestmentAccountant, TrancheCurrency}, + CurrencyPair, PoolUpdateGuard, PriceValue, TrancheTokenPrice, UpdateState, }; use cfg_types::{epoch::EpochState, investments::InvestmentInfo}; use frame_support::traits::Contains; @@ -443,7 +444,7 @@ impl ChangeGuard for Pallet { #[cfg(feature = "runtime-benchmarks")] mod benchmarks_utils { - use cfg_traits::{Investment, PoolBenchmarkHelper}; + use cfg_traits::{investments::Investment, PoolBenchmarkHelper}; use cfg_types::{ pools::TrancheMetadata, tokens::{CurrencyId, CustomMetadata}, diff --git a/pallets/pool-system/src/lib.rs b/pallets/pool-system/src/lib.rs index 6eba1a6b56..fcbf075760 100644 --- a/pallets/pool-system/src/lib.rs +++ b/pallets/pool-system/src/lib.rs @@ -179,7 +179,10 @@ impl Default for Release { #[frame_support::pallet] pub mod pallet { - use cfg_traits::{OrderManager, PoolUpdateGuard, TrancheCurrency as TrancheCurrencyT}; + use cfg_traits::{ + investments::{OrderManager, TrancheCurrency as TrancheCurrencyT}, + PoolUpdateGuard, + }; use cfg_types::{ orders::{FulfillmentWithPrice, TotalOrder}, tokens::CustomMetadata, diff --git a/pallets/pool-system/src/mock.rs b/pallets/pool-system/src/mock.rs index be93e3240a..6e90c8f7f9 100644 --- a/pallets/pool-system/src/mock.rs +++ b/pallets/pool-system/src/mock.rs @@ -12,8 +12,8 @@ use cfg_primitives::{Balance, BlockNumber, CollectionId, PoolId, TrancheId}; pub use cfg_primitives::{Moment, PoolEpochId, TrancheWeight}; use cfg_traits::{ - OrderManager, Permissions as PermissionsT, PoolUpdateGuard, PreConditions, - TrancheCurrency as TrancheCurrencyT, + investments::{OrderManager, TrancheCurrency as TrancheCurrencyT}, + Permissions as PermissionsT, PoolUpdateGuard, PreConditions, }; pub use cfg_types::fixed_point::{Quantity, Rate}; use cfg_types::{ @@ -250,6 +250,16 @@ where } } +pub struct NoopCollectHook; +impl cfg_traits::StatusNotificationHook for NoopCollectHook { + type Error = sp_runtime::DispatchError; + type Id = cfg_types::investments::ForeignInvestmentInfo; + type Status = cfg_types::investments::CollectedAmount; + + fn notify_status_change(_id: Self::Id, _status: Self::Status) -> DispatchResult { + Ok(()) + } +} parameter_types! { pub const MaxOutstandingCollects: u32 = 10; } @@ -257,6 +267,8 @@ impl pallet_investments::Config for Runtime { type Accountant = PoolSystem; type Amount = Balance; type BalanceRatio = Quantity; + type CollectedInvestmentHook = NoopCollectHook; + type CollectedRedemptionHook = NoopCollectHook; type InvestmentId = TrancheCurrency; type MaxOutstandingCollects = MaxOutstandingCollects; type PreConditions = Always; diff --git a/pallets/pool-system/src/pool_types.rs b/pallets/pool-system/src/pool_types.rs index adc170a525..7c0d08a14c 100644 --- a/pallets/pool-system/src/pool_types.rs +++ b/pallets/pool-system/src/pool_types.rs @@ -251,7 +251,7 @@ impl< EpochId: BaseArithmetic + Copy, PoolId: Copy + Encode, Rate: FixedPointNumber, - TrancheCurrency: Copy + cfg_traits::TrancheCurrency, + TrancheCurrency: Copy + cfg_traits::investments::TrancheCurrency, TrancheId: Clone + From<[u8; 16]> + PartialEq, Weight: Copy + From, MaxTranches: Get, diff --git a/pallets/pool-system/src/tests/mod.rs b/pallets/pool-system/src/tests/mod.rs index 28dfc92af8..f9f6bf3941 100644 --- a/pallets/pool-system/src/tests/mod.rs +++ b/pallets/pool-system/src/tests/mod.rs @@ -10,7 +10,7 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. -use cfg_traits::{PoolMutate, TrancheCurrency as TrancheCurrencyT, TrancheTokenPrice}; +use cfg_traits::{investments::TrancheCurrency as TrancheCurrencyT, PoolMutate, TrancheTokenPrice}; use cfg_types::{ epoch::EpochState, fixed_point::Rate, diff --git a/pallets/pool-system/src/tranches.rs b/pallets/pool-system/src/tranches.rs index 73cef0c2db..d06b844f9e 100644 --- a/pallets/pool-system/src/tranches.rs +++ b/pallets/pool-system/src/tranches.rs @@ -13,7 +13,7 @@ use cfg_primitives::Moment; #[cfg(test)] use cfg_primitives::{Balance, PoolId, TrancheId, TrancheWeight}; -use cfg_traits::TrancheCurrency as TrancheCurrencyT; +use cfg_traits::investments::TrancheCurrency as TrancheCurrencyT; #[cfg(test)] use cfg_types::{ fixed_point::{Quantity, Rate}, @@ -174,8 +174,8 @@ where } /// Update the debt of a Tranche by multiplying with the accrued interest - /// since the last update: debt = debt * interest_rate_per_second ^ (now - /// - last_update) + /// since the last update: + /// debt = debt * interest_rate_per_second ^ (now - last_update) pub fn accrue(&mut self, now: Moment) -> Result<(), ArithmeticError> { let delta = now - self.last_updated_interest; let interest = self.interest_rate_per_sec(); diff --git a/runtime/altair/Cargo.toml b/runtime/altair/Cargo.toml index 36efdc4faf..a7273f69d3 100644 --- a/runtime/altair/Cargo.toml +++ b/runtime/altair/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "altair-runtime" -version = "0.10.33" +version = "0.10.34" authors = ["Centrifuge "] edition = "2021" build = "build.rs" @@ -54,7 +54,9 @@ sp-version = { git = "https://github.com/paritytech/substrate", default-features # frame dependencies frame-benchmarking = { git = "https://github.com/paritytech/substrate", default-features = false, optional = true, branch = "polkadot-v0.9.38" } frame-executive = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.38" } -frame-support = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.38" } +frame-support = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.38", features = [ + "tuples-96", +] } frame-system = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.38" } frame-system-benchmarking = { git = "https://github.com/paritytech/substrate", default-features = false, optional = true, branch = "polkadot-v0.9.38" } frame-system-rpc-runtime-api = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.38" } @@ -113,6 +115,7 @@ pallet-crowdloan-reward = { path = "../../pallets/crowdloan-reward", default-fea pallet-data-collector = { path = "../../pallets/data-collector", default-features = false } pallet-ethereum-transaction = { path = "../../pallets/ethereum-transaction", default-features = false } pallet-fees = { path = "../../pallets/fees", default-features = false } +pallet-foreign-investments = { path = "../../pallets/foreign-investments", default-features = false } pallet-interest-accrual = { path = "../../pallets/interest-accrual", default-features = false } pallet-investments = { path = "../../pallets/investments", default-features = false } pallet-keystore = { path = "../../pallets/keystore", default-features = false } @@ -122,6 +125,7 @@ pallet-liquidity-rewards = { path = "../../pallets/liquidity-rewards", default-f pallet-loans = { path = "../../pallets/loans", default-features = false } pallet-migration-manager = { path = "../../pallets/migration", default-features = false } pallet-nft-sales = { path = "../../pallets/nft-sales", default-features = false } +pallet-order-book = { path = "../../pallets/order-book", default-features = false } pallet-permissions = { path = "../../pallets/permissions", default-features = false } pallet-pool-registry = { path = "../../pallets/pool-registry", default-features = false } pallet-pool-system = { path = "../../pallets/pool-system", default-features = false } @@ -188,6 +192,7 @@ std = [ "pallet-evm-precompile-dispatch/std", "pallet-evm-chain-id/std", "pallet-fees/std", + "pallet-foreign-investments/std", "pallet-identity/std", "pallet-interest-accrual/std", "pallet-investments/std", @@ -197,6 +202,7 @@ std = [ "pallet-multisig/std", "pallet-membership/std", "pallet-nft-sales/std", + "pallet-order-book/std", "pallet-permissions/std", "moonbeam-relay-encoder/std", "pallet-pool-system/std", @@ -272,6 +278,7 @@ runtime-benchmarks = [ "pallet-ethereum-transaction/runtime-benchmarks", "pallet-evm/runtime-benchmarks", "pallet-fees/runtime-benchmarks", + "pallet-foreign-investments/runtime-benchmarks", "pallet-identity/runtime-benchmarks", "pallet-interest-accrual/runtime-benchmarks", "pallet-investments/runtime-benchmarks", @@ -281,6 +288,7 @@ runtime-benchmarks = [ "pallet-multisig/runtime-benchmarks", "pallet-membership/runtime-benchmarks", "pallet-nft-sales/runtime-benchmarks", + "pallet-order-book/runtime-benchmarks", "pallet-permissions/runtime-benchmarks", "pallet-pool-system/runtime-benchmarks", "pallet-pool-registry/runtime-benchmarks", @@ -359,6 +367,7 @@ try-runtime = [ "pallet-evm/try-runtime", "pallet-evm-chain-id/try-runtime", "pallet-fees/try-runtime", + "pallet-foreign-investments/try-runtime", "pallet-identity/try-runtime", "pallet-interest-accrual/try-runtime", "pallet-investments/try-runtime", @@ -368,6 +377,7 @@ try-runtime = [ "pallet-multisig/try-runtime", "pallet-membership/try-runtime", "pallet-nft-sales/try-runtime", + "pallet-order-book/try-runtime", "pallet-permissions/try-runtime", "pallet-pool-system/try-runtime", "pallet-pool-registry/try-runtime", diff --git a/runtime/altair/src/lib.rs b/runtime/altair/src/lib.rs index 529a0cc136..c6ac5c4300 100644 --- a/runtime/altair/src/lib.rs +++ b/runtime/altair/src/lib.rs @@ -22,14 +22,14 @@ use ::xcm::v3::{MultiAsset, MultiLocation}; pub use cfg_primitives::{constants::*, types::*}; use cfg_traits::{ - OrderManager, Permissions as PermissionsT, PoolNAV, PoolUpdateGuard, PreConditions, - TrancheCurrency as _, TryConvert, + investments::{OrderManager, TrancheCurrency as _}, + Permissions as PermissionsT, PoolNAV, PoolUpdateGuard, PreConditions, TryConvert, }; pub use cfg_types::tokens::CurrencyId; use cfg_types::{ consts::pools::*, fee_keys::FeeKey, - fixed_point::{Quantity, Rate}, + fixed_point::{Quantity, Rate, Ratio}, ids::PRICE_ORACLE_PALLET_ID, oracles::OracleKey, permissions::{PermissionRoles, PermissionScope, PermissionedCurrencyRole, PoolRole, Role}, @@ -88,7 +88,7 @@ use sp_runtime::{ create_runtime_str, generic, impl_opaque_keys, traits::{ AccountIdConversion, BlakeTwo256, Block as BlockT, ConvertInto, DispatchInfoOf, - Dispatchable, PostDispatchInfoOf, UniqueSaturatedInto, Zero, + Dispatchable, One, PostDispatchInfoOf, UniqueSaturatedInto, Zero, }, transaction_validity::{TransactionSource, TransactionValidity, TransactionValidityError}, ApplyExtrinsicResult, DispatchError, DispatchResult, FixedI128, Perbill, Permill, Perquintill, @@ -127,7 +127,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: create_runtime_str!("altair"), impl_name: create_runtime_str!("altair"), authoring_version: 1, - spec_version: 1033, + spec_version: 1034, impl_version: 1, #[cfg(not(feature = "disable-runtime-api"))] apis: RUNTIME_API_VERSIONS, @@ -1388,14 +1388,41 @@ impl pallet_xcm_transactor::Config for Runtime { type XcmSender = XcmRouter; } +parameter_types! { + pub DefaultTokenSellRate: Ratio = Ratio::one(); +} + +impl pallet_foreign_investments::Config for Runtime { + type Balance = Balance; + type CollectedForeignRedemptionHook = + pallet_liquidity_pools::hooks::CollectedForeignRedemptionHook; + type CurrencyConverter = + runtime_common::foreign_investments::IdentityPoolCurrencyConverter; + type CurrencyId = CurrencyId; + type DecreasedForeignInvestOrderHook = + pallet_liquidity_pools::hooks::DecreasedForeignInvestOrderHook; + type DefaultTokenSellRate = DefaultTokenSellRate; + type Investment = Investments; + type InvestmentId = TrancheCurrency; + type PoolId = PoolId; + type PoolInspect = PoolSystem; + type Rate = Ratio; + type RuntimeEvent = RuntimeEvent; + type TokenSwapOrderId = u64; + type TokenSwaps = OrderBook; + type TrancheId = TrancheId; + type WeightInfo = (); +} + impl pallet_liquidity_pools::Config for Runtime { - type AccountConverter = AccountConverter; type AdminOrigin = EnsureRoot; type AssetRegistry = OrmlAssetRegistry; type Balance = Balance; - type BalanceRatio = Quantity; + type BalanceRatio = Ratio; type CurrencyId = CurrencyId; - type ForeignInvestment = Investments; + type DomainAccountToAccountId = AccountConverter; + type DomainAddressToAccountId = AccountConverter; + type ForeignInvestment = ForeignInvestments; type GeneralCurrencyPrefix = cfg_primitives::liquidity_pools::GeneralCurrencyPrefix; type OutboundQueue = LiquidityPoolsGateway; type Permission = Permissions; @@ -1416,38 +1443,13 @@ parameter_types! { pub Sender: AccountId = GatewayAccountProvider::::get_gateway_account(); } -/// A stumb inbound queue that does not yet hit the LP logic (before FI we do -/// not want that) but stores an Event. -pub struct StumbInboundQueue; -impl InboundQueue for StumbInboundQueue { - type Message = pallet_liquidity_pools::Message; - type Sender = DomainAddress; - - fn submit(sender: Self::Sender, message: Self::Message) -> DispatchResult { - let event = { - let event = - pallet_liquidity_pools::Event::::IncomingMessage { sender, message }; - - // Mirror deposit_event logic here as it is private - let event = <::RuntimeEvent as From< - pallet_liquidity_pools::Event, - >>::from(event); - - <::RuntimeEvent as Into< - ::RuntimeEvent, - >>::into(event) - }; - - // Triggering only the event for error resolution - System::deposit_event(event); - - Ok(()) - } -} - impl pallet_liquidity_pools_gateway::Config for Runtime { type AdminOrigin = EnsureRootOr; - type InboundQueue = StumbInboundQueue; + #[cfg(not(feature = "testnet-runtime"))] + type InboundQueue = + runtime_common::gateway::stump_queue::StumpInboundQueue; + #[cfg(feature = "testnet-runtime")] + type InboundQueue = LiquidityPools; type LocalEVMOrigin = pallet_liquidity_pools_gateway::EnsureLocal; type MaxIncomingMessageSize = MaxIncomingMessageSize; type Message = pallet_liquidity_pools::Message; @@ -1691,6 +1693,10 @@ impl pallet_investments::Config for Runtime { type Accountant = PoolSystem; type Amount = Balance; type BalanceRatio = Quantity; + type CollectedInvestmentHook = + pallet_foreign_investments::hooks::CollectedInvestmentHook; + type CollectedRedemptionHook = + pallet_foreign_investments::hooks::CollectedRedemptionHook; type InvestmentId = TrancheCurrency; type MaxOutstandingCollects = MaxOutstandingCollects; type PreConditions = IsTrancheInvestor; @@ -1765,6 +1771,24 @@ impl pallet_keystore::pallet::Config for Runtime { type WeightInfo = (); } +parameter_types! { + pub const OrderPairVecSize: u32 = 1_000_000u32; +} + +impl pallet_order_book::Config for Runtime { + type AdminOrigin = EnsureRoot; + type AssetCurrencyId = CurrencyId; + type AssetRegistry = OrmlAssetRegistry; + type Balance = Balance; + type FulfilledOrderHook = pallet_foreign_investments::hooks::FulfilledSwapOrderHook; + type OrderIdNonce = u64; + type OrderPairVecSize = OrderPairVecSize; + type RuntimeEvent = RuntimeEvent; + type SellRatio = Ratio; + type TradeableAsset = Tokens; + type Weights = weights::pallet_order_book::WeightInfo; +} + // Frame Order in this block dictates the index of each one in the metadata // Any addition should be done at the bottom // Any deletion affects the following frames during runtime upgrades @@ -1830,6 +1854,8 @@ construct_runtime!( LiquidityRewardsBase: pallet_rewards::::{Pallet, Storage, Event, Config} = 110, LiquidityRewards: pallet_liquidity_rewards::{Pallet, Call, Storage, Event} = 111, GapRewardMechanism: pallet_rewards::mechanism::gap = 112, + OrderBook: pallet_order_book::{Pallet, Call, Storage, Event} = 113, + ForeignInvestments: pallet_foreign_investments::{Pallet, Storage, Event} = 114, // XCM XcmpQueue: cumulus_pallet_xcmp_queue::{Pallet, Call, Storage, Event} = 120, @@ -1892,7 +1918,7 @@ pub type Executive = frame_executive::Executive< frame_system::ChainContext, Runtime, AllPalletsWithSystem, - migrations::UpgradeAltair1033, + migrations::UpgradeAltair1034, >; impl fp_self_contained::SelfContainedCall for RuntimeCall { @@ -2006,8 +2032,7 @@ mod __runtime_api_use { #[cfg(not(feature = "disable-runtime-api"))] use __runtime_api_use::*; -use cfg_traits::liquidity_pools::InboundQueue; -use cfg_types::domain_address::{Domain, DomainAddress}; +use cfg_types::domain_address::Domain; use runtime_common::{account_conversion::AccountConverter, xcm::AccountIdToMultiLocation}; #[cfg(not(feature = "disable-runtime-api"))] @@ -2429,6 +2454,7 @@ impl_runtime_apis! { list_benchmark!(list, extra, pallet_session, SessionBench::); list_benchmark!(list, extra, pallet_restricted_tokens, Tokens); list_benchmark!(list, extra, pallet_keystore, Keystore); + list_benchmark!(list, extra, pallet_order_book, OrderBook); let storage_info = AllPalletsWithSystem::storage_info(); @@ -2499,6 +2525,7 @@ impl_runtime_apis! { add_benchmark!(params, batches, pallet_session, SessionBench::); add_benchmark!(params, batches, pallet_restricted_tokens, Tokens); add_benchmark!(params, batches, pallet_keystore, Keystore); + add_benchmark!(params, batches, pallet_order_book, OrderBook); if batches.is_empty() { return Err("Benchmark not found for this pallet.".into()) } Ok(batches) diff --git a/runtime/altair/src/migrations.rs b/runtime/altair/src/migrations.rs index 09d74382c5..f1d3a1f8fd 100644 --- a/runtime/altair/src/migrations.rs +++ b/runtime/altair/src/migrations.rs @@ -15,7 +15,7 @@ use frame_support::{traits::OnRuntimeUpgrade, weights::Weight}; /// that have to be applied on that chain, which includes migrations that have /// already been executed on Algol (1028 & 1029). #[cfg(not(feature = "testnet-runtime"))] -pub type UpgradeAltair1033 = ( +pub type UpgradeAltair1034 = ( // FIXME: This migration fails to decode 4 entries against Altair // orml_tokens_migration::CurrencyIdRefactorMigration, // At minimum, bumps storage version from 1 to 2 @@ -57,7 +57,7 @@ pub type UpgradeAltair1033 = ( /// the side releases that only landed on Algol (1028 to 1031) but not yet on /// Altair. #[cfg(feature = "testnet-runtime")] -pub type UpgradeAltair1033 = (); +pub type UpgradeAltair1034 = (); mod asset_registry { use cfg_primitives::Balance; diff --git a/runtime/altair/src/weights/mod.rs b/runtime/altair/src/weights/mod.rs index 873e3d4b3f..d361991a78 100644 --- a/runtime/altair/src/weights/mod.rs +++ b/runtime/altair/src/weights/mod.rs @@ -28,6 +28,7 @@ pub mod pallet_loans; pub mod pallet_migration_manager; pub mod pallet_multisig; pub mod pallet_nft_sales; +pub mod pallet_order_book; pub mod pallet_permissions; pub mod pallet_pool_registry; pub mod pallet_pool_system; diff --git a/runtime/altair/src/weights/pallet_order_book.rs b/runtime/altair/src/weights/pallet_order_book.rs new file mode 100644 index 0000000000..a4ef7881a2 --- /dev/null +++ b/runtime/altair/src/weights/pallet_order_book.rs @@ -0,0 +1,143 @@ + +//! Autogenerated weights for `pallet_order_book` +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev +//! DATE: 2023-08-29, STEPS: `10`, REPEAT: `1`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `kf-FG`, CPU: `` +//! EXECUTION: Some(Wasm), WASM-EXECUTION: Compiled, CHAIN: Some("development-local"), DB CACHE: 1024 + +// Executed Command: +// target/release/centrifuge-chain +// benchmark +// pallet +// --chain=development-local +// --steps=10 +// --repeat=1 +// --pallet=pallet-order-book +// --extrinsic=* +// --execution=wasm +// --wasm-execution=compiled +// --heap-pages=4096 +// --output=pallet-order-book.rs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] + +use frame_support::{traits::Get, weights::Weight}; +use sp_std::marker::PhantomData; + +/// Weight functions for `pallet_order_book`. +pub struct WeightInfo(PhantomData); +impl pallet_order_book::WeightInfo for WeightInfo { + /// Storage: OrderBook OrderIdNonceStore (r:1 w:1) + /// Proof: OrderBook OrderIdNonceStore (max_values: Some(1), max_size: Some(8), added: 503, mode: MaxEncodedLen) + /// Storage: OrderBook TradingPair (r:1 w:0) + /// Proof: OrderBook TradingPair (max_values: None, max_size: Some(82), added: 2557, mode: MaxEncodedLen) + /// Storage: OrmlAssetRegistry Metadata (r:2 w:0) + /// Proof Skipped: OrmlAssetRegistry Metadata (max_values: None, max_size: None, mode: Measured) + /// Storage: OrmlTokens Accounts (r:1 w:1) + /// Proof: OrmlTokens Accounts (max_values: None, max_size: Some(129), added: 2604, mode: MaxEncodedLen) + /// Storage: OrderBook AssetPairOrders (r:1 w:1) + /// Proof: OrderBook AssetPairOrders (max_values: None, max_size: Some(8000070), added: 8002545, mode: MaxEncodedLen) + /// Storage: OrderBook Orders (r:0 w:1) + /// Proof: OrderBook Orders (max_values: None, max_size: Some(186), added: 2661, mode: MaxEncodedLen) + /// Storage: OrderBook UserOrders (r:0 w:1) + /// Proof: OrderBook UserOrders (max_values: None, max_size: Some(226), added: 2701, mode: MaxEncodedLen) + fn create_order() -> Weight { + // Proof Size summary in bytes: + // Measured: `1217` + // Estimated: `8014376` + // Minimum execution time: 46_000 nanoseconds. + Weight::from_parts(46_000_000, 8014376) + .saturating_add(T::DbWeight::get().reads(6)) + .saturating_add(T::DbWeight::get().writes(5)) + } + /// Storage: OrderBook Orders (r:1 w:1) + /// Proof: OrderBook Orders (max_values: None, max_size: Some(186), added: 2661, mode: MaxEncodedLen) + /// Storage: OrderBook TradingPair (r:1 w:0) + /// Proof: OrderBook TradingPair (max_values: None, max_size: Some(82), added: 2557, mode: MaxEncodedLen) + /// Storage: OrmlAssetRegistry Metadata (r:2 w:0) + /// Proof Skipped: OrmlAssetRegistry Metadata (max_values: None, max_size: None, mode: Measured) + /// Storage: OrmlTokens Accounts (r:1 w:1) + /// Proof: OrmlTokens Accounts (max_values: None, max_size: Some(129), added: 2604, mode: MaxEncodedLen) + /// Storage: OrderBook UserOrders (r:1 w:1) + /// Proof: OrderBook UserOrders (max_values: None, max_size: Some(226), added: 2701, mode: MaxEncodedLen) + fn user_update_order() -> Weight { + // Proof Size summary in bytes: + // Measured: `1722` + // Estimated: `17195` + // Minimum execution time: 40_000 nanoseconds. + Weight::from_parts(40_000_000, 17195) + .saturating_add(T::DbWeight::get().reads(6)) + .saturating_add(T::DbWeight::get().writes(3)) + } + /// Storage: OrderBook Orders (r:1 w:1) + /// Proof: OrderBook Orders (max_values: None, max_size: Some(186), added: 2661, mode: MaxEncodedLen) + /// Storage: OrmlTokens Accounts (r:1 w:1) + /// Proof: OrmlTokens Accounts (max_values: None, max_size: Some(129), added: 2604, mode: MaxEncodedLen) + /// Storage: OrderBook AssetPairOrders (r:1 w:1) + /// Proof: OrderBook AssetPairOrders (max_values: None, max_size: Some(8000070), added: 8002545, mode: MaxEncodedLen) + /// Storage: OrderBook UserOrders (r:0 w:1) + /// Proof: OrderBook UserOrders (max_values: None, max_size: Some(226), added: 2701, mode: MaxEncodedLen) + fn user_cancel_order() -> Weight { + // Proof Size summary in bytes: + // Measured: `1116` + // Estimated: `8007810` + // Minimum execution time: 32_000 nanoseconds. + Weight::from_parts(32_000_000, 8007810) + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().writes(4)) + } + /// Storage: OrderBook Orders (r:1 w:1) + /// Proof: OrderBook Orders (max_values: None, max_size: Some(186), added: 2661, mode: MaxEncodedLen) + /// Storage: OrmlTokens Accounts (r:4 w:4) + /// Proof: OrmlTokens Accounts (max_values: None, max_size: Some(129), added: 2604, mode: MaxEncodedLen) + /// Storage: System Account (r:2 w:0) + /// Proof: System Account (max_values: None, max_size: Some(128), added: 2603, mode: MaxEncodedLen) + /// Storage: OrderBook AssetPairOrders (r:1 w:1) + /// Proof: OrderBook AssetPairOrders (max_values: None, max_size: Some(8000070), added: 8002545, mode: MaxEncodedLen) + /// Storage: OrderBook UserOrders (r:0 w:1) + /// Proof: OrderBook UserOrders (max_values: None, max_size: Some(226), added: 2701, mode: MaxEncodedLen) + fn fill_order_full() -> Weight { + // Proof Size summary in bytes: + // Measured: `1702` + // Estimated: `8020828` + // Minimum execution time: 64_000 nanoseconds. + Weight::from_parts(64_000_000, 8020828) + .saturating_add(T::DbWeight::get().reads(8)) + .saturating_add(T::DbWeight::get().writes(7)) + } + /// Storage: OrderBook TradingPair (r:0 w:1) + /// Proof: OrderBook TradingPair (max_values: None, max_size: Some(82), added: 2557, mode: MaxEncodedLen) + fn add_trading_pair() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 9_000 nanoseconds. + Weight::from_ref_time(9_000_000) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: OrderBook TradingPair (r:0 w:1) + /// Proof: OrderBook TradingPair (max_values: None, max_size: Some(82), added: 2557, mode: MaxEncodedLen) + fn rm_trading_pair() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 9_000 nanoseconds. + Weight::from_ref_time(9_000_000) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: OrderBook TradingPair (r:1 w:1) + /// Proof: OrderBook TradingPair (max_values: None, max_size: Some(82), added: 2557, mode: MaxEncodedLen) + fn update_min_order() -> Weight { + // Proof Size summary in bytes: + // Measured: `209` + // Estimated: `2557` + // Minimum execution time: 14_000 nanoseconds. + Weight::from_parts(14_000_000, 2557) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } +} diff --git a/runtime/centrifuge/Cargo.toml b/runtime/centrifuge/Cargo.toml index ddc2e80f54..247076045e 100644 --- a/runtime/centrifuge/Cargo.toml +++ b/runtime/centrifuge/Cargo.toml @@ -116,6 +116,7 @@ pallet-crowdloan-reward = { path = "../../pallets/crowdloan-reward", default-fea pallet-data-collector = { path = "../../pallets/data-collector", default-features = false } pallet-ethereum-transaction = { path = "../../pallets/ethereum-transaction", default-features = false } pallet-fees = { path = "../../pallets/fees", default-features = false } +pallet-foreign-investments = { path = "../../pallets/foreign-investments", default-features = false } pallet-interest-accrual = { path = "../../pallets/interest-accrual", default-features = false } pallet-investments = { path = "../../pallets/investments", default-features = false } pallet-keystore = { path = "../../pallets/keystore", default-features = false } @@ -125,6 +126,7 @@ pallet-liquidity-rewards = { path = "../../pallets/liquidity-rewards", default-f pallet-loans = { path = "../../pallets/loans", default-features = false } pallet-migration-manager = { path = "../../pallets/migration", default-features = false } pallet-nft = { path = "../../pallets/nft", default-features = false } +pallet-order-book = { path = "../../pallets/order-book", default-features = false } pallet-permissions = { path = "../../pallets/permissions", default-features = false } pallet-pool-registry = { path = "../../pallets/pool-registry", default-features = false } pallet-pool-system = { path = "../../pallets/pool-system", default-features = false } @@ -201,6 +203,7 @@ std = [ "pallet-evm-precompile-dispatch/std", "pallet-evm-chain-id/std", "pallet-fees/std", + "pallet-foreign-investments/std", "pallet-identity/std", "pallet-interest-accrual/std", "pallet-investments/std", @@ -213,6 +216,7 @@ std = [ "pallet-multisig/std", "pallet-membership/std", "pallet-nft/std", + "pallet-order-book/std", "pallet-permissions/std", "pallet-pool-registry/std", "pallet-pool-system/std", @@ -289,6 +293,7 @@ runtime-benchmarks = [ "pallet-ethereum-transaction/runtime-benchmarks", "pallet-evm/runtime-benchmarks", "pallet-fees/runtime-benchmarks", + "pallet-foreign-investments/runtime-benchmarks", "pallet-identity/runtime-benchmarks", "pallet-interest-accrual/runtime-benchmarks", "pallet-investments/runtime-benchmarks", @@ -301,6 +306,7 @@ runtime-benchmarks = [ "pallet-multisig/runtime-benchmarks", "pallet-membership/runtime-benchmarks", "pallet-nft/runtime-benchmarks", + "pallet-order-book/runtime-benchmarks", "pallet-permissions/runtime-benchmarks", "pallet-pool-registry/runtime-benchmarks", "pallet-pool-system/runtime-benchmarks", @@ -369,6 +375,7 @@ try-runtime = [ "pallet-evm/try-runtime", "pallet-evm-chain-id/try-runtime", "pallet-fees/try-runtime", + "pallet-foreign-investments/try-runtime", "pallet-identity/try-runtime", "pallet-interest-accrual/try-runtime", "pallet-investments/try-runtime", @@ -381,6 +388,7 @@ try-runtime = [ "pallet-multisig/try-runtime", "pallet-membership/try-runtime", "pallet-nft/try-runtime", + "pallet-order-book/try-runtime", "pallet-permissions/try-runtime", "pallet-pool-registry/try-runtime", "pallet-pool-system/try-runtime", diff --git a/runtime/centrifuge/src/lib.rs b/runtime/centrifuge/src/lib.rs index 02a8ebf0f9..2755f2beda 100644 --- a/runtime/centrifuge/src/lib.rs +++ b/runtime/centrifuge/src/lib.rs @@ -21,15 +21,15 @@ pub use cfg_primitives::{constants::*, types::*}; use cfg_traits::{ - liquidity_pools::{InboundQueue, OutboundQueue}, - OrderManager, Permissions as PermissionsT, PoolNAV, PoolUpdateGuard, PreConditions, - TrancheCurrency as _, TryConvert, + investments::{OrderManager, TrancheCurrency as _}, + liquidity_pools::OutboundQueue, + Permissions as PermissionsT, PoolNAV, PoolUpdateGuard, PreConditions, TryConvert, }; use cfg_types::{ consts::pools::{MaxTrancheNameLengthBytes, MaxTrancheSymbolLengthBytes}, - domain_address::{Domain, DomainAddress}, + domain_address::Domain, fee_keys::FeeKey, - fixed_point::{Quantity, Rate}, + fixed_point::{Quantity, Rate, Ratio}, ids::PRICE_ORACLE_PALLET_ID, oracles::OracleKey, permissions::{ @@ -90,7 +90,7 @@ use sp_runtime::{ create_runtime_str, generic, impl_opaque_keys, traits::{ AccountIdConversion, BlakeTwo256, Block as BlockT, ConvertInto, DispatchInfoOf, - Dispatchable, PostDispatchInfoOf, UniqueSaturatedInto, Zero, + Dispatchable, One, PostDispatchInfoOf, UniqueSaturatedInto, Zero, }, transaction_validity::{TransactionSource, TransactionValidity, TransactionValidityError}, ApplyExtrinsicResult, FixedI128, Perbill, Permill, Perquintill, @@ -428,21 +428,48 @@ impl orml_asset_registry::Config for Runtime { type WeightInfo = (); } +parameter_types! { + pub DefaultTokenSellRate: Ratio = Ratio::one(); +} + +impl pallet_foreign_investments::Config for Runtime { + type Balance = Balance; + type CollectedForeignRedemptionHook = + pallet_liquidity_pools::hooks::CollectedForeignRedemptionHook; + type CurrencyConverter = + runtime_common::foreign_investments::IdentityPoolCurrencyConverter; + type CurrencyId = CurrencyId; + type DecreasedForeignInvestOrderHook = + pallet_liquidity_pools::hooks::DecreasedForeignInvestOrderHook; + type DefaultTokenSellRate = DefaultTokenSellRate; + type Investment = Investments; + type InvestmentId = TrancheCurrency; + type PoolId = PoolId; + type PoolInspect = PoolSystem; + type Rate = Ratio; + type RuntimeEvent = RuntimeEvent; + type TokenSwapOrderId = u64; + type TokenSwaps = OrderBook; + type TrancheId = TrancheId; + type WeightInfo = (); +} + parameter_types! { // To be used if we want to register a particular asset in the chain spec, when running the chain locally. pub LiquidityPoolsPalletIndex: PalletIndex = ::index() as u8; } impl pallet_liquidity_pools::Config for Runtime { - type AccountConverter = AccountConverter; // NOTE: No need to adapt that. The Router is an artifact and will be removed // with FI PR type AdminOrigin = EnsureRootOr; type AssetRegistry = OrmlAssetRegistry; type Balance = Balance; - type BalanceRatio = Quantity; + type BalanceRatio = Ratio; type CurrencyId = CurrencyId; - type ForeignInvestment = Investments; + type DomainAccountToAccountId = AccountConverter; + type DomainAddressToAccountId = AccountConverter; + type ForeignInvestment = ForeignInvestments; type GeneralCurrencyPrefix = cfg_primitives::liquidity_pools::GeneralCurrencyPrefix; type OutboundQueue = FilteredOutboundQueue; type Permission = Permissions; @@ -511,7 +538,8 @@ parameter_types! { impl pallet_liquidity_pools_gateway::Config for Runtime { type AdminOrigin = EnsureAccountOrRootOr; - type InboundQueue = DummyInboundQueue; + type InboundQueue = + runtime_common::gateway::stump_queue::StumpInboundQueue; type LocalEVMOrigin = pallet_liquidity_pools_gateway::EnsureLocal; type MaxIncomingMessageSize = MaxIncomingMessageSize; type Message = LiquidityPoolsMessage; @@ -523,19 +551,6 @@ impl pallet_liquidity_pools_gateway::Config for Runtime { type WeightInfo = (); } -/// DummyInboundQueue will be used in the first phase of testing in order to -/// ensure that no incoming messages will be processed. -pub struct DummyInboundQueue; - -impl InboundQueue for DummyInboundQueue { - type Message = LiquidityPoolsMessage; - type Sender = DomainAddress; - - fn submit(_: Self::Sender, _: Self::Message) -> DispatchResult { - Err(DispatchError::Other("InboundQueue not supported yet")) - } -} - impl pallet_randomness_collective_flip::Config for Runtime {} impl parachain_info::Config for Runtime {} @@ -1746,6 +1761,10 @@ impl pallet_investments::Config for Runtime { type Accountant = PoolSystem; type Amount = Balance; type BalanceRatio = Quantity; + type CollectedInvestmentHook = + pallet_foreign_investments::hooks::CollectedInvestmentHook; + type CollectedRedemptionHook = + pallet_foreign_investments::hooks::CollectedRedemptionHook; type InvestmentId = TrancheCurrency; type MaxOutstandingCollects = MaxOutstandingCollects; type PreConditions = IsTrancheInvestor; @@ -1906,6 +1925,24 @@ impl pallet_uniques::Config for Runtime { type WeightInfo = weights::pallet_uniques::WeightInfo; } +parameter_types! { + pub const OrderPairVecSize: u32 = 1_000u32; +} + +impl pallet_order_book::Config for Runtime { + type AdminOrigin = EnsureRoot; + type AssetCurrencyId = CurrencyId; + type AssetRegistry = OrmlAssetRegistry; + type Balance = Balance; + type FulfilledOrderHook = pallet_foreign_investments::hooks::FulfilledSwapOrderHook; + type OrderIdNonce = u64; + type OrderPairVecSize = OrderPairVecSize; + type RuntimeEvent = RuntimeEvent; + type SellRatio = Ratio; + type TradeableAsset = Tokens; + type Weights = weights::pallet_order_book::WeightInfo; +} + // Frame Order in this block dictates the index of each one in the metadata // Any addition should be done at the bottom // Any deletion affects the following frames during runtime upgrades @@ -1966,6 +2003,8 @@ construct_runtime!( LiquidityRewards: pallet_liquidity_rewards::{Pallet, Call, Storage, Event} = 105, GapRewardMechanism: pallet_rewards::mechanism::gap = 106, LiquidityPoolsGateway: pallet_liquidity_pools_gateway::{Pallet, Call, Storage, Event, Origin } = 107, + OrderBook: pallet_order_book::{Pallet, Call, Storage, Event} = 108, + ForeignInvestments: pallet_foreign_investments::{Pallet, Storage, Event} = 109, // XCM XcmpQueue: cumulus_pallet_xcmp_queue::{Pallet, Call, Storage, Event} = 120, @@ -2485,6 +2524,7 @@ impl_runtime_apis! { list_benchmark!(list, extra, pallet_loans, Loans); list_benchmark!(list, extra, pallet_collator_selection, CollatorSelection); list_benchmark!(list, extra, cumulus_pallet_xcmp_queue, XcmpQueue); + list_benchmark!(list, extra, pallet_order_book, OrderBook); let storage_info = AllPalletsWithSystem::storage_info(); @@ -2555,6 +2595,7 @@ impl_runtime_apis! { add_benchmark!(params, batches, pallet_loans, Loans); add_benchmark!(params, batches, pallet_collator_selection, CollatorSelection); add_benchmark!(params, batches, cumulus_pallet_xcmp_queue, XcmpQueue); + add_benchmark!(params, batches, pallet_order_book, OrderBook); if batches.is_empty() { return Err("Benchmark not found for this pallet.".into()) } Ok(batches) diff --git a/runtime/centrifuge/src/weights/mod.rs b/runtime/centrifuge/src/weights/mod.rs index 12100a50dc..e3a5d11f4a 100644 --- a/runtime/centrifuge/src/weights/mod.rs +++ b/runtime/centrifuge/src/weights/mod.rs @@ -29,6 +29,7 @@ pub mod pallet_liquidity_rewards; pub mod pallet_loans; pub mod pallet_migration_manager; pub mod pallet_multisig; +pub mod pallet_order_book; pub mod pallet_permissions; pub mod pallet_pool_registry; pub mod pallet_pool_system; diff --git a/runtime/centrifuge/src/weights/pallet_order_book.rs b/runtime/centrifuge/src/weights/pallet_order_book.rs new file mode 100644 index 0000000000..a4ef7881a2 --- /dev/null +++ b/runtime/centrifuge/src/weights/pallet_order_book.rs @@ -0,0 +1,143 @@ + +//! Autogenerated weights for `pallet_order_book` +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev +//! DATE: 2023-08-29, STEPS: `10`, REPEAT: `1`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `kf-FG`, CPU: `` +//! EXECUTION: Some(Wasm), WASM-EXECUTION: Compiled, CHAIN: Some("development-local"), DB CACHE: 1024 + +// Executed Command: +// target/release/centrifuge-chain +// benchmark +// pallet +// --chain=development-local +// --steps=10 +// --repeat=1 +// --pallet=pallet-order-book +// --extrinsic=* +// --execution=wasm +// --wasm-execution=compiled +// --heap-pages=4096 +// --output=pallet-order-book.rs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] + +use frame_support::{traits::Get, weights::Weight}; +use sp_std::marker::PhantomData; + +/// Weight functions for `pallet_order_book`. +pub struct WeightInfo(PhantomData); +impl pallet_order_book::WeightInfo for WeightInfo { + /// Storage: OrderBook OrderIdNonceStore (r:1 w:1) + /// Proof: OrderBook OrderIdNonceStore (max_values: Some(1), max_size: Some(8), added: 503, mode: MaxEncodedLen) + /// Storage: OrderBook TradingPair (r:1 w:0) + /// Proof: OrderBook TradingPair (max_values: None, max_size: Some(82), added: 2557, mode: MaxEncodedLen) + /// Storage: OrmlAssetRegistry Metadata (r:2 w:0) + /// Proof Skipped: OrmlAssetRegistry Metadata (max_values: None, max_size: None, mode: Measured) + /// Storage: OrmlTokens Accounts (r:1 w:1) + /// Proof: OrmlTokens Accounts (max_values: None, max_size: Some(129), added: 2604, mode: MaxEncodedLen) + /// Storage: OrderBook AssetPairOrders (r:1 w:1) + /// Proof: OrderBook AssetPairOrders (max_values: None, max_size: Some(8000070), added: 8002545, mode: MaxEncodedLen) + /// Storage: OrderBook Orders (r:0 w:1) + /// Proof: OrderBook Orders (max_values: None, max_size: Some(186), added: 2661, mode: MaxEncodedLen) + /// Storage: OrderBook UserOrders (r:0 w:1) + /// Proof: OrderBook UserOrders (max_values: None, max_size: Some(226), added: 2701, mode: MaxEncodedLen) + fn create_order() -> Weight { + // Proof Size summary in bytes: + // Measured: `1217` + // Estimated: `8014376` + // Minimum execution time: 46_000 nanoseconds. + Weight::from_parts(46_000_000, 8014376) + .saturating_add(T::DbWeight::get().reads(6)) + .saturating_add(T::DbWeight::get().writes(5)) + } + /// Storage: OrderBook Orders (r:1 w:1) + /// Proof: OrderBook Orders (max_values: None, max_size: Some(186), added: 2661, mode: MaxEncodedLen) + /// Storage: OrderBook TradingPair (r:1 w:0) + /// Proof: OrderBook TradingPair (max_values: None, max_size: Some(82), added: 2557, mode: MaxEncodedLen) + /// Storage: OrmlAssetRegistry Metadata (r:2 w:0) + /// Proof Skipped: OrmlAssetRegistry Metadata (max_values: None, max_size: None, mode: Measured) + /// Storage: OrmlTokens Accounts (r:1 w:1) + /// Proof: OrmlTokens Accounts (max_values: None, max_size: Some(129), added: 2604, mode: MaxEncodedLen) + /// Storage: OrderBook UserOrders (r:1 w:1) + /// Proof: OrderBook UserOrders (max_values: None, max_size: Some(226), added: 2701, mode: MaxEncodedLen) + fn user_update_order() -> Weight { + // Proof Size summary in bytes: + // Measured: `1722` + // Estimated: `17195` + // Minimum execution time: 40_000 nanoseconds. + Weight::from_parts(40_000_000, 17195) + .saturating_add(T::DbWeight::get().reads(6)) + .saturating_add(T::DbWeight::get().writes(3)) + } + /// Storage: OrderBook Orders (r:1 w:1) + /// Proof: OrderBook Orders (max_values: None, max_size: Some(186), added: 2661, mode: MaxEncodedLen) + /// Storage: OrmlTokens Accounts (r:1 w:1) + /// Proof: OrmlTokens Accounts (max_values: None, max_size: Some(129), added: 2604, mode: MaxEncodedLen) + /// Storage: OrderBook AssetPairOrders (r:1 w:1) + /// Proof: OrderBook AssetPairOrders (max_values: None, max_size: Some(8000070), added: 8002545, mode: MaxEncodedLen) + /// Storage: OrderBook UserOrders (r:0 w:1) + /// Proof: OrderBook UserOrders (max_values: None, max_size: Some(226), added: 2701, mode: MaxEncodedLen) + fn user_cancel_order() -> Weight { + // Proof Size summary in bytes: + // Measured: `1116` + // Estimated: `8007810` + // Minimum execution time: 32_000 nanoseconds. + Weight::from_parts(32_000_000, 8007810) + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().writes(4)) + } + /// Storage: OrderBook Orders (r:1 w:1) + /// Proof: OrderBook Orders (max_values: None, max_size: Some(186), added: 2661, mode: MaxEncodedLen) + /// Storage: OrmlTokens Accounts (r:4 w:4) + /// Proof: OrmlTokens Accounts (max_values: None, max_size: Some(129), added: 2604, mode: MaxEncodedLen) + /// Storage: System Account (r:2 w:0) + /// Proof: System Account (max_values: None, max_size: Some(128), added: 2603, mode: MaxEncodedLen) + /// Storage: OrderBook AssetPairOrders (r:1 w:1) + /// Proof: OrderBook AssetPairOrders (max_values: None, max_size: Some(8000070), added: 8002545, mode: MaxEncodedLen) + /// Storage: OrderBook UserOrders (r:0 w:1) + /// Proof: OrderBook UserOrders (max_values: None, max_size: Some(226), added: 2701, mode: MaxEncodedLen) + fn fill_order_full() -> Weight { + // Proof Size summary in bytes: + // Measured: `1702` + // Estimated: `8020828` + // Minimum execution time: 64_000 nanoseconds. + Weight::from_parts(64_000_000, 8020828) + .saturating_add(T::DbWeight::get().reads(8)) + .saturating_add(T::DbWeight::get().writes(7)) + } + /// Storage: OrderBook TradingPair (r:0 w:1) + /// Proof: OrderBook TradingPair (max_values: None, max_size: Some(82), added: 2557, mode: MaxEncodedLen) + fn add_trading_pair() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 9_000 nanoseconds. + Weight::from_ref_time(9_000_000) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: OrderBook TradingPair (r:0 w:1) + /// Proof: OrderBook TradingPair (max_values: None, max_size: Some(82), added: 2557, mode: MaxEncodedLen) + fn rm_trading_pair() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 9_000 nanoseconds. + Weight::from_ref_time(9_000_000) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: OrderBook TradingPair (r:1 w:1) + /// Proof: OrderBook TradingPair (max_values: None, max_size: Some(82), added: 2557, mode: MaxEncodedLen) + fn update_min_order() -> Weight { + // Proof Size summary in bytes: + // Measured: `209` + // Estimated: `2557` + // Minimum execution time: 14_000 nanoseconds. + Weight::from_parts(14_000_000, 2557) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } +} diff --git a/runtime/common/src/account_conversion.rs b/runtime/common/src/account_conversion.rs index 0cf2fdc65d..43443c228d 100644 --- a/runtime/common/src/account_conversion.rs +++ b/runtime/common/src/account_conversion.rs @@ -12,7 +12,7 @@ use cfg_primitives::AccountId; use cfg_traits::TryConvert; -use cfg_types::domain_address::DomainAddress; +use cfg_types::domain_address::{Domain, DomainAddress}; use pallet_evm::AddressMapping; use sp_core::{Get, H160}; use sp_runtime::traits::Convert; @@ -62,6 +62,20 @@ impl Convert for AccountConverter Convert<(Domain, [u8; 32]), AccountId> for AccountConverter { + fn convert((domain, account): (Domain, [u8; 32])) -> AccountId { + match domain { + Domain::Centrifuge => AccountId::new(account), + // EVM AccountId20 addresses are right-padded to 32 bytes + Domain::EVM(chain_id) => { + let mut bytes20 = [0; 20]; + bytes20.copy_from_slice(&account[..20]); + Self::convert_evm_address(chain_id, bytes20) + } + } + } +} + impl TryConvert for AccountConverter where XcmConverter: xcm_executor::traits::Convert, diff --git a/runtime/common/src/gateway.rs b/runtime/common/src/gateway.rs index b82249b63a..0d3b8801c3 100644 --- a/runtime/common/src/gateway.rs +++ b/runtime/common/src/gateway.rs @@ -35,3 +35,49 @@ where AccountConverter::::into_account_id(truncated_sender_account) } } + +// NOTE: Can be removed once all runtimes implement a true InboundQueue +pub mod stump_queue { + use cfg_traits::liquidity_pools::InboundQueue; + use cfg_types::domain_address::{Domain, DomainAddress}; + use sp_runtime::DispatchResult; + use sp_std::marker::PhantomData; + + /// A stump inbound queue that does not yet hit the LP logic (before FI we + /// do not want that) but stores an Event. + pub struct StumpInboundQueue(PhantomData<(Runtime, RuntimeEvent)>); + impl InboundQueue for StumpInboundQueue + where + Runtime: pallet_liquidity_pools::Config + frame_system::Config, + { + type Message = pallet_liquidity_pools::Message< + Domain, + ::PoolId, + ::TrancheId, + ::Balance, + ::BalanceRatio, + >; + type Sender = DomainAddress; + + fn submit(sender: Self::Sender, message: Self::Message) -> DispatchResult { + let event = { + let event = + pallet_liquidity_pools::Event::::IncomingMessage { sender, message }; + + // Mirror deposit_event logic here as it is private + let event = <::RuntimeEvent as From< + pallet_liquidity_pools::Event, + >>::from(event); + + <::RuntimeEvent as Into< + ::RuntimeEvent, + >>::into(event) + }; + + // Triggering only the event for error resolution + frame_system::pallet::Pallet::::deposit_event(event); + + Ok(()) + } + } +} diff --git a/runtime/common/src/lib.rs b/runtime/common/src/lib.rs index bd831925cd..3886440513 100644 --- a/runtime/common/src/lib.rs +++ b/runtime/common/src/lib.rs @@ -351,7 +351,7 @@ pub mod changes { /// Module for investment portfolio common to all runtimes pub mod investment_portfolios { - use cfg_traits::{InvestmentsPortfolio, TrancheCurrency}; + use cfg_traits::investments::{InvestmentsPortfolio, TrancheCurrency}; use sp_std::vec::Vec; /// Get the PoolId, CurrencyId, InvestmentId, and Balance for all @@ -421,6 +421,71 @@ pub mod xcm_transactor { } } +pub mod foreign_investments { + use cfg_primitives::{conversion::convert_balance_decimals, Balance}; + use cfg_traits::IdentityCurrencyConversion; + use cfg_types::tokens::CurrencyId; + use frame_support::pallet_prelude::PhantomData; + use orml_traits::asset_registry::Inspect; + use sp_runtime::DispatchError; + + /// Simple currency converter which maps the amount of the outgoing currency + /// to the precision of the incoming one. E.g., the worth of 100 + /// EthWrappedDai in USDC. + /// + /// Requires currencies to have their decimal precision registered in an + /// asset registry. Moreover, one of the currencies must be a allowed as + /// pool currency. + /// + /// NOTE: This converter is only supposed to be used short-term as an MVP + /// for stable coin conversions. We assume those conversions to be 1-to-1 + /// bidirectionally. In the near future, this conversion must be improved to + /// account for conversion ratios other than 1.0. + pub struct IdentityPoolCurrencyConverter(PhantomData); + + impl IdentityCurrencyConversion for IdentityPoolCurrencyConverter + where + AssetRegistry: Inspect< + AssetId = CurrencyId, + Balance = Balance, + CustomMetadata = cfg_types::tokens::CustomMetadata, + >, + { + type Balance = Balance; + type Currency = CurrencyId; + type Error = DispatchError; + + fn stable_to_stable( + currency_in: Self::Currency, + currency_out: Self::Currency, + amount_out: Self::Balance, + ) -> Result { + match (currency_out, currency_in) { + (from, to) if from == to => Ok(amount_out), + (CurrencyId::ForeignAsset(_), CurrencyId::ForeignAsset(_)) => { + let from_metadata = AssetRegistry::metadata(¤cy_out) + .ok_or(DispatchError::CannotLookup)?; + let to_metadata = + AssetRegistry::metadata(¤cy_in).ok_or(DispatchError::CannotLookup)?; + frame_support::ensure!( + from_metadata.additional.pool_currency + || to_metadata.additional.pool_currency, + DispatchError::Token(sp_runtime::TokenError::Unsupported) + ); + + convert_balance_decimals( + from_metadata.decimals, + to_metadata.decimals, + amount_out, + ) + .map_err(DispatchError::from) + } + _ => Err(DispatchError::Token(sp_runtime::TokenError::Unsupported)), + } + } + } +} + pub mod origin { use cfg_primitives::AccountId; use frame_support::traits::{EitherOfDiverse, SortedMembers}; diff --git a/runtime/development/Cargo.toml b/runtime/development/Cargo.toml index 759a39b55e..acecc2bce2 100644 --- a/runtime/development/Cargo.toml +++ b/runtime/development/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "development-runtime" -version = "0.10.27" +version = "0.10.29" authors = ["Centrifuge "] edition = "2021" build = "build.rs" @@ -125,6 +125,7 @@ pallet-crowdloan-reward = { path = "../../pallets/crowdloan-reward", default-fea pallet-data-collector = { path = "../../pallets/data-collector", default-features = false } pallet-ethereum-transaction = { path = "../../pallets/ethereum-transaction", default-features = false } pallet-fees = { path = "../../pallets/fees", default-features = false } +pallet-foreign-investments = { path = "../../pallets/foreign-investments", default-features = false } pallet-interest-accrual = { path = "../../pallets/interest-accrual", default-features = false } pallet-investments = { path = "../../pallets/investments", default-features = false } pallet-keystore = { path = "../../pallets/keystore", default-features = false } @@ -207,6 +208,7 @@ std = [ "pallet-evm-precompile-dispatch/std", "pallet-evm-chain-id/std", "pallet-fees/std", + "pallet-foreign-investments/std", "pallet-identity/std", "pallet-interest-accrual/std", "pallet-investments/std", @@ -299,6 +301,7 @@ runtime-benchmarks = [ "pallet-ethereum-transaction/runtime-benchmarks", "pallet-evm/runtime-benchmarks", "pallet-fees/runtime-benchmarks", + "pallet-foreign-investments/runtime-benchmarks", "pallet-interest-accrual/runtime-benchmarks", "pallet-investments/runtime-benchmarks", "pallet-keystore/runtime-benchmarks", @@ -405,6 +408,7 @@ try-runtime = [ "pallet-democracy/try-runtime", "pallet-elections-phragmen/try-runtime", "pallet-fees/try-runtime", + "pallet-foreign-investments/try-runtime", "pallet-identity/try-runtime", "pallet-migration-manager/try-runtime", "pallet-multisig/try-runtime", diff --git a/runtime/development/src/lib.rs b/runtime/development/src/lib.rs index 0e261b5679..650a1b04ff 100644 --- a/runtime/development/src/lib.rs +++ b/runtime/development/src/lib.rs @@ -24,8 +24,8 @@ pub use cfg_primitives::{ types::{PoolId, *}, }; use cfg_traits::{ - OrderManager, Permissions as PermissionsT, PoolNAV, PoolUpdateGuard, PreConditions, - TrancheCurrency as _, TryConvert as _, + investments::{OrderManager, TrancheCurrency as _}, + Permissions as PermissionsT, PoolNAV, PoolUpdateGuard, PreConditions, TryConvert as _, }; use cfg_types::{ consts::pools::*, @@ -102,7 +102,7 @@ use sp_runtime::{ create_runtime_str, generic, impl_opaque_keys, traits::{ AccountIdConversion, BlakeTwo256, Block as BlockT, ConvertInto, DispatchInfoOf, - Dispatchable, PostDispatchInfoOf, UniqueSaturatedInto, Zero, + Dispatchable, One, PostDispatchInfoOf, UniqueSaturatedInto, Zero, }, transaction_validity::{TransactionSource, TransactionValidity, TransactionValidityError}, ApplyExtrinsicResult, FixedI128, Perbill, Permill, Perquintill, @@ -138,7 +138,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: create_runtime_str!("centrifuge-devel"), impl_name: create_runtime_str!("centrifuge-devel"), authoring_version: 1, - spec_version: 1027, + spec_version: 1029, impl_version: 1, #[cfg(not(feature = "disable-runtime-api"))] apis: RUNTIME_API_VERSIONS, @@ -1544,18 +1544,45 @@ impl orml_asset_registry::Config for Runtime { type WeightInfo = (); } +parameter_types! { + pub DefaultTokenSellRate: Ratio = Ratio::one(); +} + +impl pallet_foreign_investments::Config for Runtime { + type Balance = Balance; + type CollectedForeignRedemptionHook = + pallet_liquidity_pools::hooks::CollectedForeignRedemptionHook; + type CurrencyConverter = + runtime_common::foreign_investments::IdentityPoolCurrencyConverter; + type CurrencyId = CurrencyId; + type DecreasedForeignInvestOrderHook = + pallet_liquidity_pools::hooks::DecreasedForeignInvestOrderHook; + type DefaultTokenSellRate = DefaultTokenSellRate; + type Investment = Investments; + type InvestmentId = TrancheCurrency; + type PoolId = PoolId; + type PoolInspect = PoolSystem; + type Rate = Ratio; + type RuntimeEvent = RuntimeEvent; + type TokenSwapOrderId = u64; + type TokenSwaps = OrderBook; + type TrancheId = TrancheId; + type WeightInfo = (); +} + parameter_types! { pub LiquidityPoolsPalletIndex: PalletIndex = ::index() as u8; } impl pallet_liquidity_pools::Config for Runtime { - type AccountConverter = AccountConverter; type AdminOrigin = EnsureRoot; type AssetRegistry = OrmlAssetRegistry; type Balance = Balance; - type BalanceRatio = Quantity; + type BalanceRatio = Ratio; type CurrencyId = CurrencyId; - type ForeignInvestment = Investments; + type DomainAccountToAccountId = AccountConverter; + type DomainAddressToAccountId = AccountConverter; + type ForeignInvestment = ForeignInvestments; type GeneralCurrencyPrefix = cfg_primitives::liquidity_pools::GeneralCurrencyPrefix; type OutboundQueue = LiquidityPoolsGateway; type Permission = Permissions; @@ -1648,6 +1675,10 @@ impl pallet_investments::Config for Runtime { type Accountant = PoolSystem; type Amount = Balance; type BalanceRatio = Quantity; + type CollectedInvestmentHook = + pallet_foreign_investments::hooks::CollectedInvestmentHook; + type CollectedRedemptionHook = + pallet_foreign_investments::hooks::CollectedRedemptionHook; type InvestmentId = TrancheCurrency; type MaxOutstandingCollects = MaxOutstandingCollects; type PreConditions = IsTrancheInvestor; @@ -1824,6 +1855,7 @@ impl pallet_order_book::Config for Runtime { type AssetCurrencyId = CurrencyId; type AssetRegistry = OrmlAssetRegistry; type Balance = Balance; + type FulfilledOrderHook = pallet_foreign_investments::hooks::FulfilledSwapOrderHook; type OrderIdNonce = u64; type OrderPairVecSize = OrderPairVecSize; type RuntimeEvent = RuntimeEvent; @@ -1902,6 +1934,7 @@ construct_runtime!( GapRewardMechanism: pallet_rewards::mechanism::gap = 114, LiquidityPoolsGateway: pallet_liquidity_pools_gateway::{Pallet, Call, Storage, Event, Origin } = 115, OrderBook: pallet_order_book::{Pallet, Call, Storage, Event} = 116, + ForeignInvestments: pallet_foreign_investments::{Pallet, Storage, Event} = 117, // XCM XcmpQueue: cumulus_pallet_xcmp_queue::{Pallet, Call, Storage, Event} = 120, diff --git a/runtime/development/src/liquidity_pools.rs b/runtime/development/src/liquidity_pools.rs index d832bb5188..c9dc2b9ff9 100644 --- a/runtime/development/src/liquidity_pools.rs +++ b/runtime/development/src/liquidity_pools.rs @@ -11,47 +11,14 @@ // GNU General Public License for more details. use cfg_primitives::{AccountId, Balance, PoolId, TrancheId}; -use cfg_traits::liquidity_pools::InboundQueue; -use cfg_types::{ - domain_address::{Domain, DomainAddress}, - fixed_point::Quantity, -}; -use frame_support::{dispatch::DispatchResult, parameter_types}; +use cfg_types::{domain_address::Domain, fixed_point::Quantity}; +use frame_support::parameter_types; use frame_system::EnsureRoot; use runtime_common::gateway::GatewayAccountProvider; use super::{Runtime, RuntimeEvent, RuntimeOrigin}; use crate::LocationToAccountId; -/// A stumb inbound queue that does not yet hit the LP logic (before FI we do -/// not want that) but stores an Event. -pub struct StumbInboundQueue; -impl InboundQueue for StumbInboundQueue { - type Message = pallet_liquidity_pools::Message; - type Sender = DomainAddress; - - fn submit(sender: Self::Sender, message: Self::Message) -> DispatchResult { - let event = { - let event = - pallet_liquidity_pools::Event::::IncomingMessage { sender, message }; - - // Mirror deposit_event logic here as it is private - let event = <::RuntimeEvent as From< - pallet_liquidity_pools::Event, - >>::from(event); - - <::RuntimeEvent as Into< - ::RuntimeEvent, - >>::into(event) - }; - - // Triggering only the event for error resolution - crate::System::deposit_event(event); - - Ok(()) - } -} - parameter_types! { pub const MaxIncomingMessageSize: u32 = 1024; pub Sender: AccountId = GatewayAccountProvider::::get_gateway_account(); diff --git a/runtime/integration-tests/Cargo.toml b/runtime/integration-tests/Cargo.toml index 3a08c7e180..6ec4036e5c 100644 --- a/runtime/integration-tests/Cargo.toml +++ b/runtime/integration-tests/Cargo.toml @@ -99,10 +99,12 @@ axelar-gateway-precompile = { path = "../../pallets/liquidity-pools-gateway/axel liquidity-pools-gateway-routers = { path = "../../pallets/liquidity-pools-gateway/routers" } pallet-block-rewards = { path = "../../pallets/block-rewards" } pallet-ethereum-transaction = { path = "../../pallets/ethereum-transaction" } +pallet-foreign-investments = { path = "../../pallets/foreign-investments" } pallet-investments = { path = "../../pallets/investments" } pallet-liquidity-pools = { path = "../../pallets/liquidity-pools" } pallet-liquidity-pools-gateway = { path = "../../pallets/liquidity-pools-gateway" } pallet-loans = { path = "../../pallets/loans" } +pallet-order-book = { path = "../../pallets/order-book" } pallet-permissions = { path = "../../pallets/permissions" } pallet-pool-registry = { path = "../../pallets/pool-registry" } pallet-pool-system = { path = "../../pallets/pool-system" } @@ -139,7 +141,9 @@ std = [ "orml-xtokens/std", "pallet-aura/std", "pallet-balances/std", + "pallet-foreign-investments/std", "pallet-investments/std", + "pallet-order-book/std", "pallet-transaction-payment/std", "pallet-uniques/std", "pallet-xcm/std", @@ -186,7 +190,9 @@ runtime-benchmarks = [ "orml-tokens/runtime-benchmarks", "orml-xtokens/runtime-benchmarks", "pallet-balances/runtime-benchmarks", + "pallet-foreign-investments/runtime-benchmarks", "pallet-investments/runtime-benchmarks", + "pallet-order-book/runtime-benchmarks", "pallet-uniques/runtime-benchmarks", "pallet-xcm/runtime-benchmarks", "polkadot-parachain/runtime-benchmarks", diff --git a/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools.rs b/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools.rs deleted file mode 100644 index 7b94a383a3..0000000000 --- a/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools.rs +++ /dev/null @@ -1,2178 +0,0 @@ -// Copyright 2021 Centrifuge GmbH (centrifuge.io). -// This file is part of Centrifuge chain project. -// -// Centrifuge is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version (see http://www.gnu.org/licenses). -// Centrifuge is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. - -// Copyright 2021 Centrifuge GmbH (centrifuge.io). -// This file is part of Centrifuge chain project. -// -// Centrifuge is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version (see http://www.gnu.org/licenses). -// Centrifuge is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. - -use ::xcm::{ - latest::{Junction, Junction::*, Junctions::*, MultiLocation, NetworkId}, - prelude::{Parachain, X1, X2}, - VersionedMultiLocation, -}; -use cfg_primitives::{currency_decimals, parachains, AccountId, Balance, PoolId, TrancheId, CFG}; -use cfg_traits::{ - liquidity_pools::{Codec, InboundQueue}, - OrderManager, Permissions as _, PoolMutate, TrancheCurrency, -}; -use cfg_types::{ - domain_address::{Domain, DomainAddress}, - fixed_point::{Quantity, Rate}, - investments::InvestmentAccount, - orders::FulfillmentWithPrice, - permissions::{PermissionScope, PoolRole, Role, UNION}, - pools::TrancheMetadata, - tokens::{ - CrossChainTransferability, CurrencyId, CurrencyId::ForeignAsset, CustomMetadata, - ForeignAssetId, LiquidityPoolsWrappedToken, - }, - xcm::XcmMetadata, -}; -use codec::Encode; -use development_runtime::{ - Balances, Investments, LiquidityPools, LiquidityPoolsGateway, Loans, LocationToAccountId, - OrmlAssetRegistry, OrmlTokens, Permissions, PoolSystem, Runtime as DevelopmentRuntime, - RuntimeOrigin, System, TreasuryAccount, XTokens, XcmTransactor, -}; -use frame_support::{ - assert_noop, assert_ok, - dispatch::Weight, - traits::{fungibles::Mutate, Get, PalletInfo}, -}; -use hex::FromHex; -use liquidity_pools_gateway_routers::XcmDomain as GatewayXcmDomain; -use orml_traits::{asset_registry::AssetMetadata, FixedConversionRateProvider, MultiCurrency}; -use pallet_liquidity_pools::{ - encoded_contract_call, Error::UnauthorizedTransfer, Message, ParachainId, Router, XcmDomain, -}; -use pallet_pool_system::{ - pool_types::PoolDetails, - tranches::{TrancheInput, TrancheLoc, TrancheType}, -}; -use runtime_common::{ - account_conversion::AccountConverter, xcm::general_key, xcm_fees::default_per_second, -}; -use sp_core::H160; -use sp_runtime::{ - traits::{AccountIdConversion, BadOrigin, ConstU32, Convert, EnsureAdd, One, Zero}, - BoundedVec, DispatchError, Perquintill, SaturatedConversion, WeakBoundedVec, -}; -use utils::investments::{ - default_tranche_id, general_currency_index, investment_account, investment_id, -}; -use xcm_emulator::TestExt; - -use crate::{ - liquidity_pools::pallet::development::{ - setup::{cfg, dollar, ALICE, BOB, PARA_ID_MOONBEAM}, - test_net::{Development, Moonbeam, RelayChain, TestNet}, - tests::liquidity_pools::utils::{ - get_default_moonbeam_native_token_location, DEFAULT_BALANCE_GLMR, - DEFAULT_MOONBEAM_LOCATION, - }, - }, - utils::{AUSD_CURRENCY_ID, GLIMMER_CURRENCY_ID, MOONBEAM_EVM_CHAIN_ID}, - *, -}; - -/// NOTE: We can't actually verify that the messages hits the -/// LiquidityPoolsXcmRouter contract on Moonbeam since that would require a -/// rather heavy e2e setup to emulate, involving depending on Moonbeam's -/// runtime, having said contract deployed to their evm environment, and be able -/// to query the evm side. Instead, these tests verify that - given all -/// pre-requirements are set up correctly - we succeed to send the message from -/// the Centrifuge chain pov. We have other unit tests verifying the -/// LiquidityPools' messages encoding and the encoding of the remote EVM call to -/// be executed on Moonbeam. - -/// Verify that `LiquidityPools::add_pool` succeeds when called with all the -/// necessary requirements. -#[test] -fn add_pool() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - let pool_id: u64 = 42; - - // Verify that the pool must exist before we can call LiquidityPools::add_pool - assert_noop!( - LiquidityPools::add_pool( - RuntimeOrigin::signed(ALICE.into()), - pool_id, - Domain::EVM(1284), - ), - pallet_liquidity_pools::Error::::PoolNotFound - ); - - // Now create the pool - utils::create_ausd_pool(pool_id); - - // Verify ALICE can't call `add_pool` given she is not the `PoolAdmin` - assert_noop!( - LiquidityPools::add_pool( - RuntimeOrigin::signed(ALICE.into()), - pool_id, - Domain::EVM(1284), - ), - pallet_liquidity_pools::Error::::NotPoolAdmin - ); - - // Verify that it works if it's BOB calling it (the pool admin) - assert_ok!(LiquidityPools::add_pool( - RuntimeOrigin::signed(BOB.into()), - pool_id, - Domain::EVM(1284), - )); - }); -} - -/// Verify that `LiquidityPools::add_tranche` succeeds when called with all the -/// necessary requirements. We can't actually verify that the call hits the -/// LiquidityPoolsXcmRouter contract on Moonbeam since that would require a very -/// heavy e2e setup to emulate. Instead, here we test that we can send the -/// extrinsic and we have other unit tests verifying the encoding of the remote -/// EVM call to be executed on Moonbeam. -#[test] -fn add_tranche() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - let decimals: u8 = 15; - - // Now create the pool - let pool_id: u64 = 42; - utils::create_ausd_pool(pool_id); - - // Verify we can't call LiquidityPools::add_tranche with a non-existing - // tranche_id - let nonexistent_tranche = [71u8; 16]; - assert_noop!( - LiquidityPools::add_tranche( - RuntimeOrigin::signed(ALICE.into()), - pool_id, - nonexistent_tranche, - Domain::EVM(1284), - ), - pallet_liquidity_pools::Error::::TrancheNotFound - ); - - // Find the right tranche id - let pool_details = PoolSystem::pool(pool_id).expect("Pool should exist"); - let tranche_id = pool_details - .tranches - .tranche_id(TrancheLoc::Index(0)) - .expect("Tranche at index 0 exists"); - - // Verify ALICE can't call `add_tranche` given she is not the `PoolAdmin` - assert_noop!( - LiquidityPools::add_tranche( - RuntimeOrigin::signed(ALICE.into()), - pool_id, - tranche_id, - Domain::EVM(1284), - ), - pallet_liquidity_pools::Error::::NotPoolAdmin - ); - - // Finally, verify we can call LiquidityPools::add_tranche successfully - // when called by the PoolAdmin with the right pool + tranche id pair. - assert_ok!(LiquidityPools::add_tranche( - RuntimeOrigin::signed(BOB.into()), - pool_id, - tranche_id, - Domain::EVM(1284), - )); - }); -} - -#[test] -fn update_member() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - - // Now create the pool - let pool_id: u64 = 42; - utils::create_ausd_pool(pool_id); - - // Find the right tranche id - let pool_details = PoolSystem::pool(pool_id).expect("Pool should exist"); - let tranche_id = pool_details - .tranches - .tranche_id(TrancheLoc::Index(0)) - .expect("Tranche at index 0 exists"); - - // Finally, verify we can call LiquidityPools::add_tranche successfully - // when given a valid pool + tranche id pair. - let new_member = DomainAddress::EVM(1284, [3; 20]); - let valid_until = utils::DEFAULT_VALIDITY; - - // Make ALICE the MembersListAdmin of this Pool - assert_ok!(Permissions::add( - RuntimeOrigin::root(), - Role::PoolRole(PoolRole::PoolAdmin), - ALICE.into(), - PermissionScope::Pool(pool_id), - Role::PoolRole(PoolRole::InvestorAdmin), - )); - - // Verify it fails if the destination is not whitelisted yet - assert_noop!( - LiquidityPools::update_member( - RuntimeOrigin::signed(ALICE.into()), - pool_id, - tranche_id, - new_member.clone(), - valid_until, - ), - pallet_liquidity_pools::Error::::InvestorDomainAddressNotAMember, - ); - - // Whitelist destination as TrancheInvestor of this Pool - assert_ok!(Permissions::add( - RuntimeOrigin::signed(ALICE.into()), - Role::PoolRole(PoolRole::InvestorAdmin), - AccountConverter::::convert( - new_member.clone() - ), - PermissionScope::Pool(pool_id), - Role::PoolRole(PoolRole::TrancheInvestor( - default_tranche_id(pool_id), - valid_until - )), - )); - - // Verify the Investor role was set as expected in Permissions - assert!(Permissions::has( - PermissionScope::Pool(pool_id), - AccountConverter::::convert( - new_member.clone() - ), - Role::PoolRole(PoolRole::TrancheInvestor(tranche_id, valid_until)), - )); - - // Verify it now works - assert_ok!(LiquidityPools::update_member( - RuntimeOrigin::signed(ALICE.into()), - pool_id, - tranche_id, - new_member, - valid_until, - )); - - // Verify it cannot be called for another member without whitelisting the domain - // beforehand - assert_noop!( - LiquidityPools::update_member( - RuntimeOrigin::signed(ALICE.into()), - pool_id, - tranche_id, - DomainAddress::EVM(1284, [9; 20]), - valid_until, - ), - pallet_liquidity_pools::Error::::InvestorDomainAddressNotAMember, - ); - }); -} - -#[test] -fn update_token_price() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - let decimals: u8 = 15; - - // Now create the pool - let pool_id: u64 = 42; - utils::create_ausd_pool(pool_id); - - // Find the right tranche id - let pool_details = PoolSystem::pool(pool_id).expect("Pool should exist"); - let tranche_id = pool_details - .tranches - .tranche_id(TrancheLoc::Index(0)) - .expect("Tranche at index 0 exists"); - - // Verify it now works - assert_ok!(LiquidityPools::update_token_price( - RuntimeOrigin::signed(ALICE.into()), - pool_id, - tranche_id, - Domain::EVM(1284), - )); - }); -} - -#[test] -fn transfer_non_tranche_tokens_from_local() { - TestNet::reset(); - - Development::execute_with(|| { - // Register GLMR and fund BOB - utils::setup_pre_requirements(); - - let initial_balance = 100_000_000; - let amount = initial_balance / 2; - let dest_address = utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM; - let currency_id = AUSD_CURRENCY_ID; - let source_account = BOB; - - // Mint sufficient balance - assert_ok!(OrmlTokens::mint_into( - currency_id, - &source_account.into(), - initial_balance - )); - assert_eq!( - OrmlTokens::free_balance(currency_id, &source_account.into()), - initial_balance - ); - - // Only `ForeignAsset` can be transferred - assert_noop!( - LiquidityPools::transfer( - RuntimeOrigin::signed(source_account.into()), - CurrencyId::Tranche(42u64, [0u8; 16]), - dest_address.clone(), - amount, - ), - pallet_liquidity_pools::Error::::InvalidTransferCurrency - ); - assert_noop!( - LiquidityPools::transfer( - RuntimeOrigin::signed(source_account.into()), - CurrencyId::Staking(cfg_types::tokens::StakingCurrency::BlockRewards), - dest_address.clone(), - amount, - ), - pallet_liquidity_pools::Error::::AssetNotFound - ); - assert_noop!( - LiquidityPools::transfer( - RuntimeOrigin::signed(source_account.into()), - CurrencyId::Native, - dest_address.clone(), - amount, - ), - pallet_liquidity_pools::Error::::AssetNotFound - ); - - // Cannot transfer as long as cross chain transferability is disabled - assert_noop!( - LiquidityPools::transfer( - RuntimeOrigin::signed(source_account.into()), - currency_id, - dest_address.clone(), - initial_balance, - ), - pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsTransferable - ); - - // Enable LiquidityPools transferability - assert_ok!(OrmlAssetRegistry::update_asset( - RuntimeOrigin::root(), - currency_id, - None, - None, - None, - None, - // Changed: Add location which can be converted to LiquidityPoolsWrappedToken - Some(Some(utils::liquidity_pools_transferable_multilocation( - MOONBEAM_EVM_CHAIN_ID, - // Value of evm_address is irrelevant here - [1u8; 20], - ))), - Some(CustomMetadata { - // Changed: Allow liquidity_pools transferability - transferability: CrossChainTransferability::LiquidityPools, - mintable: Default::default(), - permissioned: Default::default(), - pool_currency: Default::default() - }) - )); - - // Cannot transfer more than owned - assert_noop!( - LiquidityPools::transfer( - RuntimeOrigin::signed(source_account.into()), - currency_id, - dest_address.clone(), - initial_balance.saturating_add(1), - ), - orml_tokens::Error::::BalanceTooLow - ); - - assert_ok!(LiquidityPools::transfer( - RuntimeOrigin::signed(source_account.into()), - currency_id, - dest_address.clone(), - amount, - )); - - // The account to which the currency should have been transferred - // to on Centrifuge for bookkeeping purposes. - let domain_account: AccountId = Domain::convert(dest_address.domain()); - // Verify that the correct amount of the token was transferred - // to the dest domain account on Centrifuge. - assert_eq!( - OrmlTokens::free_balance(currency_id, &domain_account), - amount - ); - assert_eq!( - OrmlTokens::free_balance(currency_id, &source_account.into()), - initial_balance - amount - ); - }); -} - -#[test] -fn transfer_non_tranche_tokens_to_local() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - - let initial_balance = utils::DEFAULT_BALANCE_GLMR; - let amount = utils::DEFAULT_BALANCE_GLMR / 2; - let dest_address = utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM; - let currency_id = AUSD_CURRENCY_ID; - let receiver: AccountId = BOB.into(); - - // Mock incoming decrease message - let msg = utils::LiquidityPoolMessage::Transfer { - currency: general_currency_index(currency_id), - // sender is irrelevant for other -> local - sender: ALICE, - receiver: receiver.clone().into(), - amount, - }; - - assert!(OrmlTokens::total_issuance(currency_id).is_zero()); - - // TODO(after PR #1376): Re-activate via Gateway handling - // // Verify that we do not accept incoming messages if the connection has not - // been // initialized - // assert_noop!( - // LiquidityPools::submit(utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg.clone()), - // pallet_liquidity_pools::Error::::InvalidIncomingMessageOrigin - // ); - // assert_ok!(LiquidityPools::add_instance( - // RuntimeOrigin::root(), - // utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM.address().into() - // )); - - // Finally, verify that we can now transfer the tranche to the destination - // address - assert_ok!(LiquidityPools::submit( - utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, - msg - )); - - // Verify that the correct amount was minted - assert_eq!(OrmlTokens::total_issuance(currency_id), amount); - assert_eq!(OrmlTokens::free_balance(currency_id, &receiver), amount); - - // Verify empty transfers throw - assert_noop!( - LiquidityPools::submit( - utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, - utils::LiquidityPoolMessage::Transfer { - currency: general_currency_index(currency_id), - sender: ALICE, - receiver: receiver.into(), - amount: 0, - }, - ), - pallet_liquidity_pools::Error::::InvalidTransferAmount - ); - }); -} - -#[test] -fn transfer_tranche_tokens_from_local() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - - let pool_id: u64 = 42; - let amount = 100_000; - let dest_address: DomainAddress = DomainAddress::EVM(1284, [99; 20]); - let receiver = BOB; - - // Create the pool - utils::create_ausd_pool(pool_id); - - let tranche_tokens: CurrencyId = - cfg_types::tokens::TrancheCurrency::generate(pool_id, default_tranche_id(pool_id)) - .into(); - - // Verify that we first need the destination address to be whitelisted - assert_noop!( - LiquidityPools::transfer_tranche_tokens( - RuntimeOrigin::signed(ALICE.into()), - pool_id, - default_tranche_id(pool_id), - dest_address.clone(), - amount, - ), - pallet_liquidity_pools::Error::::UnauthorizedTransfer - ); - - // Make receiver the MembersListAdmin of this Pool - assert_ok!(Permissions::add( - RuntimeOrigin::root(), - Role::PoolRole(PoolRole::PoolAdmin), - receiver.into(), - PermissionScope::Pool(pool_id), - Role::PoolRole(PoolRole::InvestorAdmin), - )); - - // Whitelist destination as TrancheInvestor of this Pool - let valid_until = u64::MAX; - assert_ok!(Permissions::add( - RuntimeOrigin::signed(receiver.into()), - Role::PoolRole(PoolRole::InvestorAdmin), - AccountConverter::::convert( - dest_address.clone() - ), - PermissionScope::Pool(pool_id), - Role::PoolRole(PoolRole::TrancheInvestor( - default_tranche_id(pool_id), - valid_until - )), - )); - - // Call the LiquidityPools::update_member which ensures the destination address - // is whitelisted. - assert_ok!(LiquidityPools::update_member( - RuntimeOrigin::signed(receiver.into()), - pool_id, - default_tranche_id(pool_id), - dest_address.clone(), - valid_until, - )); - - // Give receiver enough Tranche balance to be able to transfer it - OrmlTokens::deposit(tranche_tokens, &receiver.into(), amount); - - // Finally, verify that we can now transfer the tranche to the destination - // address - assert_ok!(LiquidityPools::transfer_tranche_tokens( - RuntimeOrigin::signed(receiver.into()), - pool_id, - default_tranche_id(pool_id), - dest_address.clone(), - amount, - )); - - // The account to which the tranche should have been transferred - // to on Centrifuge for bookkeeping purposes. - let domain_account: AccountId = Domain::convert(dest_address.domain()); - - // Verify that the correct amount of the Tranche token was transferred - // to the dest domain account on Centrifuge. - assert_eq!( - OrmlTokens::free_balance(tranche_tokens, &domain_account), - amount - ); - assert!(OrmlTokens::free_balance(tranche_tokens, &receiver.into()).is_zero()); - }); -} - -#[test] -fn transfer_tranche_tokens_to_local() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - - // Create new pool - let pool_id: u64 = 42; - utils::create_ausd_pool(pool_id); - - let amount = 100_000_000; - let receiver: AccountId = BOB.into(); - let sender: DomainAddress = DomainAddress::EVM(1284, [99; 20]); - let sending_domain_locator = - Domain::convert(utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM.domain()); - let tranche_id = default_tranche_id(pool_id); - let tranche_tokens: CurrencyId = - cfg_types::tokens::TrancheCurrency::generate(pool_id, tranche_id).into(); - let valid_until = u64::MAX; - - // Fund `DomainLocator` account of origination domain tranche tokens are - // transferred from this account instead of minting - assert_ok!(OrmlTokens::mint_into( - tranche_tokens, - &sending_domain_locator, - amount - )); - - // Mock incoming decrease message - let msg = utils::LiquidityPoolMessage::TransferTrancheTokens { - pool_id, - tranche_id, - sender: sender.address(), - domain: Domain::Centrifuge, - receiver: receiver.clone().into(), - amount, - }; - - // TODO(after PR #1376): Re-activate via Gateway handling - // // Verify that we do not accept incoming messages if the connection has not - // been // initialized - // assert_noop!( - // LiquidityPools::submit(utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg.clone()), - // pallet_liquidity_pools::Error::::InvalidIncomingMessageOrigin - // ); - // assert_ok!(LiquidityPools::add_instance( - // RuntimeOrigin::root(), - // utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM.address().into() - // )); - - // Verify that we first need the receiver to be whitelisted - assert_noop!( - LiquidityPools::submit(utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg.clone()), - pallet_liquidity_pools::Error::::UnauthorizedTransfer - ); - - // Make receiver the MembersListAdmin of this Pool - assert_ok!(Permissions::add( - RuntimeOrigin::root(), - Role::PoolRole(PoolRole::PoolAdmin), - receiver.clone(), - PermissionScope::Pool(pool_id), - Role::PoolRole(PoolRole::InvestorAdmin), - )); - - // Whitelist destination as TrancheInvestor of this Pool - assert_ok!(Permissions::add( - RuntimeOrigin::signed(receiver.clone()), - Role::PoolRole(PoolRole::InvestorAdmin), - receiver.clone(), - PermissionScope::Pool(pool_id), - Role::PoolRole(PoolRole::TrancheInvestor( - default_tranche_id(pool_id), - valid_until - )), - )); - - // Finally, verify that we can now transfer the tranche to the destination - // address - assert_ok!(LiquidityPools::submit( - utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, - msg - )); - - // Verify that the correct amount of the Tranche token was transferred - // to the dest domain account on Centrifuge. - assert_eq!(OrmlTokens::free_balance(tranche_tokens, &receiver), amount); - assert!(OrmlTokens::free_balance(tranche_tokens, &sending_domain_locator).is_zero()); - - // TODO(subsequent PR): Verify that we cannot transfer to the local - // domain blocked by https://github.com/centrifuge/centrifuge-chain/pull/1376 - // assert_noop!( - // LiquidityPools::submit(utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg), - // pallet_liquidity_pools::Error::::InvalidDomain - // ); - }); -} - -#[test] -/// Try to transfer tranches for non-existing pools or invalid tranche ids for -/// existing pools. -fn transferring_invalid_tranche_tokens_should_fail() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - let dest_address: DomainAddress = DomainAddress::EVM(1284, [99; 20]); - - let valid_pool_id: u64 = 42; - utils::create_ausd_pool(valid_pool_id); - let pool_details = PoolSystem::pool(valid_pool_id).expect("Pool should exist"); - let valid_tranche_id = pool_details - .tranches - .tranche_id(TrancheLoc::Index(0)) - .expect("Tranche at index 0 exists"); - let valid_until = u64::MAX; - let transfer_amount = 42; - let invalid_pool_id = valid_pool_id + 1; - let invalid_tranche_id = valid_tranche_id.map(|i| i.saturating_add(1)); - assert!(PoolSystem::pool(invalid_pool_id).is_none()); - - // Make BOB the MembersListAdmin of both pools - assert_ok!(Permissions::add( - RuntimeOrigin::root(), - Role::PoolRole(PoolRole::PoolAdmin), - BOB.into(), - PermissionScope::Pool(valid_pool_id), - Role::PoolRole(PoolRole::InvestorAdmin), - )); - assert_ok!(Permissions::add( - RuntimeOrigin::root(), - Role::PoolRole(PoolRole::PoolAdmin), - BOB.into(), - PermissionScope::Pool(invalid_pool_id), - Role::PoolRole(PoolRole::InvestorAdmin), - )); - - // Give BOB investor role for (valid_pool_id, invalid_tranche_id) and - // (invalid_pool_id, valid_tranche_id) - assert_ok!(Permissions::add( - RuntimeOrigin::signed(BOB.into()), - Role::PoolRole(PoolRole::InvestorAdmin), - AccountConverter::::convert( - dest_address.clone() - ), - PermissionScope::Pool(invalid_pool_id), - Role::PoolRole(PoolRole::TrancheInvestor(valid_tranche_id, valid_until)), - )); - assert_ok!(Permissions::add( - RuntimeOrigin::signed(BOB.into()), - Role::PoolRole(PoolRole::InvestorAdmin), - AccountConverter::::convert( - dest_address.clone() - ), - PermissionScope::Pool(valid_pool_id), - Role::PoolRole(PoolRole::TrancheInvestor(invalid_tranche_id, valid_until)), - )); - assert_noop!( - LiquidityPools::transfer_tranche_tokens( - RuntimeOrigin::signed(BOB.into()), - invalid_pool_id, - valid_tranche_id, - dest_address.clone(), - transfer_amount - ), - pallet_liquidity_pools::Error::::PoolNotFound - ); - assert_noop!( - LiquidityPools::transfer_tranche_tokens( - RuntimeOrigin::signed(BOB.into()), - valid_pool_id, - invalid_tranche_id, - dest_address, - transfer_amount - ), - pallet_liquidity_pools::Error::::TrancheNotFound - ); - }); -} - -#[test] -fn add_currency() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - - let currency_id = AUSD_CURRENCY_ID; - let location = utils::liquidity_pools_transferable_multilocation( - MOONBEAM_EVM_CHAIN_ID, - utils::DEFAULT_EVM_ADDRESS_MOONBEAM, - ); - - // Enable LiquidityPools transferability - assert_ok!(OrmlAssetRegistry::update_asset( - RuntimeOrigin::root(), - currency_id, - None, - None, - None, - None, - // Changed: Add location which can be converted to LiquidityPoolsWrappedToken - Some(Some(location)), - Some(CustomMetadata { - // Changed: Allow liquidity_pools transferability - transferability: CrossChainTransferability::LiquidityPools, - mintable: Default::default(), - permissioned: Default::default(), - pool_currency: Default::default() - }) - )); - - assert_eq!( - OrmlTokens::free_balance( - GLIMMER_CURRENCY_ID, - &::Sender::get() - ), - DEFAULT_BALANCE_GLMR - ); - - assert_ok!(LiquidityPools::add_currency( - RuntimeOrigin::signed(BOB.into()), - currency_id - )); - - assert_eq!( - OrmlTokens::free_balance( - GLIMMER_CURRENCY_ID, - &::Sender::get() - ), - /// Ensure it only charged the 0.2 GLMR of fee - DEFAULT_BALANCE_GLMR - - dollar(18).saturating_div(5) - ); - }); -} - -#[test] -fn add_currency_should_fail() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - - assert_noop!( - LiquidityPools::add_currency( - RuntimeOrigin::signed(BOB.into()), - CurrencyId::ForeignAsset(42) - ), - pallet_liquidity_pools::Error::::AssetNotFound - ); - assert_noop!( - LiquidityPools::add_currency(RuntimeOrigin::signed(BOB.into()), CurrencyId::Native), - pallet_liquidity_pools::Error::::AssetNotFound - ); - assert_noop!( - LiquidityPools::add_currency( - RuntimeOrigin::signed(BOB.into()), - CurrencyId::Staking(cfg_types::tokens::StakingCurrency::BlockRewards) - ), - pallet_liquidity_pools::Error::::AssetNotFound - ); - assert_noop!( - LiquidityPools::add_currency( - RuntimeOrigin::signed(BOB.into()), - CurrencyId::Staking(cfg_types::tokens::StakingCurrency::BlockRewards) - ), - pallet_liquidity_pools::Error::::AssetNotFound - ); - - // Should fail to add currency_id which is missing a registered - // MultiLocation - let currency_id = CurrencyId::ForeignAsset(100); - assert_ok!(OrmlAssetRegistry::register_asset( - RuntimeOrigin::root(), - utils::asset_metadata( - "Test".into(), - "TEST".into(), - 12, - false, - None, - CrossChainTransferability::LiquidityPools, - ), - Some(currency_id) - )); - assert_noop!( - LiquidityPools::add_currency(RuntimeOrigin::signed(BOB.into()), currency_id), - pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsWrappedToken - ); - - // Add convertable MultiLocation to metadata but remove transferability - assert_ok!(OrmlAssetRegistry::update_asset( - RuntimeOrigin::root(), - currency_id, - None, - None, - None, - None, - // Changed: Add multilocation to metadata for some random EVM chain id for which no - // instance is registered - Some(Some(utils::liquidity_pools_transferable_multilocation( - u64::MAX, - [1u8; 20], - ))), - Some(CustomMetadata { - // Changed: Disallow liquidityPools transferability - transferability: CrossChainTransferability::Xcm(Default::default()), - mintable: Default::default(), - permissioned: Default::default(), - pool_currency: Default::default(), - }), - )); - assert_noop!( - LiquidityPools::add_currency(RuntimeOrigin::signed(BOB.into()), currency_id), - pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsTransferable - ); - - // Switch transferability from XCM to None - assert_ok!(OrmlAssetRegistry::update_asset( - RuntimeOrigin::root(), - currency_id, - None, - None, - None, - None, - None, - Some(CustomMetadata { - // Changed: Disallow cross chain transferability entirely - transferability: CrossChainTransferability::None, - mintable: Default::default(), - permissioned: Default::default(), - pool_currency: Default::default(), - }) - )); - assert_noop!( - LiquidityPools::add_currency(RuntimeOrigin::signed(BOB.into()), currency_id), - pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsTransferable - ); - - // TODO(subsequent PR): Reactivate later - // Blocked by https://github.com/centrifuge/centrifuge-chain/pull/1376 - - // // Should fail if no domain router is registered for the asset's - // // metadata evm chain id - // assert_ok!(OrmlAssetRegistry::update_asset( - // RuntimeOrigin::root(), - // currency_id, - // None, - // None, - // None, - // None, - // None, - // Some(CustomMetadata { - // // Changed: Enable all cross chain transferability in metadata - // transferability: CrossChainTransferability::All(XcmMetadata { - // fee_per_second: Default::default() - // }), - // mintable: Default::default(), - // permissioned: Default::default(), - // pool_currency: Default::default(), - // }) - // )); - // assert_noop!( - // LiquidityPools::add_currency(RuntimeOrigin::signed(BOB.into()), - // currency_id), - // pallet_liquidity_pools::Error::::MissingRouter - // ); - }); -} - -#[test] -fn allow_pool_currency() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - - let currency_id = AUSD_CURRENCY_ID; - let pool_id: u64 = 42; - let evm_chain_id: u64 = MOONBEAM_EVM_CHAIN_ID; - let evm_address = [1u8; 20]; - - // Create an AUSD pool - utils::create_ausd_pool(pool_id); - - // Enable LiquidityPools transferability - assert_ok!(OrmlAssetRegistry::update_asset( - RuntimeOrigin::root(), - currency_id, - None, - None, - None, - None, - // Changed: Add location which can be converted to LiquidityPoolsWrappedToken - Some(Some(utils::liquidity_pools_transferable_multilocation( - evm_chain_id, - evm_address, - ))), - Some(CustomMetadata { - // Changed: Allow liquidity_pools transferability - transferability: CrossChainTransferability::LiquidityPools, - mintable: Default::default(), - permissioned: Default::default(), - pool_currency: true, - }) - )); - - assert_ok!(LiquidityPools::allow_pool_currency( - RuntimeOrigin::signed(BOB.into()), - pool_id, - default_tranche_id(pool_id), - currency_id, - )); - }); -} - -#[test] -fn allow_pool_should_fail() { - TestNet::reset(); - - Development::execute_with(|| { - let pool_id: u64 = 42; - let currency_id = CurrencyId::ForeignAsset(42); - let ausd_currency_id = AUSD_CURRENCY_ID; - - utils::setup_pre_requirements(); - // Should fail if pool does not exist - assert_noop!( - LiquidityPools::allow_pool_currency( - RuntimeOrigin::signed(BOB.into()), - pool_id, - // Tranche id is arbitrary in this case as pool does not exist - [0u8; 16], - currency_id, - ), - pallet_liquidity_pools::Error::::PoolNotFound - ); - - // Register currency_id with pool_currency set to true - assert_ok!(OrmlAssetRegistry::register_asset( - RuntimeOrigin::root(), - utils::asset_metadata( - "Test".into(), - "TEST".into(), - 12, - true, - None, - Default::default(), - ), - Some(currency_id) - )); - - // Create pool - utils::create_currency_pool(pool_id, currency_id, 10_000 * dollar(12)); - - // Should fail if asset is not pool currency - assert!(currency_id != ausd_currency_id); - assert_noop!( - LiquidityPools::allow_pool_currency( - RuntimeOrigin::signed(BOB.into()), - pool_id, - default_tranche_id(pool_id), - ausd_currency_id, - ), - pallet_liquidity_pools::Error::::InvalidInvestCurrency - ); - - // Should fail if currency is not liquidityPools transferable - assert_ok!(OrmlAssetRegistry::update_asset( - RuntimeOrigin::root(), - currency_id, - None, - None, - None, - None, - None, - Some(CustomMetadata { - // Disallow any cross chain transferability - transferability: CrossChainTransferability::None, - mintable: Default::default(), - permissioned: Default::default(), - // Changed: Allow to be usable as pool currency - pool_currency: true, - }), - )); - assert_noop!( - LiquidityPools::allow_pool_currency( - RuntimeOrigin::signed(BOB.into()), - pool_id, - default_tranche_id(pool_id), - currency_id, - ), - pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsTransferable - ); - - // Should fail if currency does not have any MultiLocation in metadata - assert_ok!(OrmlAssetRegistry::update_asset( - RuntimeOrigin::root(), - currency_id, - None, - None, - None, - None, - None, - Some(CustomMetadata { - // Changed: Allow liquidityPools transferability - transferability: CrossChainTransferability::LiquidityPools, - mintable: Default::default(), - permissioned: Default::default(), - // Still allow to be pool currency - pool_currency: true, - }), - )); - assert_noop!( - LiquidityPools::allow_pool_currency( - RuntimeOrigin::signed(BOB.into()), - pool_id, - default_tranche_id(pool_id), - currency_id, - ), - pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsWrappedToken - ); - - // Should fail if currency does not have LiquidityPoolsWrappedToken location in - // metadata - assert_ok!(OrmlAssetRegistry::update_asset( - RuntimeOrigin::root(), - currency_id, - None, - None, - None, - None, - // Changed: Add some location which cannot be converted to LiquidityPoolsWrappedToken - Some(Some(VersionedMultiLocation::V3(Default::default()))), - // No change for transferability required as it is already allowed for LiquidityPools - None, - )); - assert_noop!( - LiquidityPools::allow_pool_currency( - RuntimeOrigin::signed(BOB.into()), - pool_id, - default_tranche_id(pool_id), - currency_id, - ), - pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsWrappedToken - ); - - // Create new pool for non foreign asset - // NOTE: Can be removed after merging https://github.com/centrifuge/centrifuge-chain/pull/1343 - assert_ok!(OrmlAssetRegistry::register_asset( - RuntimeOrigin::root(), - utils::asset_metadata( - "Acala Dollar".into(), - "AUSD".into(), - 12, - true, - None, - Default::default() - ), - Some(CurrencyId::AUSD) - )); - utils::create_currency_pool(pool_id + 1, CurrencyId::AUSD, 10_000 * dollar(12)); - // Should fail if currency is not foreign asset - assert_noop!( - LiquidityPools::allow_pool_currency( - RuntimeOrigin::signed(BOB.into()), - pool_id + 1, - // Tranche id is arbitrary in this case, so we don't need to check for the exact - // pool_id - default_tranche_id(pool_id + 1), - CurrencyId::AUSD, - ), - DispatchError::Token(sp_runtime::TokenError::Unsupported) - ); - }); -} - -#[test] -fn schedule_upgrade() { - use frame_support::traits::fungible::Mutate; - - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - - // Only Root can call `schedule_upgrade` - assert_noop!( - LiquidityPools::schedule_upgrade( - RuntimeOrigin::signed(BOB.into()), - MOONBEAM_EVM_CHAIN_ID, - [7; 20] - ), - BadOrigin - ); - - // Now it finally works - assert_ok!(LiquidityPools::schedule_upgrade( - RuntimeOrigin::root(), - MOONBEAM_EVM_CHAIN_ID, - [7; 20] - )); - }); -} - -#[test] -fn inbound_increase_invest_order() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - - let pool_id = 42; - let amount = 100_000_000; - let investor: AccountId = BOB.into(); - let currency_id = AUSD_CURRENCY_ID; - let currency_decimals = currency_decimals::AUSD; - - // Create new pool - utils::create_currency_pool(pool_id, currency_id, currency_decimals.into()); - - // Set permissions and execute initial investment - utils::investments::do_initial_increase_investment(pool_id, amount, investor, currency_id); - - // Verify the order was updated to the amount - assert_eq!( - pallet_investments::Pallet::::acc_active_invest_order( - investment_id(pool_id, default_tranche_id(pool_id)) - ) - .amount, - amount - ); - }); -} - -#[test] -fn inbound_decrease_invest_order() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - - let pool_id = 42; - let invest_amount = 100_000_000; - let decrease_amount = invest_amount / 3; - let final_amount = invest_amount - decrease_amount; - let investor: AccountId = BOB.into(); - let currency_id = AUSD_CURRENCY_ID; - let currency_decimals = currency_decimals::AUSD; - - // Create new pool - utils::create_currency_pool(pool_id, currency_id, currency_decimals.into()); - - // Set permissions and execute initial investment - utils::investments::do_initial_increase_investment( - pool_id, - invest_amount, - investor.clone(), - currency_id, - ); - - // Mock incoming decrease message - let msg = utils::LiquidityPoolMessage::DecreaseInvestOrder { - pool_id, - tranche_id: default_tranche_id(pool_id), - investor: investor.clone().into(), - currency: general_currency_index(currency_id), - amount: decrease_amount, - }; - - // Execute byte message - assert_ok!(LiquidityPools::submit( - utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, - msg - )); - - // Verify investment was decreased into investment account - assert_eq!( - OrmlTokens::free_balance( - currency_id, - &investment_account(investment_id(pool_id, default_tranche_id(pool_id))) - ), - final_amount - ); - // The transfer does not happen right away, so should still be in investor's - // wallet - assert_eq!( - OrmlTokens::free_balance(currency_id, &investor), - decrease_amount - ); - assert!(System::events().iter().any(|e| e.event - == pallet_investments::Event::::InvestOrderUpdated { - investment_id: investment_id(pool_id, default_tranche_id(pool_id)), - submitted_at: 0, - who: investor.clone(), - amount: final_amount - } - .into())); - assert_eq!( - pallet_investments::Pallet::::acc_active_invest_order( - investment_id(pool_id, default_tranche_id(pool_id)) - ) - .amount, - final_amount - ); - }); -} - -#[test] -fn inbound_collect_invest_order() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - - let pool_id = 42; - let amount = 100_000_000; - let investor: AccountId = BOB.into(); - let currency_id = AUSD_CURRENCY_ID; - let currency_decimals = currency_decimals::AUSD; - - // Create new pool - utils::create_currency_pool(pool_id, currency_id, currency_decimals.into()); - - let investment_currency_id: CurrencyId = - investment_id(pool_id, default_tranche_id(pool_id)).into(); - - // Set permissions and execute initial investment - utils::investments::do_initial_increase_investment( - pool_id, - amount, - investor.clone(), - currency_id, - ); - let events_before_collect = System::events(); - - // Process and fulfill order - // NOTE: Without this step, the order id is not cleared and - // `Event::InvestCollectedForNonClearedOrderId` be dispatched - assert_ok!(Investments::process_invest_orders(investment_id( - pool_id, - default_tranche_id(pool_id) - ))); - - // Tranche tokens will be minted upon fulfillment - assert_eq!(OrmlTokens::total_issuance(investment_currency_id), 0); - assert_ok!(Investments::invest_fulfillment( - investment_id(pool_id, default_tranche_id(pool_id)), - FulfillmentWithPrice { - of_amount: Perquintill::one(), - price: Quantity::one(), - } - )); - assert_eq!(OrmlTokens::total_issuance(investment_currency_id), amount); - - // Mock collection message msg - let msg = utils::LiquidityPoolMessage::CollectInvest { - pool_id, - tranche_id: default_tranche_id(pool_id), - investor: investor.clone().into(), - }; - - // Execute byte message - assert_ok!(LiquidityPools::submit( - utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, - msg - )); - - // Remove events before collect execution - let events_since_collect: Vec<_> = System::events() - .into_iter() - .filter(|e| !events_before_collect.contains(e)) - .collect(); - - // Verify investment was collected into investor - assert_eq!( - OrmlTokens::free_balance( - investment_id(pool_id, default_tranche_id(pool_id)).into(), - &investor - ), - amount - ); - - // Order should have been cleared by fulfilling investment - assert_eq!( - pallet_investments::Pallet::::acc_active_invest_order( - investment_id(pool_id, default_tranche_id(pool_id)) - ) - .amount, - 0 - ); - assert!(!events_since_collect.iter().any(|e| { - e.event - == pallet_investments::Event::::InvestCollectedForNonClearedOrderId { - investment_id: investment_id(pool_id, default_tranche_id(pool_id)), - who: investor.clone(), - } - .into() - })); - - // Order should not have been updated since everything is collected - assert!(!events_since_collect.iter().any(|e| { - e.event - == pallet_investments::Event::::InvestOrderUpdated { - investment_id: investment_id(pool_id, default_tranche_id(pool_id)), - submitted_at: 0, - who: investor.clone(), - amount: 0, - } - .into() - })); - - // Order should have been fully collected - assert!(events_since_collect.iter().any(|e| { - e.event - == pallet_investments::Event::::InvestOrdersCollected { - investment_id: investment_id(pool_id, default_tranche_id(pool_id)), - processed_orders: vec![0], - who: investor.clone(), - collection: pallet_investments::InvestCollection:: { - payout_investment_invest: amount, - remaining_investment_invest: 0, - }, - outcome: pallet_investments::CollectOutcome::FullyCollected, - } - .into() - })); - }); -} - -#[test] -fn inbound_increase_redeem_order() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - - let pool_id = 42; - let amount = 100_000_000; - let investor: AccountId = BOB.into(); - let currency_id = AUSD_CURRENCY_ID; - let currency_decimals = currency_decimals::AUSD; - - // Create new pool - utils::create_currency_pool(pool_id, currency_id, currency_decimals.into()); - - // Set permissions and execute initial redemption - utils::investments::do_initial_increase_redemption(pool_id, amount, investor, currency_id); - - // Verify amount was noted in the corresponding order - assert_eq!( - pallet_investments::Pallet::::acc_active_redeem_order( - investment_id(pool_id, default_tranche_id(pool_id)) - ) - .amount, - amount - ); - }); -} - -#[test] -fn inbound_decrease_redeem_order() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - - let pool_id = 42; - let redeem_amount = 100_000_000; - let decrease_amount = redeem_amount / 3; - let final_amount = redeem_amount - decrease_amount; - let investor: AccountId = BOB.into(); - let currency_id = AUSD_CURRENCY_ID; - let currency_decimals = currency_decimals::AUSD; - - // Create new pool - utils::create_currency_pool(pool_id, currency_id, currency_decimals.into()); - - // Set permissions and execute initial redemption - utils::investments::do_initial_increase_redemption( - pool_id, - redeem_amount, - investor.clone(), - currency_id, - ); - - // Verify the corresponding redemption order id is 0 - assert_eq!( - pallet_investments::Pallet::::invest_order_id(investment_id( - pool_id, - default_tranche_id(pool_id) - )), - 0 - ); - - // Mock incoming decrease message - let msg = utils::LiquidityPoolMessage::DecreaseRedeemOrder { - pool_id, - tranche_id: default_tranche_id(pool_id), - investor: investor.clone().into(), - currency: general_currency_index(currency_id), - amount: decrease_amount, - }; - - // Execute byte message - assert_ok!(LiquidityPools::submit( - utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, - msg - )); - - // Verify investment was decreased into investment account - assert_eq!( - OrmlTokens::free_balance( - investment_id(pool_id, default_tranche_id(pool_id)).into(), - &investment_account(investment_id(pool_id, default_tranche_id(pool_id))) - ), - final_amount - ); - // Burning does not happen right away, so should still be in investor's wallet - assert_eq!( - OrmlTokens::free_balance( - investment_id(pool_id, default_tranche_id(pool_id)).into(), - &investor - ), - decrease_amount - ); - - // Order should have been updated - assert!(System::events().iter().any(|e| e.event - == pallet_investments::Event::::RedeemOrderUpdated { - investment_id: investment_id(pool_id, default_tranche_id(pool_id)), - submitted_at: 0, - who: investor.clone(), - amount: final_amount - } - .into())); - assert_eq!( - pallet_investments::Pallet::::acc_active_redeem_order( - investment_id(pool_id, default_tranche_id(pool_id)), - ) - .amount, - final_amount - ); - }); -} - -#[test] -fn inbound_collect_redeem_order() { - TestNet::reset(); - - Development::execute_with(|| { - utils::setup_pre_requirements(); - - let pool_id = 42; - let amount = 100_000_000; - let investor: AccountId = BOB.into(); - let currency_id = AUSD_CURRENCY_ID; - let currency_decimals = currency_decimals::AUSD; - let pool_account = - pallet_pool_system::pool_types::PoolLocator { pool_id }.into_account_truncating(); - - // Create new pool - utils::create_currency_pool(pool_id, currency_id, currency_decimals.into()); - - // Set permissions and execute initial investment - utils::investments::do_initial_increase_redemption( - pool_id, - amount, - investor.clone(), - currency_id, - ); - let events_before_collect = System::events(); - - // Fund the pool account with sufficient pool currency, else redemption cannot - // swap tranche tokens against pool currency - assert_ok!(OrmlTokens::mint_into(currency_id, &pool_account, amount)); - - // Process and fulfill order - // NOTE: Without this step, the order id is not cleared and - // `Event::RedeemCollectedForNonClearedOrderId` be dispatched - assert_ok!(Investments::process_redeem_orders(investment_id( - pool_id, - default_tranche_id(pool_id) - ))); - assert_ok!(Investments::redeem_fulfillment( - investment_id(pool_id, default_tranche_id(pool_id)), - FulfillmentWithPrice { - of_amount: Perquintill::one(), - price: Quantity::one(), - } - )); - - // Mock collection message msg - let msg = utils::LiquidityPoolMessage::CollectRedeem { - pool_id, - tranche_id: default_tranche_id(pool_id), - investor: investor.clone().into(), - }; - - // Execute byte message - assert_ok!(LiquidityPools::submit( - utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, - msg - )); - - // Remove events before collect execution - let events_since_collect: Vec<_> = System::events() - .into_iter() - .filter(|e| !events_before_collect.contains(e)) - .collect(); - - // Verify investment was collected into investor - assert_eq!(OrmlTokens::free_balance(currency_id, &investor), amount); - - // Order should have been cleared by fulfilling redemption - assert_eq!( - pallet_investments::Pallet::::acc_active_redeem_order( - investment_id(pool_id, default_tranche_id(pool_id)) - ) - .amount, - 0 - ); - assert!(!events_since_collect.iter().any(|e| { - e.event - == pallet_investments::Event::::RedeemCollectedForNonClearedOrderId { - investment_id: investment_id(pool_id, default_tranche_id(pool_id)), - who: investor.clone(), - } - .into() - })); - - // Order should not have been updated since everything is collected - assert!(!events_since_collect.iter().any(|e| { - e.event - == pallet_investments::Event::::RedeemOrderUpdated { - investment_id: investment_id(pool_id, default_tranche_id(pool_id)), - submitted_at: 0, - who: investor.clone(), - amount: 0, - } - .into() - })); - - // Order should have been fully collected - assert!(events_since_collect.iter().any(|e| { - e.event - == pallet_investments::Event::::RedeemOrdersCollected { - investment_id: investment_id(pool_id, default_tranche_id(pool_id)), - processed_orders: vec![0], - who: investor.clone(), - collection: pallet_investments::RedeemCollection:: { - payout_investment_redeem: amount, - remaining_investment_redeem: 0, - }, - outcome: pallet_investments::CollectOutcome::FullyCollected, - } - .into() - })); - }); -} - -#[test] -fn test_vec_to_fixed_array() { - let src = "TrNcH".as_bytes().to_vec(); - let symbol: [u8; 32] = cfg_utils::vec_to_fixed_array(src); - - assert!(symbol.starts_with("TrNcH".as_bytes())); - - assert_eq!( - symbol, - [ - 84, 114, 78, 99, 72, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0 - ] - ); -} - -// Verify that the max tranche token symbol and name lengths are what the -// LiquidityPools pallet expects. -#[test] -fn verify_tranche_fields_sizes() { - assert_eq!( - cfg_types::consts::pools::MaxTrancheNameLengthBytes::get(), - pallet_liquidity_pools::TOKEN_NAME_SIZE as u32 - ); - assert_eq!( - cfg_types::consts::pools::MaxTrancheSymbolLengthBytes::get(), - pallet_liquidity_pools::TOKEN_SYMBOL_SIZE as u32 - ); -} - -mod utils { - use cfg_primitives::Moment; - use cfg_types::tokens::CrossChainTransferability; - use liquidity_pools_gateway_routers::{ - ethereum_xcm::EthereumXCMRouter, DomainRouter, XCMRouter, XcmTransactInfo, - DEFAULT_PROOF_SIZE, - }; - use runtime_common::xcm_fees::native_per_second; - use sp_runtime::traits::{EnsureDiv, EnsureMul}; - - use super::*; - use crate::{ - liquidity_pools::pallet::development::tests::register_ausd, - utils::{AUSD_CURRENCY_ID, GLIMMER_CURRENCY_ID, MOONBEAM_EVM_CHAIN_ID}, - }; - - // 10 GLMR (18 decimals) - pub const DEFAULT_BALANCE_GLMR: Balance = 10000000000000000000; - pub const DOMAIN_MOONBEAM: Domain = Domain::EVM(MOONBEAM_EVM_CHAIN_ID); - pub const DEFAULT_EVM_ADDRESS_MOONBEAM: [u8; 20] = [99; 20]; - pub const DEFAULT_DOMAIN_ADDRESS_MOONBEAM: DomainAddress = - DomainAddress::EVM(MOONBEAM_EVM_CHAIN_ID, DEFAULT_EVM_ADDRESS_MOONBEAM); - pub const DEFAULT_VALIDITY: Moment = 2555583502; - pub const DEFAULT_OTHER_DOMAIN_ADDRESS: DomainAddress = - DomainAddress::EVM(MOONBEAM_EVM_CHAIN_ID, [0; 20]); - - pub const DEFAULT_MOONBEAM_LOCATION: MultiLocation = MultiLocation { - parents: 1, - interior: X1(Parachain(PARA_ID_MOONBEAM)), - }; - pub type LiquidityPoolMessage = Message; - - pub fn get_default_moonbeam_native_token_location() -> MultiLocation { - MultiLocation { - parents: 1, - interior: X2(Parachain(PARA_ID_MOONBEAM), general_key(&[0, 1])), - } - } - - /// Returns a `VersionedMultiLocation` that can be converted into - /// `LiquidityPoolsWrappedToken` which is required for cross chain asset - /// registration and transfer. - pub fn liquidity_pools_transferable_multilocation( - chain_id: u64, - address: [u8; 20], - ) -> VersionedMultiLocation { - VersionedMultiLocation::V3(MultiLocation { - parents: 0, - interior: - X3( - PalletInstance( - ::PalletInfo::index::< - LiquidityPools, - >() - .expect("LiquidityPools should have pallet index") - .saturated_into(), - ), - GlobalConsensus(NetworkId::Ethereum { chain_id }), - AccountKey20 { - network: None, - key: address, - }, - ), - }) - } - - pub fn set_test_domain_router( - evm_chain_id: u64, - xcm_domain_location: VersionedMultiLocation, - currency_id: CurrencyId, - ) { - let ethereum_xcm_router = EthereumXCMRouter:: { - router: XCMRouter { - xcm_domain: GatewayXcmDomain { - location: Box::new(xcm_domain_location), - ethereum_xcm_transact_call_index: BoundedVec::truncate_from(vec![38, 0]), - contract_address: H160::from(utils::DEFAULT_EVM_ADDRESS_MOONBEAM), - max_gas_limit: 500_000, - transact_required_weight_at_most: Weight::from_parts( - 12530000000, - DEFAULT_PROOF_SIZE.saturating_div(2), - ), - overall_weight: Weight::from_parts(15530000000, DEFAULT_PROOF_SIZE), - fee_currency: currency_id, - // 0.2 token - fee_amount: 200000000000000000, - }, - _marker: Default::default(), - }, - _marker: Default::default(), - }; - - let domain_router = DomainRouter::EthereumXCM(ethereum_xcm_router); - let domain = Domain::EVM(evm_chain_id); - - assert_ok!(LiquidityPoolsGateway::set_domain_router( - RuntimeOrigin::root(), - domain, - domain_router, - )); - } - - /// Initializes universally required storage for liquidityPools tests: - /// * Set the EthereumXCM router which in turn sets: - /// * transact info and domain router for Moonbeam `MultiLocation`, - /// * fee for GLMR (`GLIMMER_CURRENCY_ID`), - /// * Register GLMR and AUSD in `OrmlAssetRegistry`, - /// * Mint 10 GLMR (`DEFAULT_BALANCE_GLMR`) for Alice and Bob. - /// - /// NOTE: AUSD is the default pool currency in `create_pool`. - /// Neither AUSD nor GLMR are registered as a liquidityPools-transferable - /// currency! - pub fn setup_pre_requirements() { - /// Set the EthereumXCM router necessary for Moonbeam. - set_test_domain_router( - MOONBEAM_EVM_CHAIN_ID, - DEFAULT_MOONBEAM_LOCATION.into(), - GLIMMER_CURRENCY_ID, - ); - - /// Register Moonbeam's native token - assert_ok!(OrmlAssetRegistry::register_asset( - RuntimeOrigin::root(), - utils::asset_metadata( - "Glimmer".into(), - "GLMR".into(), - 18, - false, - Some(VersionedMultiLocation::V3( - get_default_moonbeam_native_token_location() - )), - CrossChainTransferability::Xcm(Default::default()), - ), - Some(GLIMMER_CURRENCY_ID) - )); - - // Fund the gateway sender account with enough glimmer to pay for fees - OrmlTokens::deposit( - GLIMMER_CURRENCY_ID, - &::Sender::get(), - DEFAULT_BALANCE_GLMR, - ); - - // Register AUSD in the asset registry which is the default pool currency in - // `create_pool` - register_ausd(); - } - - /// Creates a new pool for the given id with - /// * BOB as admin and depositor - /// * Two tranches - /// * AUSD as pool currency with max reserve 10k. - pub fn create_ausd_pool(pool_id: u64) { - create_currency_pool(pool_id, AUSD_CURRENCY_ID, dollar(currency_decimals::AUSD)) - } - - /// Creates a new pool for for the given id with the provided currency. - /// * BOB as admin and depositor - /// * Two tranches - /// * The given `currency` as pool currency with of `currency_decimals`. - pub fn create_currency_pool(pool_id: u64, currency_id: CurrencyId, currency_decimals: Balance) { - assert_ok!(PoolSystem::create( - BOB.into(), - BOB.into(), - pool_id, - vec![ - TrancheInput { - tranche_type: TrancheType::Residual, - seniority: None, - metadata: TrancheMetadata { - // NOTE: For now, we have to set these metadata fields of the first tranche - // to be convertible to the 32-byte size expected by the liquidity pools - // AddTranche message. - token_name: BoundedVec::< - u8, - cfg_types::consts::pools::MaxTrancheNameLengthBytes, - >::try_from("A highly advanced tranche".as_bytes().to_vec(),) - .expect(""), - token_symbol: BoundedVec::< - u8, - cfg_types::consts::pools::MaxTrancheSymbolLengthBytes, - >::try_from("TrNcH".as_bytes().to_vec()) - .expect(""), - } - }, - TrancheInput { - tranche_type: TrancheType::NonResidual { - interest_rate_per_sec: One::one(), - min_risk_buffer: Perquintill::from_percent(10), - }, - seniority: None, - metadata: TrancheMetadata { - token_name: BoundedVec::default(), - token_symbol: BoundedVec::default(), - } - } - ], - currency_id, - currency_decimals, - )); - } - - /// Returns metadata for the given data with existential deposit of - /// 1_000_000. - pub fn asset_metadata( - name: Vec, - symbol: Vec, - decimals: u32, - is_pool_currency: bool, - location: Option, - transferability: CrossChainTransferability, - ) -> AssetMetadata { - AssetMetadata { - name, - symbol, - decimals, - location, - existential_deposit: 1_000_000, - additional: CustomMetadata { - transferability, - mintable: false, - permissioned: false, - pool_currency: is_pool_currency, - }, - } - } - - pub mod investments { - use super::*; - - /// Returns the investment account of the given investment_id. - pub fn investment_account(investment_id: cfg_types::tokens::TrancheCurrency) -> AccountId { - InvestmentAccount { investment_id }.into_account_truncating() - } - - pub fn default_investment_account(pool_id: u64) -> AccountId { - InvestmentAccount { - investment_id: investment_id(pool_id, default_tranche_id(pool_id)), - } - .into_account_truncating() - } - - /// Returns the investment_id of the given pool and tranche ids. - pub fn investment_id( - pool_id: u64, - tranche_id: TrancheId, - ) -> cfg_types::tokens::TrancheCurrency { - ::TrancheCurrency::generate( - pool_id, tranche_id, - ) - } - - /// Returns the tranche id at index 0 for the given pool id. - pub fn default_tranche_id(pool_id: u64) -> TrancheId { - let pool_details = PoolSystem::pool(pool_id).expect("Pool should exist"); - pool_details - .tranches - .tranche_id(TrancheLoc::Index(0)) - .expect("Tranche at index 0 exists") - } - - /// Returns the derived general currency index. - /// - /// Throws if the provided currency_id is not - /// `CurrencyId::ForeignAsset(id)`. - pub fn general_currency_index(currency_id: CurrencyId) -> u128 { - pallet_liquidity_pools::Pallet::::try_get_general_index(currency_id) - .expect("ForeignAsset should convert into u128") - } - - /// Sets up required permissions for the investor and executes an - /// initial investment via LiquidityPools by executing - /// `IncreaseInvestOrder`. - /// - /// Assumes `utils::setup_pre_requirements` and - /// `utils::investments::create_currency_pool` to have been called - /// beforehand - pub fn do_initial_increase_investment( - pool_id: u64, - amount: Balance, - investor: AccountId, - currency_id: CurrencyId, - ) { - let valid_until = utils::DEFAULT_VALIDITY; - - // Mock incoming increase invest message - let msg = utils::LiquidityPoolMessage::IncreaseInvestOrder { - pool_id, - tranche_id: default_tranche_id(pool_id), - investor: investor.clone().into(), - currency: general_currency_index(currency_id), - amount, - }; - // TODO(after PR #1376): Re-activate via Gateway handling - // // Should fail if instance has not been added yet - // assert_noop!( - // LiquidityPools::submit(utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg.clone()), - // pallet_liquidity_pools::Error::::InvalidIncomingMessageOrigin - // ); - // assert_ok!(LiquidityPools::add_instance( - // RuntimeOrigin::root(), - // utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM.address().into() - // )); - - // Should fail if investor does not have investor role yet - assert_noop!( - LiquidityPools::submit(utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg.clone()), - DispatchError::Other("Account does not have the TrancheInvestor permission.") - ); - - // Make investor the MembersListAdmin of this Pool - assert_ok!(Permissions::add( - RuntimeOrigin::root(), - Role::PoolRole(PoolRole::PoolAdmin), - investor.clone(), - PermissionScope::Pool(pool_id), - Role::PoolRole(PoolRole::TrancheInvestor( - default_tranche_id(pool_id), - valid_until - )), - )); - - let amount_before = OrmlTokens::free_balance( - currency_id, - &investment_account(investment_id(pool_id, default_tranche_id(pool_id))), - ); - let final_amount = amount_before - .ensure_add(amount) - .expect("Should not overflow when incrementing amount"); - - // Execute byte message - assert_ok!(LiquidityPools::submit( - utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, - msg - )); - - // Verify investment was transferred into investment account - assert_eq!( - OrmlTokens::free_balance( - currency_id, - &investment_account(investment_id(pool_id, default_tranche_id(pool_id))) - ), - final_amount - ); - assert_eq!( - System::events().iter().last().unwrap().event, - pallet_investments::Event::::InvestOrderUpdated { - investment_id: investment_id(pool_id, default_tranche_id(pool_id)), - submitted_at: 0, - who: investor, - amount: final_amount - } - .into() - ); - } - - /// Sets up required permissions for the investor and executes an - /// initial redemption via LiquidityPools by executing - /// `IncreaseRedeemOrder`. - /// - /// Assumes `utils::setup_pre_requirements` and - /// `utils::investments::create_currency_pool` to have been called - /// beforehand - pub fn do_initial_increase_redemption( - pool_id: u64, - amount: Balance, - investor: AccountId, - currency_id: CurrencyId, - ) { - let valid_until = utils::DEFAULT_VALIDITY; - - // Fund `DomainLocator` account of origination domain as redeemed tranche tokens - // are transferred from this account instead of minting - assert_ok!(OrmlTokens::mint_into( - investment_id(pool_id, default_tranche_id(pool_id)).into(), - &Domain::convert(DEFAULT_DOMAIN_ADDRESS_MOONBEAM.domain()), - amount - )); - - // Verify redemption has not been made yet - assert_eq!( - OrmlTokens::free_balance( - investment_id(pool_id, default_tranche_id(pool_id)).into(), - &investment_account(investment_id(pool_id, default_tranche_id(pool_id))) - ), - 0 - ); - assert_eq!( - OrmlTokens::free_balance( - investment_id(pool_id, default_tranche_id(pool_id)).into(), - &investor - ), - 0 - ); - - // Mock incoming increase invest message - let msg = utils::LiquidityPoolMessage::IncreaseRedeemOrder { - pool_id: 42, - tranche_id: default_tranche_id(pool_id), - investor: investor.clone().into(), - currency: general_currency_index(currency_id), - amount, - }; - // TODO(after PR #1376): Re-activate via Gateway handling - // // Should fail if instance has not been added yet - // assert_noop!( - // LiquidityPools::submit(utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg.clone()), - // pallet_liquidity_pools::Error::::InvalidIncomingMessageOrigin - // ); - // assert_ok!(LiquidityPools::add_instance( - // RuntimeOrigin::root(), - // utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM.address().into() - // )); - - // Should fail if investor does not have investor role yet - assert_noop!( - LiquidityPools::submit(utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg.clone()), - DispatchError::Other("Account does not have the TrancheInvestor permission.") - ); - - // Make investor the MembersListAdmin of this Pool - assert_ok!(Permissions::add( - RuntimeOrigin::root(), - Role::PoolRole(PoolRole::PoolAdmin), - investor.clone(), - PermissionScope::Pool(pool_id), - Role::PoolRole(PoolRole::TrancheInvestor( - default_tranche_id(pool_id), - valid_until - )), - )); - - assert_ok!(LiquidityPools::submit( - utils::DEFAULT_DOMAIN_ADDRESS_MOONBEAM, - msg - )); - - // Verify redemption was transferred into investment account - assert_eq!( - OrmlTokens::free_balance( - investment_id(pool_id, default_tranche_id(pool_id)).into(), - &investment_account(investment_id(pool_id, default_tranche_id(pool_id))) - ), - amount - ); - assert_eq!( - OrmlTokens::free_balance( - investment_id(pool_id, default_tranche_id(pool_id)).into(), - &investor - ), - 0 - ); - assert_eq!( - OrmlTokens::free_balance( - investment_id(pool_id, default_tranche_id(pool_id)).into(), - &AccountConverter::::convert( - DEFAULT_OTHER_DOMAIN_ADDRESS - ) - ), - 0 - ); - assert_eq!( - System::events().iter().last().unwrap().event, - pallet_investments::Event::::RedeemOrderUpdated { - investment_id: investment_id(pool_id, default_tranche_id(pool_id)), - submitted_at: 0, - who: investor, - amount - } - .into() - ); - - // Verify order id is 0 - assert_eq!( - pallet_investments::Pallet::::redeem_order_id(investment_id( - pool_id, - default_tranche_id(pool_id) - )), - 0 - ); - } - } -} diff --git a/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/add_allow_upgrade.rs b/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/add_allow_upgrade.rs new file mode 100644 index 0000000000..656fd5de9e --- /dev/null +++ b/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/add_allow_upgrade.rs @@ -0,0 +1,738 @@ +// Copyright 2021 Centrifuge GmbH (centrifuge.io). +// This file is part of Centrifuge chain project. +// +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// Copyright 2021 Centrifuge GmbH (centrifuge.io). +// This file is part of Centrifuge chain project. +// +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +use cfg_primitives::{currency_decimals, parachains, AccountId, Balance, PoolId, TrancheId, CFG}; +use cfg_traits::{ + investments::{OrderManager, TrancheCurrency as TrancheCurrencyT}, + liquidity_pools::InboundQueue, + Permissions as _, +}; +use cfg_types::{ + domain_address::{Domain, DomainAddress}, + permissions::{PermissionScope, PoolRole, Role}, + tokens::{ + CrossChainTransferability, CurrencyId, CurrencyId::ForeignAsset, CustomMetadata, + ForeignAssetId, + }, +}; +use development_runtime::{ + LiquidityPools, LocationToAccountId, OrmlAssetRegistry, OrmlTokens, Permissions, + Runtime as DevelopmentRuntime, RuntimeOrigin, System, TreasuryAccount, XTokens, XcmTransactor, +}; +use frame_support::{assert_noop, assert_ok, traits::fungibles::Mutate}; +use orml_traits::{asset_registry::AssetMetadata, FixedConversionRateProvider, MultiCurrency}; +use runtime_common::account_conversion::AccountConverter; +use sp_runtime::{ + traits::{BadOrigin, Convert, One, Zero}, + BoundedVec, DispatchError, +}; +use xcm::{latest::MultiLocation, VersionedMultiLocation}; +use xcm_emulator::TestExt; + +use crate::{ + liquidity_pools::pallet::development::{ + setup::{dollar, ALICE, BOB}, + test_net::{Development, Moonbeam, RelayChain, TestNet}, + tests::liquidity_pools::setup::{ + asset_metadata, create_ausd_pool, create_currency_pool, + enable_liquidity_pool_transferability, get_default_moonbeam_native_token_location, + investments::default_tranche_id, liquidity_pools_transferable_multilocation, + setup_pre_requirements, DEFAULT_BALANCE_GLMR, DEFAULT_MOONBEAM_LOCATION, + DEFAULT_POOL_ID, DEFAULT_VALIDITY, + }, + }, + utils::{AUSD_CURRENCY_ID, GLMR_CURRENCY_ID, MOONBEAM_EVM_CHAIN_ID}, +}; + +/// NOTE: We can't actually verify that the messages hits the +/// LiquidityPoolsXcmRouter contract on Moonbeam since that would require a +/// rather heavy e2e setup to emulate, involving depending on Moonbeam's +/// runtime, having said contract deployed to their evm environment, and be able +/// to query the evm side. Instead, these tests verify that - given all +/// pre-requirements are set up correctly - we succeed to send the message from +/// the Centrifuge chain pov. We have other unit tests verifying the +/// LiquidityPools' messages encoding and the encoding of the remote EVM call to +/// be executed on Moonbeam. +/// Verify that `LiquidityPools::add_pool` succeeds when called with all the +/// necessary requirements. +#[test] +fn add_pool() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + + // Verify that the pool must exist before we can call LiquidityPools::add_pool + assert_noop!( + LiquidityPools::add_pool( + RuntimeOrigin::signed(ALICE.into()), + pool_id, + Domain::EVM(MOONBEAM_EVM_CHAIN_ID), + ), + pallet_liquidity_pools::Error::::PoolNotFound + ); + + // Now create the pool + create_ausd_pool(pool_id); + + // Verify ALICE can't call `add_pool` given she is not the `PoolAdmin` + assert_noop!( + LiquidityPools::add_pool( + RuntimeOrigin::signed(ALICE.into()), + pool_id, + Domain::EVM(MOONBEAM_EVM_CHAIN_ID), + ), + pallet_liquidity_pools::Error::::NotPoolAdmin + ); + + // Verify that it works if it's BOB calling it (the pool admin) + assert_ok!(LiquidityPools::add_pool( + RuntimeOrigin::signed(BOB.into()), + pool_id, + Domain::EVM(MOONBEAM_EVM_CHAIN_ID), + )); + }); +} + +/// Verify that `LiquidityPools::add_tranche` succeeds when called with all the +/// necessary requirements. We can't actually verify that the call hits the +/// LiquidityPoolsXcmRouter contract on Moonbeam since that would require a very +/// heavy e2e setup to emulate. Instead, here we test that we can send the +/// extrinsic and we have other unit tests verifying the encoding of the remote +/// EVM call to be executed on Moonbeam. +#[test] +fn add_tranche() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let decimals: u8 = 15; + + // Now create the pool + let pool_id = DEFAULT_POOL_ID; + create_ausd_pool(pool_id); + + // Verify we can't call LiquidityPools::add_tranche with a non-existing + // tranche_id + let nonexistent_tranche = [71u8; 16]; + assert_noop!( + LiquidityPools::add_tranche( + RuntimeOrigin::signed(ALICE.into()), + pool_id, + nonexistent_tranche, + Domain::EVM(MOONBEAM_EVM_CHAIN_ID), + ), + pallet_liquidity_pools::Error::::TrancheNotFound + ); + let tranche_id = default_tranche_id(pool_id); + + // Verify ALICE can't call `add_tranche` given she is not the `PoolAdmin` + assert_noop!( + LiquidityPools::add_tranche( + RuntimeOrigin::signed(ALICE.into()), + pool_id, + tranche_id, + Domain::EVM(MOONBEAM_EVM_CHAIN_ID), + ), + pallet_liquidity_pools::Error::::NotPoolAdmin + ); + + // Finally, verify we can call LiquidityPools::add_tranche successfully + // when called by the PoolAdmin with the right pool + tranche id pair. + assert_ok!(LiquidityPools::add_tranche( + RuntimeOrigin::signed(BOB.into()), + pool_id, + tranche_id, + Domain::EVM(MOONBEAM_EVM_CHAIN_ID), + )); + + // Edge case: Should throw if tranche exists but metadata does not exist + let tranche_currency_id = CurrencyId::Tranche(pool_id, tranche_id); + orml_asset_registry::Metadata::::remove(tranche_currency_id); + assert_noop!( + LiquidityPools::update_tranche_token_metadata( + RuntimeOrigin::signed(BOB.into()), + pool_id, + tranche_id, + Domain::EVM(MOONBEAM_EVM_CHAIN_ID), + ), + pallet_liquidity_pools::Error::::TrancheMetadataNotFound + ); + }); +} + +#[test] +fn update_member() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + + // Now create the pool + let pool_id = DEFAULT_POOL_ID; + create_ausd_pool(pool_id); + let tranche_id = default_tranche_id(pool_id); + + // Finally, verify we can call LiquidityPools::add_tranche successfully + // when given a valid pool + tranche id pair. + let new_member = DomainAddress::EVM(MOONBEAM_EVM_CHAIN_ID, [3; 20]); + let valid_until = DEFAULT_VALIDITY; + + // Make ALICE the MembersListAdmin of this Pool + assert_ok!(Permissions::add( + RuntimeOrigin::root(), + Role::PoolRole(PoolRole::PoolAdmin), + ALICE.into(), + PermissionScope::Pool(pool_id), + Role::PoolRole(PoolRole::InvestorAdmin), + )); + + // Verify it fails if the destination is not whitelisted yet + assert_noop!( + LiquidityPools::update_member( + RuntimeOrigin::signed(ALICE.into()), + pool_id, + tranche_id, + new_member.clone(), + valid_until, + ), + pallet_liquidity_pools::Error::::InvestorDomainAddressNotAMember, + ); + + // Whitelist destination as TrancheInvestor of this Pool + assert_ok!(Permissions::add( + RuntimeOrigin::signed(ALICE.into()), + Role::PoolRole(PoolRole::InvestorAdmin), + AccountConverter::::convert( + new_member.clone() + ), + PermissionScope::Pool(pool_id), + Role::PoolRole(PoolRole::TrancheInvestor( + default_tranche_id(pool_id), + valid_until + )), + )); + + // Verify the Investor role was set as expected in Permissions + assert!(Permissions::has( + PermissionScope::Pool(pool_id), + AccountConverter::::convert( + new_member.clone() + ), + Role::PoolRole(PoolRole::TrancheInvestor(tranche_id, valid_until)), + )); + + // Verify it now works + assert_ok!(LiquidityPools::update_member( + RuntimeOrigin::signed(ALICE.into()), + pool_id, + tranche_id, + new_member, + valid_until, + )); + + // Verify it cannot be called for another member without whitelisting the domain + // beforehand + assert_noop!( + LiquidityPools::update_member( + RuntimeOrigin::signed(ALICE.into()), + pool_id, + tranche_id, + DomainAddress::EVM(MOONBEAM_EVM_CHAIN_ID, [9; 20]), + valid_until, + ), + pallet_liquidity_pools::Error::::InvestorDomainAddressNotAMember, + ); + }); +} + +#[test] +fn update_token_price() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let decimals: u8 = 15; + let currency_id = AUSD_CURRENCY_ID; + let pool_id = DEFAULT_POOL_ID; + create_ausd_pool(pool_id); + enable_liquidity_pool_transferability(currency_id); + + assert_ok!(LiquidityPools::update_token_price( + RuntimeOrigin::signed(BOB.into()), + pool_id, + default_tranche_id(pool_id), + currency_id, + Domain::EVM(MOONBEAM_EVM_CHAIN_ID), + )); + }); +} + +#[test] +fn add_currency() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + + let currency_id = AUSD_CURRENCY_ID; + + // Enable LiquidityPools transferability + enable_liquidity_pool_transferability(currency_id); + + assert_eq!( + OrmlTokens::free_balance( + GLMR_CURRENCY_ID, + &::Sender::get() + ), + DEFAULT_BALANCE_GLMR + ); + + assert_ok!(LiquidityPools::add_currency( + RuntimeOrigin::signed(BOB.into()), + currency_id + )); + + assert_eq!( + OrmlTokens::free_balance( + GLMR_CURRENCY_ID, + &::Sender::get() + ), + /// Ensure it only charged the 0.2 GLMR of fee + DEFAULT_BALANCE_GLMR + - dollar(18).saturating_div(5) + ); + }); +} + +#[test] +fn add_currency_should_fail() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + + assert_noop!( + LiquidityPools::add_currency( + RuntimeOrigin::signed(BOB.into()), + CurrencyId::ForeignAsset(42) + ), + pallet_liquidity_pools::Error::::AssetNotFound + ); + assert_noop!( + LiquidityPools::add_currency(RuntimeOrigin::signed(BOB.into()), CurrencyId::Native), + pallet_liquidity_pools::Error::::AssetNotFound + ); + assert_noop!( + LiquidityPools::add_currency( + RuntimeOrigin::signed(BOB.into()), + CurrencyId::Staking(cfg_types::tokens::StakingCurrency::BlockRewards) + ), + pallet_liquidity_pools::Error::::AssetNotFound + ); + assert_noop!( + LiquidityPools::add_currency( + RuntimeOrigin::signed(BOB.into()), + CurrencyId::Staking(cfg_types::tokens::StakingCurrency::BlockRewards) + ), + pallet_liquidity_pools::Error::::AssetNotFound + ); + + // Should fail to add currency_id which is missing a registered + // MultiLocation + let currency_id = CurrencyId::ForeignAsset(100); + assert_ok!(OrmlAssetRegistry::register_asset( + RuntimeOrigin::root(), + asset_metadata( + "Test".into(), + "TEST".into(), + 12, + false, + None, + CrossChainTransferability::LiquidityPools, + ), + Some(currency_id) + )); + assert_noop!( + LiquidityPools::add_currency(RuntimeOrigin::signed(BOB.into()), currency_id), + pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsWrappedToken + ); + + // Add convertable MultiLocation to metadata but remove transferability + assert_ok!(OrmlAssetRegistry::update_asset( + RuntimeOrigin::root(), + currency_id, + None, + None, + None, + None, + // Changed: Add multilocation to metadata for some random EVM chain id for which no + // instance is registered + Some(Some(liquidity_pools_transferable_multilocation( + u64::MAX, + [1u8; 20], + ))), + Some(CustomMetadata { + // Changed: Disallow liquidityPools transferability + transferability: CrossChainTransferability::Xcm(Default::default()), + mintable: Default::default(), + permissioned: Default::default(), + pool_currency: Default::default(), + }), + )); + assert_noop!( + LiquidityPools::add_currency(RuntimeOrigin::signed(BOB.into()), currency_id), + pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsTransferable + ); + + // Switch transferability from XCM to None + assert_ok!(OrmlAssetRegistry::update_asset( + RuntimeOrigin::root(), + currency_id, + None, + None, + None, + None, + None, + Some(CustomMetadata { + // Changed: Disallow cross chain transferability entirely + transferability: CrossChainTransferability::None, + mintable: Default::default(), + permissioned: Default::default(), + pool_currency: Default::default(), + }) + )); + assert_noop!( + LiquidityPools::add_currency(RuntimeOrigin::signed(BOB.into()), currency_id), + pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsTransferable + ); + }); +} + +#[test] +fn allow_pool_currency() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + + let currency_id = AUSD_CURRENCY_ID; + let pool_id = DEFAULT_POOL_ID; + let evm_chain_id: u64 = MOONBEAM_EVM_CHAIN_ID; + let evm_address = [1u8; 20]; + + // Create an AUSD pool + create_ausd_pool(pool_id); + + // Enable LiquidityPools transferability + assert_ok!(OrmlAssetRegistry::update_asset( + RuntimeOrigin::root(), + currency_id, + None, + None, + None, + None, + // Changed: Add location which can be converted to LiquidityPoolsWrappedToken + Some(Some(liquidity_pools_transferable_multilocation( + evm_chain_id, + evm_address, + ))), + Some(CustomMetadata { + // Changed: Allow liquidity_pools transferability + transferability: CrossChainTransferability::LiquidityPools, + mintable: Default::default(), + permissioned: Default::default(), + pool_currency: true, + }) + )); + + assert_ok!(LiquidityPools::allow_pool_currency( + RuntimeOrigin::signed(BOB.into()), + pool_id, + default_tranche_id(pool_id), + currency_id, + )); + }); +} + +#[test] +fn allow_pool_should_fail() { + TestNet::reset(); + + Development::execute_with(|| { + let pool_id = DEFAULT_POOL_ID; + let currency_id = CurrencyId::ForeignAsset(42); + let ausd_currency_id = AUSD_CURRENCY_ID; + + setup_pre_requirements(); + // Should fail if pool does not exist + assert_noop!( + LiquidityPools::allow_pool_currency( + RuntimeOrigin::signed(BOB.into()), + pool_id, + // Tranche id is arbitrary in this case as pool does not exist + [0u8; 16], + currency_id, + ), + pallet_liquidity_pools::Error::::PoolNotFound + ); + + // Register currency_id with pool_currency set to true + assert_ok!(OrmlAssetRegistry::register_asset( + RuntimeOrigin::root(), + asset_metadata( + "Test".into(), + "TEST".into(), + 12, + true, + None, + Default::default(), + ), + Some(currency_id) + )); + + // Create pool + create_currency_pool(pool_id, currency_id, 10_000 * dollar(12)); + + // Should fail if asset is not pool currency + assert!(currency_id != ausd_currency_id); + assert_noop!( + LiquidityPools::allow_pool_currency( + RuntimeOrigin::signed(BOB.into()), + pool_id, + default_tranche_id(pool_id), + ausd_currency_id, + ), + pallet_liquidity_pools::Error::::InvalidPaymentCurrency + ); + + // Should fail if currency is not liquidityPools transferable + assert_ok!(OrmlAssetRegistry::update_asset( + RuntimeOrigin::root(), + currency_id, + None, + None, + None, + None, + None, + Some(CustomMetadata { + // Disallow any cross chain transferability + transferability: CrossChainTransferability::None, + mintable: Default::default(), + permissioned: Default::default(), + // Changed: Allow to be usable as pool currency + pool_currency: true, + }), + )); + assert_noop!( + LiquidityPools::allow_pool_currency( + RuntimeOrigin::signed(BOB.into()), + pool_id, + default_tranche_id(pool_id), + currency_id, + ), + pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsTransferable + ); + + // Should fail if currency does not have any MultiLocation in metadata + assert_ok!(OrmlAssetRegistry::update_asset( + RuntimeOrigin::root(), + currency_id, + None, + None, + None, + None, + None, + Some(CustomMetadata { + // Changed: Allow liquidityPools transferability + transferability: CrossChainTransferability::LiquidityPools, + mintable: Default::default(), + permissioned: Default::default(), + // Still allow to be pool currency + pool_currency: true, + }), + )); + assert_noop!( + LiquidityPools::allow_pool_currency( + RuntimeOrigin::signed(BOB.into()), + pool_id, + default_tranche_id(pool_id), + currency_id, + ), + pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsWrappedToken + ); + + // Should fail if currency does not have LiquidityPoolsWrappedToken location in + // metadata + assert_ok!(OrmlAssetRegistry::update_asset( + RuntimeOrigin::root(), + currency_id, + None, + None, + None, + None, + // Changed: Add some location which cannot be converted to LiquidityPoolsWrappedToken + Some(Some(VersionedMultiLocation::V3(Default::default()))), + // No change for transferability required as it is already allowed for LiquidityPools + None, + )); + assert_noop!( + LiquidityPools::allow_pool_currency( + RuntimeOrigin::signed(BOB.into()), + pool_id, + default_tranche_id(pool_id), + currency_id, + ), + pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsWrappedToken + ); + + // Create new pool for non foreign asset + // NOTE: Can be removed after merging https://github.com/centrifuge/centrifuge-chain/pull/1343 + assert_ok!(OrmlAssetRegistry::register_asset( + RuntimeOrigin::root(), + asset_metadata( + "Acala Dollar".into(), + "AUSD".into(), + 12, + true, + None, + Default::default() + ), + Some(CurrencyId::AUSD) + )); + create_currency_pool(pool_id + 1, CurrencyId::AUSD, 10_000 * dollar(12)); + // Should fail if currency is not foreign asset + assert_noop!( + LiquidityPools::allow_pool_currency( + RuntimeOrigin::signed(BOB.into()), + pool_id + 1, + // Tranche id is arbitrary in this case, so we don't need to check for the exact + // pool_id + default_tranche_id(pool_id + 1), + CurrencyId::AUSD, + ), + DispatchError::Token(sp_runtime::TokenError::Unsupported) + ); + }); +} + +#[test] +fn schedule_upgrade() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + + // Only Root can call `schedule_upgrade` + assert_noop!( + LiquidityPools::schedule_upgrade( + RuntimeOrigin::signed(BOB.into()), + MOONBEAM_EVM_CHAIN_ID, + [7; 20] + ), + BadOrigin + ); + + // Now it finally works + assert_ok!(LiquidityPools::schedule_upgrade( + RuntimeOrigin::root(), + MOONBEAM_EVM_CHAIN_ID, + [7; 20] + )); + }); +} + +#[test] +fn cancel_upgrade_upgrade() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + + // Only Root can call `cancel_upgrade` + assert_noop!( + LiquidityPools::cancel_upgrade( + RuntimeOrigin::signed(BOB.into()), + MOONBEAM_EVM_CHAIN_ID, + [7; 20] + ), + BadOrigin + ); + + // Now it finally works + assert_ok!(LiquidityPools::cancel_upgrade( + RuntimeOrigin::root(), + MOONBEAM_EVM_CHAIN_ID, + [7; 20] + )); + }); +} + +#[test] +fn update_tranche_token_metadata() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let decimals: u8 = 15; + let pool_id = DEFAULT_POOL_ID; + // NOTE: Default pool admin is BOB + create_ausd_pool(pool_id); + + // Missing tranche token should throw + let nonexistent_tranche = [71u8; 16]; + assert_noop!( + LiquidityPools::update_tranche_token_metadata( + RuntimeOrigin::signed(ALICE.into()), + pool_id, + nonexistent_tranche, + Domain::EVM(MOONBEAM_EVM_CHAIN_ID), + ), + pallet_liquidity_pools::Error::::TrancheNotFound + ); + let tranche_id = default_tranche_id(pool_id); + + // Should throw if called by anything but `PoolAdmin` + assert_noop!( + LiquidityPools::update_tranche_token_metadata( + RuntimeOrigin::signed(ALICE.into()), + pool_id, + tranche_id, + Domain::EVM(MOONBEAM_EVM_CHAIN_ID), + ), + pallet_liquidity_pools::Error::::NotPoolAdmin + ); + + assert_ok!(LiquidityPools::update_tranche_token_metadata( + RuntimeOrigin::signed(BOB.into()), + pool_id, + tranche_id, + Domain::EVM(MOONBEAM_EVM_CHAIN_ID), + )); + + // Edge case: Should throw if tranche exists but metadata does not exist + let tranche_currency_id = CurrencyId::Tranche(pool_id, tranche_id); + orml_asset_registry::Metadata::::remove(tranche_currency_id); + assert_noop!( + LiquidityPools::update_tranche_token_metadata( + RuntimeOrigin::signed(BOB.into()), + pool_id, + tranche_id, + Domain::EVM(MOONBEAM_EVM_CHAIN_ID), + ), + pallet_liquidity_pools::Error::::TrancheMetadataNotFound + ); + }); +} diff --git a/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/foreign_investments.rs b/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/foreign_investments.rs new file mode 100644 index 0000000000..40a3f044ab --- /dev/null +++ b/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/foreign_investments.rs @@ -0,0 +1,2872 @@ +// Copyright 2021 Centrifuge GmbH (centrifuge.io). +// This file is part of Centrifuge chain project. +// +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// Copyright 2021 Centrifuge GmbH (centrifuge.io). +// This file is part of Centrifuge chain project. +// +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +use cfg_primitives::{currency_decimals, parachains, AccountId, Balance, PoolId, TrancheId, CFG}; +use cfg_traits::{ + investments::{Investment, OrderManager, TrancheCurrency as TrancheCurrencyT}, + liquidity_pools::InboundQueue, + IdentityCurrencyConversion, PoolInspect, +}; +use cfg_types::{ + domain_address::{Domain, DomainAddress}, + fixed_point::{Quantity, Ratio}, + investments::{CollectedAmount, InvestCollection, InvestmentAccount, RedeemCollection, Swap}, + orders::FulfillmentWithPrice, + permissions::{PermissionScope, PoolRole, Role, UNION}, + pools::TrancheMetadata, + tokens::{ + CrossChainTransferability, CurrencyId, CurrencyId::ForeignAsset, CustomMetadata, + ForeignAssetId, + }, +}; +use development_runtime::{ + Balances, ForeignInvestments, Investments, LiquidityPools, LocationToAccountId, + OrmlAssetRegistry, Permissions, PoolSystem, Runtime as DevelopmentRuntime, RuntimeOrigin, + System, Tokens, +}; +use frame_support::{ + assert_noop, assert_ok, + traits::{ + fungibles::{Inspect, Mutate, Transfer}, + Get, PalletInfo, + }, +}; +use orml_traits::{asset_registry::AssetMetadata, FixedConversionRateProvider, MultiCurrency}; +use pallet_foreign_investments::{ + types::{InvestState, RedeemState}, + CollectedRedemption, InvestmentState, RedemptionState, +}; +use pallet_investments::CollectOutcome; +use runtime_common::{ + account_conversion::AccountConverter, foreign_investments::IdentityPoolCurrencyConverter, +}; +use sp_runtime::{ + traits::{AccountIdConversion, BadOrigin, ConstU32, Convert, EnsureAdd, One, Zero}, + BoundedVec, DispatchError, FixedPointNumber, Perquintill, SaturatedConversion, WeakBoundedVec, +}; +use xcm_emulator::TestExt; + +use crate::{ + liquidity_pools::pallet::development::{ + setup::{dollar, ALICE, BOB}, + test_net::{Development, Moonbeam, RelayChain, TestNet}, + tests::liquidity_pools::{ + foreign_investments::setup::{ + do_initial_increase_investment, do_initial_increase_redemption, + }, + setup::{ + asset_metadata, create_ausd_pool, create_currency_pool, + enable_liquidity_pool_transferability, + investments::{ + default_investment_account, default_investment_id, default_tranche_id, + general_currency_index, investment_id, + }, + setup_pre_requirements, LiquidityPoolMessage, DEFAULT_DOMAIN_ADDRESS_MOONBEAM, + DEFAULT_POOL_ID, DEFAULT_VALIDITY, DOMAIN_MOONBEAM, + }, + }, + }, + utils::AUSD_CURRENCY_ID, +}; + +mod same_currencies { + + use pallet_foreign_investments::{CollectedInvestment, InvestmentState}; + + use super::*; + + #[test] + fn increase_invest_order() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let amount = 100_000_000; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let currency_id = AUSD_CURRENCY_ID; + let currency_decimals = currency_decimals::AUSD; + + // Create new pool + create_currency_pool(pool_id, currency_id, currency_decimals.into()); + + // Set permissions and execute initial investment + do_initial_increase_investment(pool_id, amount, investor.clone(), currency_id); + + // Verify the order was updated to the amount + assert_eq!( + pallet_investments::Pallet::::acc_active_invest_order( + default_investment_id(), + ) + .amount, + amount + ); + + // Increasing again should just bump invest_amount + let msg = LiquidityPoolMessage::IncreaseInvestOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + amount, + }; + assert_ok!(LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg)); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::InvestmentOngoing { + invest_amount: amount * 2 + } + ); + }); + } + + #[test] + fn decrease_invest_order() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let invest_amount: u128 = 100_000_000; + let decrease_amount = invest_amount / 3; + let final_amount = invest_amount - decrease_amount; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let currency_id: CurrencyId = AUSD_CURRENCY_ID; + let currency_decimals = currency_decimals::AUSD; + + // Create new pool + create_currency_pool(pool_id, currency_id, currency_decimals.into()); + + // Set permissions and execute initial investment + do_initial_increase_investment(pool_id, invest_amount, investor.clone(), currency_id); + + // Mock incoming decrease message + let msg = LiquidityPoolMessage::DecreaseInvestOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + amount: decrease_amount, + }; + + // Expect failure if transferability is disabled since this is required for + // preparing the `ExecutedDecreaseInvest` message. + assert_noop!( + LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg.clone()), + pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsTransferable + ); + enable_liquidity_pool_transferability(currency_id); + + // Execute byte message + assert_ok!(LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg)); + + // Verify investment was decreased into investment account + assert_eq!( + Tokens::balance(currency_id, &default_investment_account()), + final_amount + ); + // Since the investment was done in the pool currency, the decrement happens + // synchronously and thus it must be burned from investor's holdings + assert_eq!(Tokens::balance(currency_id, &investor), 0); + assert!(System::events().iter().any(|e| e.event + == pallet_investments::Event::::InvestOrderUpdated { + investment_id: default_investment_id(), + submitted_at: 0, + who: investor.clone(), + amount: final_amount + } + .into())); + assert!(System::events().iter().any(|e| e.event + == orml_tokens::Event::::Withdrawn { + currency_id, + who: investor.clone(), + amount: decrease_amount + } + .into())); + assert_eq!( + pallet_investments::Pallet::::acc_active_invest_order( + default_investment_id(), + ) + .amount, + final_amount + ); + }); + } + + #[test] + fn cancel_invest_order() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let invest_amount = 100_000_000; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let currency_id = AUSD_CURRENCY_ID; + let currency_decimals = currency_decimals::AUSD; + + // Create new pool + create_currency_pool(pool_id, currency_id, currency_decimals.into()); + + // Set permissions and execute initial investment + do_initial_increase_investment(pool_id, invest_amount, investor.clone(), currency_id); + + // Verify investment account holds funds before cancelling + assert_eq!( + Tokens::balance(currency_id, &default_investment_account()), + invest_amount + ); + + // Mock incoming cancel message + let msg = LiquidityPoolMessage::CancelInvestOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + }; + + // Expect failure if transferability is disabled since this is required for + // preparing the `ExecutedDecreaseInvest` message. + assert_noop!( + LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg.clone()), + pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsTransferable + ); + enable_liquidity_pool_transferability(currency_id); + + // Execute byte message + assert_ok!(LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg)); + + // Foreign InvestmentState should be cleared + assert!(!pallet_foreign_investments::InvestmentState::< + DevelopmentRuntime, + >::contains_key(&investor, default_investment_id())); + assert!(System::events().iter().any(|e| { + e.event == pallet_foreign_investments::Event::::ForeignInvestmentCleared { + investor: investor.clone(), + investment_id: default_investment_id(), + } + .into() + })); + + // Verify investment was entirely drained from investment account + assert_eq!( + Tokens::balance(currency_id, &default_investment_account()), + 0 + ); + // Since the investment was done in the pool currency, the decrement happens + // synchronously and thus it must be burned from investor's holdings + assert_eq!(Tokens::balance(currency_id, &investor), 0); + assert!(System::events().iter().any(|e| e.event + == pallet_investments::Event::::InvestOrderUpdated { + investment_id: default_investment_id(), + submitted_at: 0, + who: investor.clone(), + amount: 0 + } + .into())); + assert!(System::events().iter().any(|e| e.event + == orml_tokens::Event::::Withdrawn { + currency_id, + who: investor.clone(), + amount: invest_amount + } + .into())); + assert_eq!( + pallet_investments::Pallet::::acc_active_invest_order( + default_investment_id(), + ) + .amount, + 0 + ); + }); + } + + #[test] + fn collect_invest_order() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let amount = 100_000_000; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let currency_id = AUSD_CURRENCY_ID; + let currency_decimals = currency_decimals::AUSD; + let sending_domain_locator = Domain::convert(DEFAULT_DOMAIN_ADDRESS_MOONBEAM.domain()); + + // Create new pool + create_currency_pool(pool_id, currency_id, currency_decimals.into()); + let investment_currency_id: CurrencyId = default_investment_id().into(); + + // Set permissions and execute initial investment + do_initial_increase_investment(pool_id, amount, investor.clone(), currency_id); + let events_before_collect = System::events(); + + // Process and fulfill order + // NOTE: Without this step, the order id is not cleared and + // `Event::InvestCollectedForNonClearedOrderId` be dispatched + assert_ok!(Investments::process_invest_orders(default_investment_id())); + + // Tranche tokens will be minted upon fulfillment + assert_eq!(Tokens::total_issuance(investment_currency_id), 0); + assert_ok!(Investments::invest_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::one(), + price: Quantity::one(), + } + )); + assert_eq!(Tokens::total_issuance(investment_currency_id), amount); + + // Mock collection message msg + let msg = LiquidityPoolMessage::CollectInvest { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + }; + + // Execute byte message + assert_ok!(LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg)); + + // Remove events before collect execution + let events_since_collect: Vec<_> = System::events() + .into_iter() + .filter(|e| !events_before_collect.contains(e)) + .collect(); + + // Verify investment was transferred to the domain locator + assert_eq!( + Tokens::balance(default_investment_id().into(), &sending_domain_locator), + amount + ); + + // Order should have been cleared by fulfilling investment + assert_eq!( + pallet_investments::Pallet::::acc_active_invest_order( + default_investment_id(), + ) + .amount, + 0 + ); + assert!(!events_since_collect.iter().any(|e| { + e.event + == pallet_investments::Event::::InvestCollectedForNonClearedOrderId { + investment_id: default_investment_id(), + who: investor.clone(), + } + .into() + })); + + // Order should not have been updated since everything is collected + assert!(!events_since_collect.iter().any(|e| { + e.event + == pallet_investments::Event::::InvestOrderUpdated { + investment_id: default_investment_id(), + submitted_at: 0, + who: investor.clone(), + amount: 0, + } + .into() + })); + + // Order should have been fully collected + assert!(events_since_collect.iter().any(|e| { + e.event + == pallet_investments::Event::::InvestOrdersCollected { + investment_id: default_investment_id(), + processed_orders: vec![0], + who: investor.clone(), + collection: InvestCollection:: { + payout_investment_invest: amount, + remaining_investment_invest: 0, + }, + outcome: CollectOutcome::FullyCollected, + } + .into() + })); + + // Foreign CollectedInvestment should be killed + assert!(!pallet_foreign_investments::CollectedInvestment::< + DevelopmentRuntime, + >::contains_key(investor.clone(), default_investment_id())); + + // Foreign InvestmentState should be killed + assert!(!pallet_foreign_investments::InvestmentState::< + DevelopmentRuntime, + >::contains_key(investor.clone(), default_investment_id())); + + // Clearing of foreign InvestState should be dispatched + assert!(events_since_collect.iter().any(|e| { + e.event + == pallet_foreign_investments::Event::::ForeignInvestmentCleared { + investor: investor.clone(), + investment_id: default_investment_id(), + } + .into() + })); + }); + } + + #[test] + fn partially_collect_investment_for_through_investments() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let invest_amount = 100_000_000; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let currency_id = AUSD_CURRENCY_ID; + let currency_decimals = currency_decimals::AUSD; + let sending_domain_locator = Domain::convert(DEFAULT_DOMAIN_ADDRESS_MOONBEAM.domain()); + create_currency_pool(pool_id, currency_id, currency_decimals.into()); + do_initial_increase_investment(pool_id, invest_amount, investor.clone(), currency_id); + enable_liquidity_pool_transferability(currency_id); + let investment_currency_id: CurrencyId = default_investment_id().into(); + + assert!(!Investments::investment_requires_collect( + &investor, + default_investment_id() + )); + + // Process 50% of investment at 25% rate, i.e. 1 pool currency = 4 tranche + // tokens + assert_ok!(Investments::process_invest_orders(default_investment_id())); + assert_ok!(Investments::invest_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::from_percent(50), + price: Quantity::checked_from_rational(1, 4).unwrap(), + } + )); + + // Pre collect assertions + assert!(Investments::investment_requires_collect( + &investor, + default_investment_id() + )); + assert!(!CollectedInvestment::::contains_key( + &investor, + default_investment_id() + )); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::InvestmentOngoing { invest_amount } + ); + + // Collecting through Investments should denote amounts and transition + // state + assert_ok!(Investments::collect_investments_for( + RuntimeOrigin::signed(ALICE.into()), + investor.clone(), + default_investment_id() + )); + + assert!(!Investments::investment_requires_collect( + &investor, + default_investment_id() + )); + // The collected amount is only transferred to the user if they send a + // `CollectInvest` message + assert_eq!( + CollectedInvestment::::get(&investor, default_investment_id()), + CollectedAmount { + amount_collected: invest_amount / 2 * 4, + amount_payment: invest_amount / 2, + } + ); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::InvestmentOngoing { + invest_amount: invest_amount / 2 + } + ); + // Tranche Tokens should still be investor's wallet (i.e. not be collected to + // domain) + assert_eq!( + Tokens::balance(investment_currency_id, &investor), + invest_amount * 2 + ); + assert_eq!( + Tokens::balance(investment_currency_id, &sending_domain_locator), + 0 + ); + assert!(System::events().iter().any(|e| { + e.event + == pallet_investments::Event::::InvestOrdersCollected { + investment_id: default_investment_id(), + processed_orders: vec![0], + who: investor.clone(), + collection: InvestCollection:: { + payout_investment_invest: invest_amount * 2, + remaining_investment_invest: invest_amount / 2, + }, + outcome: CollectOutcome::FullyCollected, + } + .into() + })); + + // Process rest of investment at 50% rate (1 pool currency = 2 tranche tokens) + assert_ok!(Investments::process_invest_orders(default_investment_id())); + assert_ok!(Investments::invest_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::one(), + price: Quantity::checked_from_rational(1, 2).unwrap(), + } + )); + // Order should have been cleared by fulfilling investment + assert_eq!( + pallet_investments::Pallet::::acc_active_invest_order( + default_investment_id(), + ) + .amount, + 0 + ); + assert_eq!( + Tokens::total_issuance(investment_currency_id), + invest_amount * 3 + ); + + // Collect remainder through Investments + assert_ok!(Investments::collect_investments_for( + RuntimeOrigin::signed(ALICE.into()), + investor.clone(), + default_investment_id() + )); + assert!(!Investments::investment_requires_collect( + &investor, + default_investment_id() + )); + assert_eq!( + CollectedInvestment::::get(&investor, default_investment_id()), + CollectedAmount { + amount_collected: invest_amount * 3, + amount_payment: invest_amount, + } + ); + assert!(!InvestmentState::::contains_key( + &investor, + default_investment_id() + )); + // Tranche Tokens should still be investor's wallet (i.e. not be collected to + // domain) + assert_eq!( + Tokens::balance(investment_currency_id, &investor), + invest_amount * 3 + ); + assert_eq!( + Tokens::balance(investment_currency_id, &sending_domain_locator), + 0 + ); + assert!(!System::events().iter().any(|e| { + e.event + == pallet_investments::Event::::InvestCollectedForNonClearedOrderId { + investment_id: default_investment_id(), + who: investor.clone(), + } + .into() + })); + assert!(System::events().iter().any(|e| { + e.event + == pallet_investments::Event::::InvestOrdersCollected { + investment_id: default_investment_id(), + processed_orders: vec![1], + who: investor.clone(), + collection: InvestCollection:: { + payout_investment_invest: invest_amount, + remaining_investment_invest: 0, + }, + outcome: CollectOutcome::FullyCollected, + } + .into() + })); + // Clearing of foreign InvestState should have been dispatched exactly once + assert_eq!( + System::events() + .iter() + .filter(|e| { + e.event + == pallet_foreign_investments::Event::::ForeignInvestmentCleared { + investor: investor.clone(), + investment_id: default_investment_id(), + } + .into() + }) + .count(), + 1 + ); + + // User collects through foreign investments + let msg = LiquidityPoolMessage::CollectInvest { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + }; + assert_ok!(LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg)); + assert!(!CollectedInvestment::::contains_key( + &investor, + default_investment_id() + )); + assert_eq!( + Tokens::total_issuance(investment_currency_id), + invest_amount * 3 + ); + assert!(Tokens::balance(investment_currency_id, &investor).is_zero()); + assert_eq!( + Tokens::balance(investment_currency_id, &sending_domain_locator), + invest_amount * 3 + ); + }); + } + + #[test] + fn increase_redeem_order() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let amount = 100_000_000; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let currency_id = AUSD_CURRENCY_ID; + let currency_decimals = currency_decimals::AUSD; + + // Create new pool + create_currency_pool(pool_id, currency_id, currency_decimals.into()); + + // Set permissions and execute initial redemption + do_initial_increase_redemption(pool_id, amount, investor.clone(), currency_id); + + // Verify amount was noted in the corresponding order + assert_eq!( + pallet_investments::Pallet::::acc_active_redeem_order( + default_investment_id(), + ) + .amount, + amount + ); + + // Increasing again should just bump redeeming amount + assert_ok!(Tokens::mint_into( + default_investment_id().into(), + &Domain::convert(DEFAULT_DOMAIN_ADDRESS_MOONBEAM.domain()), + amount + )); + let msg = LiquidityPoolMessage::IncreaseRedeemOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + amount, + }; + assert_ok!(LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg)); + assert_eq!( + RedemptionState::::get(&investor, default_investment_id()), + RedeemState::Redeeming { + redeem_amount: amount * 2, + } + ); + }); + } + + #[test] + fn decrease_redeem_order() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let redeem_amount = 100_000_000; + let decrease_amount = redeem_amount / 3; + let final_amount = redeem_amount - decrease_amount; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let currency_id = AUSD_CURRENCY_ID; + let currency_decimals = currency_decimals::AUSD; + let sending_domain_locator = Domain::convert(DEFAULT_DOMAIN_ADDRESS_MOONBEAM.domain()); + + // Create new pool + create_currency_pool(pool_id, currency_id, currency_decimals.into()); + + // Set permissions and execute initial redemption + do_initial_increase_redemption(pool_id, redeem_amount, investor.clone(), currency_id); + + // Verify the corresponding redemption order id is 0 + assert_eq!( + pallet_investments::Pallet::::invest_order_id(investment_id( + pool_id, + default_tranche_id(pool_id) + )), + 0 + ); + + // Mock incoming decrease message + let msg = LiquidityPoolMessage::DecreaseRedeemOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + amount: decrease_amount, + }; + + // Execute byte message + assert_ok!(LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg)); + + // Verify investment was decreased into investment account + assert_eq!( + Tokens::balance( + default_investment_id().into(), + &default_investment_account(), + ), + final_amount + ); + // Tokens should have been transferred from investor's wallet to domain's + // sovereign account + assert_eq!( + Tokens::balance(default_investment_id().into(), &investor), + 0 + ); + assert_eq!( + Tokens::balance(default_investment_id().into(), &sending_domain_locator), + decrease_amount + ); + + // Foreign RedemptionState should be updated + assert!(System::events().iter().any(|e| { + e.event + == pallet_foreign_investments::Event::::ForeignRedemptionUpdated { + investor: investor.clone(), + investment_id: default_investment_id(), + state: RedeemState::Redeeming { + redeem_amount: final_amount + } + } + .into() + })); + + // Order should have been updated + assert!(System::events().iter().any(|e| e.event + == pallet_investments::Event::::RedeemOrderUpdated { + investment_id: default_investment_id(), + submitted_at: 0, + who: investor.clone(), + amount: final_amount + } + .into())); + assert_eq!( + pallet_investments::Pallet::::acc_active_redeem_order( + default_investment_id(), + ) + .amount, + final_amount + ); + }); + } + + #[test] + fn cancel_redeem_order() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let redeem_amount = 100_000_000; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let currency_id = AUSD_CURRENCY_ID; + let currency_decimals = currency_decimals::AUSD; + let sending_domain_locator = Domain::convert(DEFAULT_DOMAIN_ADDRESS_MOONBEAM.domain()); + + // Create new pool + create_currency_pool(pool_id, currency_id, currency_decimals.into()); + + // Set permissions and execute initial redemption + do_initial_increase_redemption(pool_id, redeem_amount, investor.clone(), currency_id); + + // Verify the corresponding redemption order id is 0 + assert_eq!( + pallet_investments::Pallet::::invest_order_id(investment_id( + pool_id, + default_tranche_id(pool_id) + )), + 0 + ); + + // Mock incoming decrease message + let msg = LiquidityPoolMessage::CancelRedeemOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + }; + + // Execute byte message + assert_ok!(LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg)); + + // Verify investment was decreased into investment account + assert_eq!( + Tokens::balance( + default_investment_id().into(), + &default_investment_account(), + ), + 0 + ); + // Tokens should have been transferred from investor's wallet to domain's + // sovereign account + assert_eq!( + Tokens::balance(default_investment_id().into(), &investor), + 0 + ); + assert_eq!( + Tokens::balance(default_investment_id().into(), &sending_domain_locator), + redeem_amount + ); + + // Foreign RedemptionState should be updated + assert!(System::events().iter().any(|e| { + e.event + == pallet_foreign_investments::Event::::ForeignRedemptionCleared { + investor: investor.clone(), + investment_id: default_investment_id(), + } + .into() + })); + + // Order should have been updated + assert!(System::events().iter().any(|e| e.event + == pallet_investments::Event::::RedeemOrderUpdated { + investment_id: default_investment_id(), + submitted_at: 0, + who: investor.clone(), + amount: 0 + } + .into())); + assert_eq!( + pallet_investments::Pallet::::acc_active_redeem_order( + default_investment_id(), + ) + .amount, + 0 + ); + }); + } + + #[test] + fn fully_collect_redeem_order() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let amount = 100_000_000; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let currency_id = AUSD_CURRENCY_ID; + let currency_decimals = currency_decimals::AUSD; + let pool_account = + pallet_pool_system::pool_types::PoolLocator { pool_id }.into_account_truncating(); + + // Create new pool + create_currency_pool(pool_id, currency_id, currency_decimals.into()); + + // Set permissions and execute initial investment + do_initial_increase_redemption(pool_id, amount, investor.clone(), currency_id); + let events_before_collect = System::events(); + + // Fund the pool account with sufficient pool currency, else redemption cannot + // swap tranche tokens against pool currency + assert_ok!(Tokens::mint_into(currency_id, &pool_account, amount)); + + // Process and fulfill order + // NOTE: Without this step, the order id is not cleared and + // `Event::RedeemCollectedForNonClearedOrderId` be dispatched + assert_ok!(Investments::process_redeem_orders(default_investment_id())); + assert_ok!(Investments::redeem_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::one(), + price: Quantity::one(), + } + )); + + // Enable liquidity pool transferability + enable_liquidity_pool_transferability(currency_id); + + // Mock collection message msg + let msg = LiquidityPoolMessage::CollectRedeem { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + }; + + // Execute byte message + assert_ok!(LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg)); + + // Remove events before collect execution + let events_since_collect: Vec<_> = System::events() + .into_iter() + .filter(|e| !events_before_collect.contains(e)) + .collect(); + + // Verify collected redemption was burned from investor + assert_eq!(Tokens::balance(currency_id, &investor), 0); + assert!(System::events().iter().any(|e| e.event + == orml_tokens::Event::::Withdrawn { + currency_id, + who: investor.clone(), + amount + } + .into())); + + // Order should have been cleared by fulfilling redemption + assert_eq!( + pallet_investments::Pallet::::acc_active_redeem_order( + default_investment_id(), + ) + .amount, + 0 + ); + assert!(!events_since_collect.iter().any(|e| { + e.event + == pallet_investments::Event::::RedeemCollectedForNonClearedOrderId { + investment_id: default_investment_id(), + who: investor.clone(), + } + .into() + })); + + // Order should not have been updated since everything is collected + assert!(!events_since_collect.iter().any(|e| { + e.event + == pallet_investments::Event::::RedeemOrderUpdated { + investment_id: default_investment_id(), + submitted_at: 0, + who: investor.clone(), + amount: 0, + } + .into() + })); + + // Order should have been fully collected + assert!(events_since_collect.iter().any(|e| { + e.event + == pallet_investments::Event::::RedeemOrdersCollected { + investment_id: default_investment_id(), + processed_orders: vec![0], + who: investor.clone(), + collection: RedeemCollection:: { + payout_investment_redeem: amount, + remaining_investment_redeem: 0, + }, + outcome: CollectOutcome::FullyCollected, + } + .into() + })); + + // Foreign CollectedRedemptionTrancheTokens should be killed + assert!(!pallet_foreign_investments::CollectedRedemption::< + DevelopmentRuntime, + >::contains_key(investor.clone(), default_investment_id(),)); + + // Foreign RedemptionState should be killed + assert!(!pallet_foreign_investments::RedemptionState::< + DevelopmentRuntime, + >::contains_key(investor.clone(), default_investment_id())); + + // Clearing of foreign RedeemState should be dispatched + assert!(events_since_collect.iter().any(|e| { + e.event + == pallet_foreign_investments::Event::::ForeignRedemptionCleared { + investor: investor.clone(), + investment_id: default_investment_id(), + } + .into() + })); + }); + } + + #[test] + fn partially_collect_redemption_for_through_investments() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let redeem_amount = 100_000_000; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let currency_id = AUSD_CURRENCY_ID; + let currency_decimals = currency_decimals::AUSD; + let pool_account = + pallet_pool_system::pool_types::PoolLocator { pool_id }.into_account_truncating(); + create_currency_pool(pool_id, currency_id, currency_decimals.into()); + do_initial_increase_redemption(pool_id, redeem_amount, investor.clone(), currency_id); + enable_liquidity_pool_transferability(currency_id); + + // Fund the pool account with sufficient pool currency, else redemption cannot + // swap tranche tokens against pool currency + assert_ok!(Tokens::mint_into(currency_id, &pool_account, redeem_amount)); + assert!(!Investments::redemption_requires_collect( + &investor, + default_investment_id() + )); + + // Process 50% of redemption at 25% rate, i.e. 1 pool currency = 4 tranche + // tokens + assert_ok!(Investments::process_redeem_orders(default_investment_id())); + assert_ok!(Investments::redeem_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::from_percent(50), + price: Quantity::checked_from_rational(1, 4).unwrap(), + } + )); + + // Pre collect assertions + assert!(Investments::redemption_requires_collect( + &investor, + default_investment_id() + )); + assert!(!CollectedRedemption::::contains_key( + &investor, + default_investment_id() + )); + assert_eq!( + RedemptionState::::get(&investor, default_investment_id()), + RedeemState::Redeeming { redeem_amount } + ); + // Collecting through investments should denote amounts and transition + // state + assert_ok!(Investments::collect_redemptions_for( + RuntimeOrigin::signed(ALICE.into()), + investor.clone(), + default_investment_id() + )); + assert!(System::events().iter().any(|e| { + e.event + == pallet_investments::Event::::RedeemOrdersCollected { + investment_id: default_investment_id(), + processed_orders: vec![0], + who: investor.clone(), + collection: RedeemCollection:: { + payout_investment_redeem: redeem_amount / 8, + remaining_investment_redeem: redeem_amount / 2, + }, + outcome: CollectOutcome::FullyCollected, + } + .into() + })); + assert!(!Investments::redemption_requires_collect( + &investor, + default_investment_id() + )); + // Since foreign currency is pool currency, the swap is immediately fulfilled + // and ExecutedCollectRedeem dispatched + assert!(!CollectedRedemption::::contains_key( + &investor, + default_investment_id() + ),); + assert_eq!( + RedemptionState::::get(&investor, default_investment_id()), + RedeemState::Redeeming { + redeem_amount: redeem_amount / 2, + } + ); + assert!(System::events().iter().any(|e| e.event + == orml_tokens::Event::::Withdrawn { + currency_id, + who: investor.clone(), + amount: redeem_amount / 8 + } + .into())); + + // Process rest of redemption at 50% rate + assert_ok!(Investments::process_redeem_orders(default_investment_id())); + assert_ok!(Investments::redeem_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::one(), + price: Quantity::checked_from_rational(1, 2).unwrap(), + } + )); + // Order should have been cleared by fulfilling redemption + assert_eq!( + pallet_investments::Pallet::::acc_active_redeem_order( + default_investment_id(), + ) + .amount, + 0 + ); + + // Collect remainder through Investments + assert_ok!(Investments::collect_redemptions_for( + RuntimeOrigin::signed(ALICE.into()), + investor.clone(), + default_investment_id() + )); + assert!(!Investments::redemption_requires_collect( + &investor, + default_investment_id() + )); + assert!(!CollectedRedemption::::contains_key( + &investor, + default_investment_id() + )); + assert!(!RedemptionState::::contains_key( + &investor, + default_investment_id() + )); + assert!(!System::events().iter().any(|e| { + e.event + == pallet_investments::Event::::RedeemCollectedForNonClearedOrderId { + investment_id: default_investment_id(), + who: investor.clone(), + } + .into() + })); + assert!(System::events().iter().any(|e| { + e.event + == pallet_investments::Event::::RedeemOrdersCollected { + investment_id: default_investment_id(), + processed_orders: vec![1], + who: investor.clone(), + collection: RedeemCollection:: { + payout_investment_redeem: redeem_amount / 4, + remaining_investment_redeem: 0, + }, + outcome: CollectOutcome::FullyCollected, + } + .into() + })); + // Verify collected redemption was burned from investor + assert_eq!(Tokens::balance(currency_id, &investor), 0); + assert!(System::events().iter().any(|e| e.event + == orml_tokens::Event::::Withdrawn { + currency_id, + who: investor.clone(), + amount: redeem_amount / 4 + } + .into())); + // Clearing of foreign RedeemState should have been dispatched exactly once + assert_eq!( + System::events() + .iter() + .filter(|e| { + e.event + == pallet_foreign_investments::Event::::ForeignRedemptionCleared { + investor: investor.clone(), + investment_id: default_investment_id(), + } + .into() + }) + .count(), + 1 + ); + }); + } + + mod should_fail { + use pallet_foreign_investments::errors::{InvestError, RedeemError}; + + use super::*; + + mod decrease_should_underflow { + use super::*; + + #[test] + fn invest_decrease_underflow() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let invest_amount: u128 = 100_000_000; + let decrease_amount = invest_amount + 1; + let investor: AccountId = AccountConverter::< + DevelopmentRuntime, + LocationToAccountId, + >::convert((DOMAIN_MOONBEAM, BOB)); + let currency_id: CurrencyId = AUSD_CURRENCY_ID; + let currency_decimals = currency_decimals::AUSD; + create_currency_pool(pool_id, currency_id, currency_decimals.into()); + do_initial_increase_investment( + pool_id, + invest_amount, + investor.clone(), + currency_id, + ); + enable_liquidity_pool_transferability(currency_id); + + // Mock incoming decrease message + let msg = LiquidityPoolMessage::DecreaseInvestOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + amount: decrease_amount, + }; + + assert_noop!( + LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg), + pallet_foreign_investments::Error::::InvestError( + InvestError::DecreaseAmountOverflow + ) + ); + }); + } + + #[test] + fn redeem_decrease_underflow() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let redeem_amount: u128 = 100_000_000; + let decrease_amount = redeem_amount + 1; + let investor: AccountId = AccountConverter::< + DevelopmentRuntime, + LocationToAccountId, + >::convert((DOMAIN_MOONBEAM, BOB)); + let currency_id: CurrencyId = AUSD_CURRENCY_ID; + let currency_decimals = currency_decimals::AUSD; + create_currency_pool(pool_id, currency_id, currency_decimals.into()); + do_initial_increase_redemption( + pool_id, + redeem_amount, + investor.clone(), + currency_id, + ); + + // Mock incoming decrease message + let msg = LiquidityPoolMessage::DecreaseRedeemOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + amount: decrease_amount, + }; + + assert_noop!( + LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg), + pallet_foreign_investments::Error::::RedeemError( + RedeemError::DecreaseTransition + ) + ); + }); + } + } + + mod should_throw_requires_collect { + use super::*; + #[test] + fn invest_requires_collect() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let amount: u128 = 100_000_000; + let investor: AccountId = AccountConverter::< + DevelopmentRuntime, + LocationToAccountId, + >::convert((DOMAIN_MOONBEAM, BOB)); + let currency_id: CurrencyId = AUSD_CURRENCY_ID; + let currency_decimals = currency_decimals::AUSD; + create_currency_pool(pool_id, currency_id, currency_decimals.into()); + do_initial_increase_investment(pool_id, amount, investor.clone(), currency_id); + enable_liquidity_pool_transferability(currency_id); + + // Prepare collection + let pool_account = pallet_pool_system::pool_types::PoolLocator { pool_id } + .into_account_truncating(); + assert_ok!(Tokens::mint_into(currency_id, &pool_account, amount)); + assert_ok!(Investments::process_invest_orders(default_investment_id())); + assert_ok!(Investments::invest_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::one(), + price: Quantity::one(), + } + )); + + // Should fail to increase + let increase_msg = LiquidityPoolMessage::IncreaseInvestOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + amount: 1, + }; + assert_noop!( + LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, increase_msg), + pallet_foreign_investments::Error::::InvestError( + InvestError::CollectRequired + ) + ); + + // Should fail to decrease + let decrease_msg = LiquidityPoolMessage::DecreaseInvestOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + amount: 1, + }; + assert_noop!( + LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, decrease_msg), + pallet_foreign_investments::Error::::InvestError( + InvestError::CollectRequired + ) + ); + }); + } + + #[test] + fn redeem_requires_collect() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let amount: u128 = 100_000_000; + let investor: AccountId = AccountConverter::< + DevelopmentRuntime, + LocationToAccountId, + >::convert((DOMAIN_MOONBEAM, BOB)); + let currency_id: CurrencyId = AUSD_CURRENCY_ID; + let currency_decimals = currency_decimals::AUSD; + create_currency_pool(pool_id, currency_id, currency_decimals.into()); + do_initial_increase_redemption(pool_id, amount, investor.clone(), currency_id); + enable_liquidity_pool_transferability(currency_id); + + // Mint more into DomainLocator required for subsequent invest attempt + assert_ok!(Tokens::mint_into( + default_investment_id().into(), + &Domain::convert(DEFAULT_DOMAIN_ADDRESS_MOONBEAM.domain()), + 1, + )); + + // Prepare collection + let pool_account = pallet_pool_system::pool_types::PoolLocator { pool_id } + .into_account_truncating(); + assert_ok!(Tokens::mint_into(currency_id, &pool_account, amount)); + assert_ok!(Investments::process_redeem_orders(default_investment_id())); + assert_ok!(Investments::redeem_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::one(), + price: Quantity::one(), + } + )); + + // Should fail to increase + let increase_msg = LiquidityPoolMessage::IncreaseRedeemOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + amount: 1, + }; + assert_noop!( + LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, increase_msg), + pallet_foreign_investments::Error::::RedeemError( + RedeemError::CollectRequired + ) + ); + + // Should fail to decrease + let decrease_msg = LiquidityPoolMessage::DecreaseRedeemOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + amount: 1, + }; + assert_noop!( + LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, decrease_msg), + pallet_foreign_investments::Error::::RedeemError( + RedeemError::CollectRequired + ) + ); + }); + } + } + } +} + +mod mismatching_currencies { + use cfg_traits::investments::ForeignInvestment; + use cfg_types::investments::{ForeignInvestmentInfo, Swap}; + use development_runtime::{DefaultTokenSellRate, OrderBook}; + use pallet_foreign_investments::{types::TokenSwapReason, InvestmentState}; + + use super::*; + use crate::{ + liquidity_pools::pallet::development::{setup::CHARLIE, tests::register_usdt}, + utils::{GLMR_CURRENCY_ID, USDT_CURRENCY_ID}, + }; + + /// Invest in pool currency, then increase in allowed foreign currency, then + /// decrease in same foreign currency multiple times. + #[test] + fn invest_increase_decrease() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let pool_currency: CurrencyId = AUSD_CURRENCY_ID; + let foreign_currency: CurrencyId = USDT_CURRENCY_ID; + let pool_currency_decimals = currency_decimals::AUSD; + let invest_amount_pool_denominated: u128 = 6_000_000_000_000_000; + create_currency_pool(pool_id, pool_currency, pool_currency_decimals.into()); + do_initial_increase_investment( + pool_id, + invest_amount_pool_denominated, + investor.clone(), + pool_currency, + ); + + // USDT investment preparations + register_usdt(); + let invest_amount_foreign_denominated: u128 = + IdentityPoolCurrencyConverter::::stable_to_stable( + foreign_currency, + pool_currency, + invest_amount_pool_denominated, + ) + .unwrap(); + + // Should fail to increase to an invalid payment currency + assert!(!ForeignInvestments::accepted_payment_currency( + default_investment_id(), + foreign_currency + )); + let increase_msg = LiquidityPoolMessage::IncreaseInvestOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(foreign_currency), + amount: invest_amount_foreign_denominated, + }; + assert_noop!( + LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, increase_msg.clone()), + pallet_liquidity_pools::Error::::InvalidPaymentCurrency + ); + + assert_ok!(OrderBook::add_trading_pair( + RuntimeOrigin::root(), + pool_currency, + foreign_currency, + 1 + )); + assert!(ForeignInvestments::accepted_payment_currency( + default_investment_id(), + foreign_currency + )); + assert!(!ForeignInvestments::accepted_payout_currency( + default_investment_id(), + foreign_currency + )); + assert_ok!(OrderBook::add_trading_pair( + RuntimeOrigin::root(), + foreign_currency, + pool_currency, + 1 + )); + assert!(ForeignInvestments::accepted_payout_currency( + default_investment_id(), + foreign_currency + )); + + // Should be able to invest since InvestmentState does not have an active swap, + // i.e. any tradable pair is allowed to invest at this point + assert_ok!(LiquidityPools::submit( + DEFAULT_DOMAIN_ADDRESS_MOONBEAM, + increase_msg + )); + assert!(System::events().iter().any(|e| { + e.event == pallet_foreign_investments::Event::::ForeignInvestmentUpdated { + investor: investor.clone(), + investment_id: default_investment_id(), + state: InvestState::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + swap: Swap { + amount: invest_amount_pool_denominated, + currency_in: pool_currency, + currency_out: foreign_currency, + }, + invest_amount: invest_amount_pool_denominated + }, + } + .into() + })); + + // Should be able to to decrease in the swapping foreign currency + enable_liquidity_pool_transferability(foreign_currency); + let decrease_msg_pool_swap_amount = LiquidityPoolMessage::DecreaseInvestOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(foreign_currency), + amount: invest_amount_foreign_denominated, + }; + assert_ok!(LiquidityPools::submit( + DEFAULT_DOMAIN_ADDRESS_MOONBEAM, + decrease_msg_pool_swap_amount + )); + // Entire swap amount into pool currency should be nullified + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::InvestmentOngoing { + invest_amount: invest_amount_pool_denominated + } + ); + assert!(System::events().iter().any(|e| { + e.event == + pallet_foreign_investments::Event::::ForeignInvestmentUpdated + { investor: investor.clone(), + investment_id: default_investment_id(), + state: InvestState::InvestmentOngoing { + invest_amount: invest_amount_pool_denominated + }, + } + .into() + })); + + // Decrease partial investing amount + enable_liquidity_pool_transferability(foreign_currency); + let decrease_msg_partial_invest_amount = LiquidityPoolMessage::DecreaseInvestOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(foreign_currency), + amount: invest_amount_foreign_denominated / 2, + }; + assert_ok!(LiquidityPools::submit( + DEFAULT_DOMAIN_ADDRESS_MOONBEAM, + decrease_msg_partial_invest_amount.clone() + )); + // Decreased amount should be taken from investing amount + let expected_state = InvestState::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { + swap: Swap { + amount: invest_amount_foreign_denominated / 2, + currency_in: foreign_currency, + currency_out: pool_currency, + }, + invest_amount: invest_amount_pool_denominated / 2, + }; + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + expected_state.clone() + ); + assert!(System::events().iter().any(|e| { + e.event == + pallet_foreign_investments::Event::::ForeignInvestmentUpdated + { investor: investor.clone(), + investment_id: default_investment_id(), + state: expected_state.clone() + } + .into() + })); + + /// Consume entire investing amount by sending same message + assert_ok!(LiquidityPools::submit( + DEFAULT_DOMAIN_ADDRESS_MOONBEAM, + decrease_msg_partial_invest_amount.clone() + )); + let expected_state = InvestState::ActiveSwapIntoForeignCurrency { + swap: Swap { + amount: invest_amount_foreign_denominated, + currency_in: foreign_currency, + currency_out: pool_currency, + }, + }; + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + expected_state.clone() + ); + assert!(System::events().iter().any(|e| { + e.event == + pallet_foreign_investments::Event::::ForeignInvestmentUpdated + { investor: investor.clone(), + investment_id: default_investment_id(), + state: expected_state.clone() + } + .into() + })); + }); + } + + #[test] + /// Propagate swaps only via OrderBook fulfillments. + /// + /// Flow: Increase, fulfill, decrease, fulfill + fn invest_swaps_happy_path() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let trader: AccountId = ALICE.into(); + let pool_currency: CurrencyId = AUSD_CURRENCY_ID; + let foreign_currency: CurrencyId = USDT_CURRENCY_ID; + let pool_currency_decimals = currency_decimals::AUSD; + let invest_amount_pool_denominated: u128 = 10_000_000_000_000_000; + create_currency_pool(pool_id, pool_currency, pool_currency_decimals.into()); + + // USDT investment preparations + register_usdt(); + // Overwrite multilocation to enable LP transferability + enable_liquidity_pool_transferability(foreign_currency); + assert_ok!(Tokens::mint_into( + pool_currency, + &trader, + invest_amount_pool_denominated + )); + let invest_amount_foreign_denominated: u128 = + IdentityPoolCurrencyConverter::::stable_to_stable( + foreign_currency, + pool_currency, + invest_amount_pool_denominated, + ) + .unwrap(); + assert_ok!(OrderBook::add_trading_pair( + RuntimeOrigin::root(), + pool_currency, + foreign_currency, + 1 + )); + assert_ok!(OrderBook::add_trading_pair( + RuntimeOrigin::root(), + foreign_currency, + pool_currency, + 1 + )); + + // Increase such that active swap into USDT is initialized + do_initial_increase_investment( + pool_id, + invest_amount_foreign_denominated, + investor.clone(), + foreign_currency, + ); + let swap_order_id = + ForeignInvestments::token_swap_order_ids(&investor, default_investment_id()) + .expect("Swap order id created during increase"); + assert_eq!( + ForeignInvestments::foreign_investment_info(swap_order_id), + Some(ForeignInvestmentInfo { + owner: investor.clone(), + id: default_investment_id(), + last_swap_reason: Some(TokenSwapReason::Investment) + }) + ); + + // Fulfilling order should propagate it from `ActiveSwapIntoForeignCurrency` to + // `InvestmentOngoing`. + assert_ok!(OrderBook::fill_order_full( + RuntimeOrigin::signed(trader.clone()), + swap_order_id + )); + assert!(System::events().iter().any(|e| { + e.event + == pallet_order_book::Event::::OrderFulfillment { + order_id: swap_order_id, + placing_account: investor.clone(), + fulfilling_account: trader.clone(), + partial_fulfillment: false, + fulfillment_amount: invest_amount_pool_denominated, + currency_in: pool_currency, + currency_out: foreign_currency, + sell_rate_limit: Ratio::one(), + } + .into() + })); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::InvestmentOngoing { + invest_amount: invest_amount_pool_denominated + } + ); + assert!( + ForeignInvestments::token_swap_order_ids(&investor, default_investment_id()) + .is_none() + ); + assert!(ForeignInvestments::foreign_investment_info(swap_order_id).is_none()); + + // Decrease by half the investment amount + let msg = LiquidityPoolMessage::DecreaseInvestOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(foreign_currency), + amount: invest_amount_foreign_denominated / 2, + }; + assert_ok!(LiquidityPools::submit( + DEFAULT_DOMAIN_ADDRESS_MOONBEAM, + msg.clone() + )); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::ActiveSwapIntoForeignCurrencyAndInvestmentOngoing { + swap: Swap { + amount: invest_amount_foreign_denominated / 2, + currency_in: foreign_currency, + currency_out: pool_currency, + }, + invest_amount: invest_amount_pool_denominated / 2, + } + ); + let swap_order_id = + ForeignInvestments::token_swap_order_ids(&investor, default_investment_id()) + .expect("Swap order id created during decrease"); + assert_eq!( + ForeignInvestments::foreign_investment_info(swap_order_id), + Some(ForeignInvestmentInfo { + owner: investor.clone(), + id: default_investment_id(), + last_swap_reason: Some(TokenSwapReason::Investment) + }) + ); + + // Fulfill the decrease swap order + assert_ok!(OrderBook::fill_order_full( + RuntimeOrigin::signed(trader.clone()), + swap_order_id + )); + assert!(System::events().iter().any(|e| { + e.event + == pallet_order_book::Event::::OrderFulfillment { + order_id: swap_order_id, + placing_account: investor.clone(), + fulfilling_account: trader.clone(), + partial_fulfillment: false, + fulfillment_amount: invest_amount_foreign_denominated / 2, + currency_in: foreign_currency, + currency_out: pool_currency, + sell_rate_limit: Ratio::one(), + } + .into() + })); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::InvestmentOngoing { + invest_amount: invest_amount_pool_denominated / 2 + } + ); + assert!( + ForeignInvestments::token_swap_order_ids(&investor, default_investment_id()) + .is_none() + ); + assert!(ForeignInvestments::foreign_investment_info(swap_order_id).is_none()); + + // TODO: Check for event that ExecutedDecreaseInvestOrder was + // dispatched + }); + } + + #[test] + /// Verify handling concurrent swap orders works if + /// * Invest is swapping from pool to foreign after decreasing an + /// unprocessed investment + /// * Redeem is swapping from pool to foreign after collecting + fn concurrent_swap_orders_same_direction() { + TestNet::reset(); + Development::execute_with(|| { + // Increase invest setup + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let trader: AccountId = ALICE.into(); + let pool_currency: CurrencyId = AUSD_CURRENCY_ID; + let foreign_currency: CurrencyId = USDT_CURRENCY_ID; + let pool_currency_decimals = currency_decimals::AUSD; + let invest_amount_pool_denominated: u128 = 10_000_000_000_000_000; + let swap_order_id = 1; + create_currency_pool(pool_id, pool_currency, pool_currency_decimals.into()); + // invest in pool currency to reach `InvestmentOngoing` quickly + do_initial_increase_investment( + pool_id, + invest_amount_pool_denominated, + investor.clone(), + pool_currency, + ); + + // USDT setup + register_usdt(); + enable_liquidity_pool_transferability(foreign_currency); + let invest_amount_foreign_denominated: u128 = + IdentityPoolCurrencyConverter::::stable_to_stable( + foreign_currency, + pool_currency, + invest_amount_pool_denominated, + ) + .unwrap(); + assert_ok!(Tokens::mint_into( + foreign_currency, + &trader, + invest_amount_foreign_denominated * 2 + )); + assert_ok!(OrderBook::add_trading_pair( + RuntimeOrigin::root(), + pool_currency, + foreign_currency, + 1 + )); + assert_ok!(OrderBook::add_trading_pair( + RuntimeOrigin::root(), + foreign_currency, + pool_currency, + 1 + )); + + // Decrease invest setup to have invest order swapping into foreign currency + let msg = LiquidityPoolMessage::DecreaseInvestOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(foreign_currency), + amount: invest_amount_foreign_denominated, + }; + assert_ok!(LiquidityPools::submit( + DEFAULT_DOMAIN_ADDRESS_MOONBEAM, + msg.clone() + )); + + // Redeem setup: Increase and process + assert_ok!(Tokens::mint_into( + default_investment_id().into(), + &Domain::convert(DEFAULT_DOMAIN_ADDRESS_MOONBEAM.domain()), + invest_amount_pool_denominated + )); + let msg = LiquidityPoolMessage::IncreaseRedeemOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(foreign_currency), + amount: invest_amount_pool_denominated, + }; + assert_ok!(LiquidityPools::submit( + DEFAULT_DOMAIN_ADDRESS_MOONBEAM, + msg.clone() + )); + let pool_account = + pallet_pool_system::pool_types::PoolLocator { pool_id }.into_account_truncating(); + assert_ok!(Tokens::mint_into( + pool_currency, + &pool_account, + invest_amount_pool_denominated + )); + assert_ok!(Investments::process_redeem_orders(default_investment_id())); + // Process 50% of redemption at 25% rate, i.e. 1 pool currency = 4 tranche + // tokens + assert_ok!(Investments::redeem_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::from_percent(50), + price: Quantity::checked_from_rational(1, 4).unwrap(), + } + )); + assert_eq!( + ForeignInvestments::foreign_investment_info(swap_order_id) + .unwrap() + .last_swap_reason + .unwrap(), + TokenSwapReason::Investment + ); + assert_ok!(Investments::collect_redemptions_for( + RuntimeOrigin::signed(CHARLIE.into()), + investor.clone(), + default_investment_id() + )); + assert_eq!( + ForeignInvestments::foreign_investment_info(swap_order_id) + .unwrap() + .last_swap_reason + .unwrap(), + TokenSwapReason::InvestmentAndRedemption + ); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::ActiveSwapIntoForeignCurrency { + swap: Swap { + amount: invest_amount_foreign_denominated, + currency_in: foreign_currency, + currency_out: pool_currency + } + } + ); + assert_eq!( + RedemptionState::::get(&investor, default_investment_id()), + RedeemState::RedeemingAndActiveSwapIntoForeignCurrency { + redeem_amount: invest_amount_pool_denominated / 2, + swap: Swap { + amount: invest_amount_foreign_denominated / 8, + currency_in: foreign_currency, + currency_out: pool_currency + } + } + ); + let swap_amount = + invest_amount_foreign_denominated + invest_amount_foreign_denominated / 8; + assert!(System::events().iter().any(|e| { + e.event + == pallet_order_book::Event::::OrderUpdated { + order_id: swap_order_id, + account: investor.clone(), + buy_amount: swap_amount, + sell_rate_limit: Ratio::one(), + min_fulfillment_amount: swap_amount, + } + .into() + })); + + // Process remaining redemption at 25% rate, i.e. 1 pool currency = + // 4 tranche tokens + assert_ok!(Investments::process_redeem_orders(default_investment_id())); + assert_ok!(Investments::redeem_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::from_percent(100), + price: Quantity::checked_from_rational(1, 4).unwrap(), + } + )); + assert_ok!(Investments::collect_redemptions_for( + RuntimeOrigin::signed(CHARLIE.into()), + investor.clone(), + default_investment_id() + )); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::ActiveSwapIntoForeignCurrency { + swap: Swap { + amount: invest_amount_foreign_denominated, + currency_in: foreign_currency, + currency_out: pool_currency + } + } + ); + assert_eq!( + RedemptionState::::get(&investor, default_investment_id()), + RedeemState::ActiveSwapIntoForeignCurrency { + swap: Swap { + amount: invest_amount_foreign_denominated / 4, + currency_in: foreign_currency, + currency_out: pool_currency + } + } + ); + let swap_amount = + invest_amount_foreign_denominated + invest_amount_foreign_denominated / 4; + assert!(System::events().iter().any(|e| { + e.event + == pallet_order_book::Event::::OrderUpdated { + order_id: swap_order_id, + account: investor.clone(), + buy_amount: swap_amount, + sell_rate_limit: Ratio::one(), + min_fulfillment_amount: swap_amount, + } + .into() + })); + + // Fulfilling order should kill both the invest as well as redeem state + assert_ok!(OrderBook::fill_order_full( + RuntimeOrigin::signed(trader.clone()), + swap_order_id + )); + assert!(System::events().iter().any(|e| { + e.event + == pallet_order_book::Event::::OrderFulfillment { + order_id: swap_order_id, + placing_account: investor.clone(), + fulfilling_account: trader.clone(), + partial_fulfillment: false, + fulfillment_amount: invest_amount_foreign_denominated / 4 * 5, + currency_in: foreign_currency, + currency_out: pool_currency, + sell_rate_limit: Ratio::one(), + } + .into() + })); + assert!(!InvestmentState::::contains_key( + &investor, + default_investment_id() + )); + assert!(!RedemptionState::::contains_key( + &investor, + default_investment_id() + )); + assert!(ForeignInvestments::foreign_investment_info(swap_order_id).is_none()); + assert!( + ForeignInvestments::token_swap_order_ids(&investor, default_investment_id()) + .is_none() + ); + }); + } + + #[test] + /// Verify handling concurrent swap orders works if + /// * Invest is swapping from foreign to pool after increasing + /// * Redeem is swapping from pool to foreign after collecting + fn concurrent_swap_orders_opposite_direction() { + TestNet::reset(); + Development::execute_with(|| { + // Increase invest setup + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let trader: AccountId = ALICE.into(); + let pool_currency: CurrencyId = AUSD_CURRENCY_ID; + let foreign_currency: CurrencyId = USDT_CURRENCY_ID; + let pool_currency_decimals = currency_decimals::AUSD; + let invest_amount_pool_denominated: u128 = 10_000_000_000_000_000; + let swap_order_id = 1; + create_currency_pool(pool_id, pool_currency, pool_currency_decimals.into()); + + // USDT setup + register_usdt(); + enable_liquidity_pool_transferability(foreign_currency); + let invest_amount_foreign_denominated: u128 = + IdentityPoolCurrencyConverter::::stable_to_stable( + foreign_currency, + pool_currency, + invest_amount_pool_denominated, + ) + .unwrap(); + assert_ok!(Tokens::mint_into( + foreign_currency, + &trader, + invest_amount_foreign_denominated * 2 + )); + assert_ok!(OrderBook::add_trading_pair( + RuntimeOrigin::root(), + pool_currency, + foreign_currency, + 1 + )); + assert_ok!(OrderBook::add_trading_pair( + RuntimeOrigin::root(), + foreign_currency, + pool_currency, + 1 + )); + + // Increase invest setup to have invest order swapping into pool currency + do_initial_increase_investment( + pool_id, + invest_amount_foreign_denominated, + investor.clone(), + foreign_currency, + ); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::ActiveSwapIntoPoolCurrency { + swap: Swap { + amount: invest_amount_pool_denominated, + currency_in: pool_currency, + currency_out: foreign_currency + } + }, + ); + assert_eq!( + ForeignInvestments::foreign_investment_info(swap_order_id) + .unwrap() + .last_swap_reason + .unwrap(), + TokenSwapReason::Investment + ); + assert_eq!( + ForeignInvestments::token_swap_order_ids(&investor, default_investment_id()), + Some(swap_order_id) + ); + + // Redeem setup: Increase and process + assert_ok!(Tokens::mint_into( + default_investment_id().into(), + &Domain::convert(DEFAULT_DOMAIN_ADDRESS_MOONBEAM.domain()), + 3 * invest_amount_pool_denominated + )); + let pool_account = + pallet_pool_system::pool_types::PoolLocator { pool_id }.into_account_truncating(); + assert_ok!(Tokens::mint_into( + pool_currency, + &pool_account, + 3 * invest_amount_pool_denominated + )); + let msg = LiquidityPoolMessage::IncreaseRedeemOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(foreign_currency), + amount: invest_amount_pool_denominated, + }; + assert_ok!(LiquidityPools::submit( + DEFAULT_DOMAIN_ADDRESS_MOONBEAM, + msg.clone() + )); + assert_eq!( + ForeignInvestments::foreign_investment_info(swap_order_id) + .unwrap() + .last_swap_reason + .unwrap(), + TokenSwapReason::Investment + ); + assert_eq!( + ForeignInvestments::token_swap_order_ids(&investor, default_investment_id()), + Some(swap_order_id) + ); + + // Process 50% of redemption at 25% rate, i.e. 1 pool currency = 4 tranche + // tokens + assert_ok!(Investments::process_redeem_orders(default_investment_id())); + assert_ok!(Investments::redeem_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::from_percent(50), + price: Quantity::checked_from_rational(1, 4).unwrap(), + } + )); + assert_ok!(Investments::collect_redemptions_for( + RuntimeOrigin::signed(CHARLIE.into()), + investor.clone(), + default_investment_id() + )); + assert_eq!( + ForeignInvestments::foreign_investment_info(swap_order_id) + .unwrap() + .last_swap_reason + .unwrap(), + TokenSwapReason::Investment + ); + assert_eq!( + ForeignInvestments::token_swap_order_ids(&investor, default_investment_id()), + Some(swap_order_id) + ); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + invest_amount: invest_amount_pool_denominated / 8, + swap: Swap { + amount: invest_amount_pool_denominated / 8 * 7, + currency_in: pool_currency, + currency_out: foreign_currency + } + }, + ); + assert_eq!( + RedemptionState::::get(&investor, default_investment_id()), + RedeemState::Redeeming { + redeem_amount: invest_amount_pool_denominated / 2, + } + ); + assert!(System::events().iter().any(|e| { + e.event + == pallet_order_book::Event::::OrderUpdated { + order_id: swap_order_id, + account: investor.clone(), + buy_amount: invest_amount_pool_denominated / 8 * 7, + sell_rate_limit: Ratio::one(), + min_fulfillment_amount: invest_amount_pool_denominated / 8 * 7, + } + .into() + })); + + // Process remaining redemption at 25% rate, i.e. 1 pool currency = + // 4 tranche tokens + assert_ok!(Investments::process_redeem_orders(default_investment_id())); + assert_ok!(Investments::redeem_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::from_percent(100), + price: Quantity::checked_from_rational(1, 4).unwrap(), + } + )); + assert_ok!(Investments::collect_redemptions_for( + RuntimeOrigin::signed(CHARLIE.into()), + investor.clone(), + default_investment_id() + )); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + invest_amount: invest_amount_pool_denominated / 4, + swap: Swap { + amount: invest_amount_pool_denominated / 4 * 3, + currency_in: pool_currency, + currency_out: foreign_currency + } + } + ); + assert!(!RedemptionState::::contains_key( + &investor, + default_investment_id() + )); + assert!(System::events().iter().any(|e| { + e.event + == pallet_order_book::Event::::OrderUpdated { + order_id: swap_order_id, + account: investor.clone(), + buy_amount: invest_amount_pool_denominated / 4 * 3, + sell_rate_limit: Ratio::one(), + min_fulfillment_amount: invest_amount_pool_denominated / 4 * 3, + } + .into() + })); + + // Redeem again with goal of redemption swap to foreign consuming investment + // swap to pool + let msg = LiquidityPoolMessage::IncreaseRedeemOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(foreign_currency), + amount: invest_amount_pool_denominated, + }; + assert_ok!(LiquidityPools::submit( + DEFAULT_DOMAIN_ADDRESS_MOONBEAM, + msg.clone() + )); + // Process remaining redemption at 200% rate, i.e. 1 tranche token = 2 pool + // currency + assert_ok!(Investments::process_redeem_orders(default_investment_id())); + assert_ok!(Investments::redeem_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::from_percent(100), + price: Quantity::checked_from_rational(2, 1).unwrap(), + } + )); + assert_ok!(Investments::collect_redemptions_for( + RuntimeOrigin::signed(CHARLIE.into()), + investor.clone(), + default_investment_id() + )); + assert!(ForeignInvestments::foreign_investment_info(swap_order_id).is_none()); + // Swap order id should be bumped since swap order update occurred for opposite + // direction (from foreign->pool to foreign->pool) + let swap_order_id = 2; + assert_eq!( + ForeignInvestments::token_swap_order_ids(&investor, default_investment_id()), + Some(swap_order_id) + ); + assert_eq!( + ForeignInvestments::foreign_investment_info(swap_order_id) + .unwrap() + .last_swap_reason + .unwrap(), + TokenSwapReason::Redemption + ); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::InvestmentOngoing { + invest_amount: invest_amount_pool_denominated + } + ); + let remaining_foreign_swap_amount = + 2 * invest_amount_foreign_denominated - invest_amount_foreign_denominated / 4 * 3; + assert_eq!( + RedemptionState::::get(&investor, default_investment_id()), + RedeemState::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + done_amount: invest_amount_foreign_denominated / 4 * 3, + swap: Swap { + amount: remaining_foreign_swap_amount, + currency_in: foreign_currency, + currency_out: pool_currency + } + } + ); + + // Fulfilling order should the invest + assert_ok!(OrderBook::fill_order_full( + RuntimeOrigin::signed(trader.clone()), + swap_order_id + )); + assert!(System::events().iter().any(|e| { + e.event + == pallet_order_book::Event::::OrderFulfillment { + order_id: swap_order_id, + placing_account: investor.clone(), + fulfilling_account: trader.clone(), + partial_fulfillment: false, + fulfillment_amount: remaining_foreign_swap_amount, + currency_in: foreign_currency, + currency_out: pool_currency, + sell_rate_limit: Ratio::one(), + } + .into() + })); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::InvestmentOngoing { + invest_amount: invest_amount_pool_denominated + } + ); + assert!(ForeignInvestments::foreign_investment_info(swap_order_id).is_none()); + assert!( + ForeignInvestments::token_swap_order_ids(&investor, default_investment_id()) + .is_none() + ); + }); + } + + /// 1. increase initial invest in pool currency + /// 2. increase invest in foreign + /// 3. process invest + /// 4. fulfill swap order + #[test] + fn fulfill_invest_swap_order_requires_collect() { + TestNet::reset(); + Development::execute_with(|| { + // Increase invest setup + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let trader: AccountId = ALICE.into(); + let pool_currency: CurrencyId = AUSD_CURRENCY_ID; + let foreign_currency: CurrencyId = USDT_CURRENCY_ID; + let pool_currency_decimals = currency_decimals::AUSD; + let invest_amount_pool_denominated: u128 = 10_000_000_000_000_000; + let swap_order_id = 1; + create_currency_pool(pool_id, pool_currency, pool_currency_decimals.into()); + // invest in pool currency to reach `InvestmentOngoing` quickly + do_initial_increase_investment( + pool_id, + invest_amount_pool_denominated, + investor.clone(), + pool_currency, + ); + + // USDT setup + register_usdt(); + enable_liquidity_pool_transferability(foreign_currency); + let invest_amount_foreign_denominated: u128 = + IdentityPoolCurrencyConverter::::stable_to_stable( + foreign_currency, + pool_currency, + invest_amount_pool_denominated, + ) + .unwrap(); + assert_ok!(Tokens::mint_into( + pool_currency, + &trader, + invest_amount_pool_denominated + )); + assert_ok!(OrderBook::add_trading_pair( + RuntimeOrigin::root(), + pool_currency, + foreign_currency, + 1 + )); + assert_ok!(OrderBook::add_trading_pair( + RuntimeOrigin::root(), + foreign_currency, + pool_currency, + 1 + )); + + // Increase invest have + // InvestState::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing + let msg = LiquidityPoolMessage::IncreaseInvestOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(foreign_currency), + amount: invest_amount_foreign_denominated, + }; + assert_ok!(LiquidityPools::submit( + DEFAULT_DOMAIN_ADDRESS_MOONBEAM, + msg.clone() + )); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::ActiveSwapIntoPoolCurrencyAndInvestmentOngoing { + swap: Swap { + amount: invest_amount_pool_denominated, + currency_in: pool_currency, + currency_out: foreign_currency, + }, + invest_amount: invest_amount_pool_denominated + } + ); + // Process 50% of investment at 25% rate, i.e. 1 pool currency = 4 tranche + // tokens + assert_ok!(Investments::process_invest_orders(default_investment_id())); + assert_ok!(Investments::invest_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::from_percent(50), + price: Quantity::checked_from_rational(1, 4).unwrap(), + } + )); + assert!(Investments::investment_requires_collect( + &investor, + default_investment_id() + )); + + // Fulfill swap order should implicitly collect, otherwise the unprocessed + // investment amount is unknown + assert_ok!(OrderBook::fill_order_full( + RuntimeOrigin::signed(trader.clone()), + swap_order_id + )); + assert!(!Investments::investment_requires_collect( + &investor, + default_investment_id() + )); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::InvestmentOngoing { + invest_amount: invest_amount_pool_denominated / 2 * 3 + } + ); + }); + } + + /// 1. increase initial redeem + /// 2. process partial redemption + /// 3. collect + /// 4. process redemption + /// 5. fulfill swap order should implicitly collect + #[test] + fn fulfill_redeem_swap_order_requires_collect() { + TestNet::reset(); + Development::execute_with(|| { + // Increase redeem setup + setup_pre_requirements(); + let pool_id = DEFAULT_POOL_ID; + let investor: AccountId = + AccountConverter::::convert(( + DOMAIN_MOONBEAM, + BOB, + )); + let trader: AccountId = ALICE.into(); + let pool_currency: CurrencyId = AUSD_CURRENCY_ID; + let foreign_currency: CurrencyId = USDT_CURRENCY_ID; + let pool_currency_decimals = currency_decimals::AUSD; + let redeem_amount_pool_denominated: u128 = 10_000_000_000_000_000; + let swap_order_id = 1; + create_currency_pool(pool_id, pool_currency, pool_currency_decimals.into()); + let pool_account = + pallet_pool_system::pool_types::PoolLocator { pool_id }.into_account_truncating(); + assert_ok!(Tokens::mint_into( + pool_currency, + &pool_account, + redeem_amount_pool_denominated + )); + + // USDT setup + register_usdt(); + enable_liquidity_pool_transferability(foreign_currency); + let redeem_amount_foreign_denominated: u128 = + IdentityPoolCurrencyConverter::::stable_to_stable( + foreign_currency, + pool_currency, + redeem_amount_pool_denominated, + ) + .unwrap(); + assert_ok!(Tokens::mint_into( + foreign_currency, + &trader, + redeem_amount_foreign_denominated + )); + assert_ok!(OrderBook::add_trading_pair( + RuntimeOrigin::root(), + pool_currency, + foreign_currency, + 1 + )); + assert_ok!(OrderBook::add_trading_pair( + RuntimeOrigin::root(), + foreign_currency, + pool_currency, + 1 + )); + + do_initial_increase_redemption( + pool_id, + redeem_amount_pool_denominated, + investor.clone(), + foreign_currency, + ); + + // Process 50% of redemption at 50% rate, i.e. 1 pool currency = 2 tranche + // tokens + assert_ok!(Investments::process_redeem_orders(default_investment_id())); + assert_ok!(Investments::redeem_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::from_percent(50), + price: Quantity::checked_from_rational(1, 2).unwrap(), + } + )); + assert_noop!( + OrderBook::fill_order_full(RuntimeOrigin::signed(trader.clone()), swap_order_id), + pallet_order_book::Error::::OrderNotFound + ); + assert!(Investments::redemption_requires_collect( + &investor, + default_investment_id() + )); + assert_ok!(Investments::collect_redemptions_for( + RuntimeOrigin::signed(CHARLIE.into()), + investor.clone(), + default_investment_id() + )); + assert_eq!( + RedemptionState::::get(&investor, default_investment_id()), + RedeemState::RedeemingAndActiveSwapIntoForeignCurrency { + redeem_amount: redeem_amount_pool_denominated / 2, + swap: Swap { + amount: redeem_amount_foreign_denominated / 4, + currency_in: foreign_currency, + currency_out: pool_currency + } + } + ); + + // Process remaining redemption at 25% rate, i.e. 1 pool currency = 4 tranche + // tokens + assert_ok!(Investments::process_redeem_orders(default_investment_id())); + assert_ok!(Investments::redeem_fulfillment( + default_investment_id(), + FulfillmentWithPrice { + of_amount: Perquintill::from_percent(100), + price: Quantity::checked_from_rational(1, 4).unwrap(), + } + )); + assert!(Investments::redemption_requires_collect( + &investor, + default_investment_id() + )); + assert_ok!(OrderBook::fill_order_full( + RuntimeOrigin::signed(trader.clone()), + swap_order_id + )); + assert!(!Investments::redemption_requires_collect( + &investor, + default_investment_id() + )); + // TODO: Assert ExecutedCollectRedeem was not dispatched + assert_eq!( + RedemptionState::::get(&investor, default_investment_id()), + RedeemState::ActiveSwapIntoForeignCurrencyAndSwapIntoForeignDone { + done_amount: redeem_amount_foreign_denominated / 4, + swap: Swap { + amount: redeem_amount_foreign_denominated / 8, + currency_in: foreign_currency, + currency_out: pool_currency + } + } + ); + }); + } +} + +mod setup { + use super::*; + use crate::liquidity_pools::pallet::development::tests::liquidity_pools::setup::DEFAULT_OTHER_DOMAIN_ADDRESS; + + /// Sets up required permissions for the investor and executes an + /// initial investment via LiquidityPools by executing + /// `IncreaseInvestOrder`. + /// + /// Assumes `setup_pre_requirements` and + /// `investments::create_currency_pool` to have been called + /// beforehand + pub fn do_initial_increase_investment( + pool_id: u64, + amount: Balance, + investor: AccountId, + currency_id: CurrencyId, + ) { + let valid_until = DEFAULT_VALIDITY; + let pool_currency: CurrencyId = + PoolSystem::currency_for(pool_id).expect("Pool existence checked already"); + + // Mock incoming increase invest message + let msg = LiquidityPoolMessage::IncreaseInvestOrder { + pool_id, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + amount, + }; + + // Should fail if investor does not have investor role yet + // However, failure is async for foreign currencies as part of updating the + // investment after the swap was fulfilled + if currency_id == pool_currency { + assert_noop!( + LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg.clone()), + DispatchError::Other("Account does not have the TrancheInvestor permission.") + ); + } + + // Make investor the MembersListAdmin of this Pool + assert_ok!(Permissions::add( + RuntimeOrigin::root(), + Role::PoolRole(PoolRole::PoolAdmin), + investor.clone(), + PermissionScope::Pool(pool_id), + Role::PoolRole(PoolRole::TrancheInvestor( + default_tranche_id(pool_id), + valid_until + )), + )); + + let amount_before = Tokens::balance(currency_id, &default_investment_account()); + let final_amount = amount_before + .ensure_add(amount) + .expect("Should not overflow when incrementing amount"); + + // Execute byte message + assert_ok!(LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg)); + + if currency_id == pool_currency { + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::InvestmentOngoing { + invest_amount: amount + } + ); + // Verify investment was transferred into investment account + assert_eq!( + Tokens::balance(currency_id, &default_investment_account()), + final_amount + ); + assert!(System::events().iter().any(|e| { + e.event == pallet_foreign_investments::Event::::ForeignInvestmentUpdated { + investor: investor.clone(), + investment_id: default_investment_id(), + state: InvestState::InvestmentOngoing { + invest_amount: final_amount + }, + } + .into() + })); + assert!(System::events().iter().any(|e| { + e.event + == pallet_investments::Event::::InvestOrderUpdated { + investment_id: default_investment_id(), + submitted_at: 0, + who: investor.clone(), + amount: final_amount, + } + .into() + })); + } else { + let amount_pool_denominated: u128 = + IdentityPoolCurrencyConverter::::stable_to_stable( + pool_currency, + currency_id, + amount, + ) + .unwrap(); + assert_eq!( + InvestmentState::::get(&investor, default_investment_id()), + InvestState::ActiveSwapIntoPoolCurrency { + swap: Swap { + currency_in: pool_currency, + currency_out: currency_id, + amount: amount_pool_denominated + } + } + ); + } + } + + /// Sets up required permissions for the investor and executes an + /// initial redemption via LiquidityPools by executing + /// `IncreaseRedeemOrder`. + /// + /// Assumes `setup_pre_requirements` and + /// `investments::create_currency_pool` to have been called + /// beforehand. + /// + /// NOTE: Mints exactly the redeeming amount of tranche tokens. + pub fn do_initial_increase_redemption( + pool_id: u64, + amount: Balance, + investor: AccountId, + currency_id: CurrencyId, + ) { + let valid_until = DEFAULT_VALIDITY; + + // Fund `DomainLocator` account of origination domain as redeemed tranche tokens + // are transferred from this account instead of minting + assert_ok!(Tokens::mint_into( + default_investment_id().into(), + &Domain::convert(DEFAULT_DOMAIN_ADDRESS_MOONBEAM.domain()), + amount + )); + + // Verify redemption has not been made yet + assert_eq!( + Tokens::balance( + default_investment_id().into(), + &default_investment_account(), + ), + 0 + ); + assert_eq!( + Tokens::balance(default_investment_id().into(), &investor), + 0 + ); + + // Mock incoming increase invest message + let msg = LiquidityPoolMessage::IncreaseRedeemOrder { + pool_id: 42, + tranche_id: default_tranche_id(pool_id), + investor: investor.clone().into(), + currency: general_currency_index(currency_id), + amount, + }; + + // Should fail if investor does not have investor role yet + assert_noop!( + LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg.clone()), + DispatchError::Other("Account does not have the TrancheInvestor permission.") + ); + + // Make investor the MembersListAdmin of this Pool + assert_ok!(Permissions::add( + RuntimeOrigin::root(), + Role::PoolRole(PoolRole::PoolAdmin), + investor.clone(), + PermissionScope::Pool(pool_id), + Role::PoolRole(PoolRole::TrancheInvestor( + default_tranche_id(pool_id), + valid_until + )), + )); + + assert_ok!(LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg)); + + assert_eq!( + RedemptionState::::get(&investor, default_investment_id()), + RedeemState::Redeeming { + redeem_amount: amount + } + ); + // Verify redemption was transferred into investment account + assert_eq!( + Tokens::balance( + default_investment_id().into(), + &default_investment_account(), + ), + amount + ); + assert_eq!( + Tokens::balance(default_investment_id().into(), &investor), + 0 + ); + assert_eq!( + Tokens::balance( + default_investment_id().into(), + &AccountConverter::::convert( + DEFAULT_OTHER_DOMAIN_ADDRESS + ) + ), + 0 + ); + assert_eq!( + System::events().iter().nth_back(4).unwrap().event, + pallet_foreign_investments::Event::::ForeignRedemptionUpdated { + investor: investor.clone(), + investment_id: default_investment_id(), + state: RedeemState::Redeeming { + redeem_amount: amount + } + } + .into() + ); + assert_eq!( + System::events().iter().last().unwrap().event, + pallet_investments::Event::::RedeemOrderUpdated { + investment_id: default_investment_id(), + submitted_at: 0, + who: investor, + amount + } + .into() + ); + + // Verify order id is 0 + assert_eq!( + pallet_investments::Pallet::::redeem_order_id(investment_id( + pool_id, + default_tranche_id(pool_id) + )), + 0 + ); + } +} diff --git a/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/mod.rs b/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/mod.rs new file mode 100644 index 0000000000..e8e509c216 --- /dev/null +++ b/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/mod.rs @@ -0,0 +1,58 @@ +// Copyright 2021 Centrifuge GmbH (centrifuge.io). +// This file is part of Centrifuge chain project. +// +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// Copyright 2021 Centrifuge GmbH (centrifuge.io). +// This file is part of Centrifuge chain project. +// +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +mod add_allow_upgrade; +mod foreign_investments; +mod setup; +mod transfers; + +#[test] +fn test_vec_to_fixed_array() { + let src = "TrNcH".as_bytes().to_vec(); + let symbol: [u8; 32] = cfg_utils::vec_to_fixed_array(src); + + assert!(symbol.starts_with("TrNcH".as_bytes())); + + assert_eq!( + symbol, + [ + 84, 114, 78, 99, 72, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0 + ] + ); +} + +// Verify that the max tranche token symbol and name lengths are what the +// LiquidityPools pallet expects. +#[test] +fn verify_tranche_fields_sizes() { + assert_eq!( + cfg_types::consts::pools::MaxTrancheNameLengthBytes::get(), + pallet_liquidity_pools::TOKEN_NAME_SIZE as u32 + ); + assert_eq!( + cfg_types::consts::pools::MaxTrancheSymbolLengthBytes::get(), + pallet_liquidity_pools::TOKEN_SYMBOL_SIZE as u32 + ); +} diff --git a/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/setup.rs b/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/setup.rs new file mode 100644 index 0000000000..664abe3a41 --- /dev/null +++ b/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/setup.rs @@ -0,0 +1,376 @@ +// Copyright 2021 Centrifuge GmbH (centrifuge.io). +// This file is part of Centrifuge chain project. +// +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// Copyright 2021 Centrifuge GmbH (centrifuge.io). +// This file is part of Centrifuge chain project. +// +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +use cfg_primitives::{currency_decimals, Balance, Moment, PoolId, TrancheId}; +use cfg_traits::{investments::InvestmentAccountant, PoolMutate}; +use cfg_types::{ + domain_address::{Domain, DomainAddress}, + fixed_point::{Quantity, Rate}, + pools::TrancheMetadata, + tokens::{CrossChainTransferability, CurrencyId, CustomMetadata}, +}; +use cumulus_primitives_core::Junction::GlobalConsensus; +use development_runtime::{ + LiquidityPools, LiquidityPoolsGateway, OrmlAssetRegistry, OrmlTokens, PoolSystem, + Runtime as DevelopmentRuntime, RuntimeOrigin, TreasuryPalletId, +}; +use frame_support::{ + assert_ok, + traits::{ + fungible::Mutate as _, + fungibles::{Balanced, Mutate}, + Get, PalletInfo, + }, +}; +use liquidity_pools_gateway_routers::{ + ethereum_xcm::EthereumXCMRouter, DomainRouter, XCMRouter, XcmDomain as GatewayXcmDomain, + XcmTransactInfo, DEFAULT_PROOF_SIZE, +}; +use orml_asset_registry::{AssetMetadata, Metadata}; +use pallet_liquidity_pools::Message; +use pallet_pool_system::tranches::{TrancheInput, TrancheType}; +use runtime_common::{ + account_conversion::AccountConverter, xcm::general_key, xcm_fees::default_per_second, +}; +use sp_core::H160; +use sp_runtime::{ + traits::{AccountIdConversion, BadOrigin, ConstU32, Convert, EnsureAdd, One, Zero}, + BoundedVec, DispatchError, Perquintill, SaturatedConversion, WeakBoundedVec, +}; +use xcm::{ + latest::{Junction, Junction::*, Junctions::*, MultiLocation, NetworkId}, + prelude::{Parachain, X1, X2}, + VersionedMultiLocation, +}; + +use crate::{ + chain::centrifuge::development, + liquidity_pools::pallet::development::{ + setup::{dollar, ALICE, BOB, PARA_ID_MOONBEAM}, + tests::register_ausd, + }, + utils::{AUSD_CURRENCY_ID, GLMR_CURRENCY_ID, MOONBEAM_EVM_CHAIN_ID}, +}; +// 10 GLMR (18 decimals) +pub const DEFAULT_BALANCE_GLMR: Balance = 10_000_000_000_000_000_000; +pub const DOMAIN_MOONBEAM: Domain = Domain::EVM(MOONBEAM_EVM_CHAIN_ID); +pub const DEFAULT_EVM_ADDRESS_MOONBEAM: [u8; 20] = [99; 20]; +pub const DEFAULT_DOMAIN_ADDRESS_MOONBEAM: DomainAddress = + DomainAddress::EVM(MOONBEAM_EVM_CHAIN_ID, DEFAULT_EVM_ADDRESS_MOONBEAM); +pub const DEFAULT_VALIDITY: Moment = 2555583502; +pub const DEFAULT_OTHER_DOMAIN_ADDRESS: DomainAddress = + DomainAddress::EVM(MOONBEAM_EVM_CHAIN_ID, [0; 20]); +pub const DEFAULT_POOL_ID: u64 = 42; +pub const DEFAULT_MOONBEAM_LOCATION: MultiLocation = MultiLocation { + parents: 1, + interior: X1(Parachain(PARA_ID_MOONBEAM)), +}; +use frame_support::weights::Weight; + +pub type LiquidityPoolMessage = Message; + +pub fn get_default_moonbeam_native_token_location() -> MultiLocation { + MultiLocation { + parents: 1, + interior: X2(Parachain(PARA_ID_MOONBEAM), general_key(&[0, 1])), + } +} + +pub fn set_test_domain_router( + evm_chain_id: u64, + xcm_domain_location: VersionedMultiLocation, + currency_id: CurrencyId, +) { + let ethereum_xcm_router = EthereumXCMRouter:: { + router: XCMRouter { + xcm_domain: GatewayXcmDomain { + location: Box::new(xcm_domain_location), + ethereum_xcm_transact_call_index: BoundedVec::truncate_from(vec![38, 0]), + contract_address: H160::from(DEFAULT_EVM_ADDRESS_MOONBEAM), + max_gas_limit: 500_000, + transact_required_weight_at_most: Weight::from_parts( + 12530000000, + DEFAULT_PROOF_SIZE.saturating_div(2), + ), + overall_weight: Weight::from_parts(15530000000, DEFAULT_PROOF_SIZE), + fee_currency: currency_id, + // 0.2 token + fee_amount: 200000000000000000, + }, + _marker: Default::default(), + }, + _marker: Default::default(), + }; + + let domain_router = DomainRouter::EthereumXCM(ethereum_xcm_router); + let domain = Domain::EVM(evm_chain_id); + + assert_ok!(LiquidityPoolsGateway::set_domain_router( + RuntimeOrigin::root(), + domain, + domain_router, + )); +} + +/// Initializes universally required storage for liquidityPools tests: +/// * Set the EthereumXCM router which in turn sets: +/// * transact info and domain router for Moonbeam `MultiLocation`, +/// * fee for GLMR (`GLMR_CURRENCY_ID`), +/// * Register GLMR and AUSD in `OrmlAssetRegistry`, +/// * Mint 10 GLMR (`DEFAULT_BALANCE_GLMR`) for Alice, Bob and the Treasury. +/// +/// NOTE: AUSD is the default pool currency in `create_pool`. +/// Neither AUSD nor GLMR are registered as a liquidityPools-transferable +/// currency! +pub fn setup_pre_requirements() { + /// Set the EthereumXCM router necessary for Moonbeam. + set_test_domain_router( + MOONBEAM_EVM_CHAIN_ID, + DEFAULT_MOONBEAM_LOCATION.into(), + GLMR_CURRENCY_ID, + ); + + /// Register Moonbeam's native token + assert_ok!(OrmlAssetRegistry::register_asset( + RuntimeOrigin::root(), + asset_metadata( + "Glimmer".into(), + "GLMR".into(), + 18, + false, + Some(VersionedMultiLocation::V3( + get_default_moonbeam_native_token_location() + )), + CrossChainTransferability::Xcm(Default::default()), + ), + Some(GLMR_CURRENCY_ID) + )); + + // Fund the gateway sender account with enough glimmer to pay for fees + OrmlTokens::deposit( + GLMR_CURRENCY_ID, + &::Sender::get(), + DEFAULT_BALANCE_GLMR, + ); + // TODO: Check + // // Treasury pays for `Executed*` messages + // OrmlTokens::deposit( + // GLMR_CURRENCY_ID, + // &TreasuryPalletId::get().into_account_truncating(), + // DEFAULT_BALANCE_GLMR * dollar(18), + // ); + + // Register AUSD in the asset registry which is the default pool currency in + // `create_pool` + register_ausd(); +} + +/// Creates a new pool for the given id with +/// * BOB as admin and depositor +/// * Two tranches +/// * AUSD as pool currency with max reserve 10k. +pub fn create_ausd_pool(pool_id: u64) { + create_currency_pool(pool_id, AUSD_CURRENCY_ID, dollar(currency_decimals::AUSD)) +} + +/// Creates a new pool for for the given id with the provided currency. +/// * BOB as admin and depositor +/// * Two tranches +/// * The given `currency` as pool currency with of `currency_decimals`. +pub fn create_currency_pool(pool_id: u64, currency_id: CurrencyId, currency_decimals: Balance) { + assert_ok!(PoolSystem::create( + BOB.into(), + BOB.into(), + pool_id, + vec![ + TrancheInput { + tranche_type: TrancheType::Residual, + seniority: None, + metadata: + TrancheMetadata { + // NOTE: For now, we have to set these metadata fields of the first tranche + // to be convertible to the 32-byte size expected by the liquidity pools + // AddTranche message. + token_name: BoundedVec::< + u8, + cfg_types::consts::pools::MaxTrancheNameLengthBytes, + >::try_from("A highly advanced tranche".as_bytes().to_vec()) + .expect(""), + token_symbol: BoundedVec::< + u8, + cfg_types::consts::pools::MaxTrancheSymbolLengthBytes, + >::try_from("TrNcH".as_bytes().to_vec()) + .expect(""), + } + }, + TrancheInput { + tranche_type: TrancheType::NonResidual { + interest_rate_per_sec: One::one(), + min_risk_buffer: Perquintill::from_percent(10), + }, + seniority: None, + metadata: TrancheMetadata { + token_name: BoundedVec::default(), + token_symbol: BoundedVec::default(), + } + } + ], + currency_id, + currency_decimals, + )); +} + +/// Returns a `VersionedMultiLocation` that can be converted into +/// `LiquidityPoolsWrappedToken` which is required for cross chain asset +/// registration and transfer. +pub fn liquidity_pools_transferable_multilocation( + chain_id: u64, + address: [u8; 20], +) -> VersionedMultiLocation { + VersionedMultiLocation::V3(MultiLocation { + parents: 0, + interior: X3( + PalletInstance( + ::PalletInfo::index::() + .expect("LiquidityPools should have pallet index") + .saturated_into(), + ), + GlobalConsensus(NetworkId::Ethereum { chain_id }), + AccountKey20 { + network: None, + key: address, + }, + ), + }) +} + +/// Enables `LiquidityPoolsTransferable` in the custom asset metadata for +/// the given currency_id. +/// +/// NOTE: Sets the location to the `MOONBEAM_EVM_CHAIN_ID` with dummy +/// address as the location is required for LiquidityPoolsWrappedToken +/// conversions. +pub fn enable_liquidity_pool_transferability(currency_id: CurrencyId) { + let metadata = + Metadata::::get(currency_id).expect("Currency should be registered"); + let location = Some(Some(liquidity_pools_transferable_multilocation( + MOONBEAM_EVM_CHAIN_ID, + // Value of evm_address is irrelevant here + [1u8; 20], + ))); + + assert_ok!(OrmlAssetRegistry::update_asset( + RuntimeOrigin::root(), + currency_id, + None, + None, + None, + None, + location, + Some(CustomMetadata { + // Changed: Allow liquidity_pools transferability + transferability: CrossChainTransferability::LiquidityPools, + ..metadata.additional + }) + )); +} + +/// Returns metadata for the given data with existential deposit of +/// 1_000_000. +pub fn asset_metadata( + name: Vec, + symbol: Vec, + decimals: u32, + is_pool_currency: bool, + location: Option, + transferability: CrossChainTransferability, +) -> AssetMetadata { + AssetMetadata { + name, + symbol, + decimals, + location, + existential_deposit: 1_000_000, + additional: CustomMetadata { + transferability, + mintable: false, + permissioned: false, + pool_currency: is_pool_currency, + }, + } +} + +pub(crate) mod investments { + use cfg_primitives::AccountId; + use cfg_traits::investments::TrancheCurrency as TrancheCurrencyT; + use cfg_types::investments::InvestmentAccount; + use development_runtime::{OrderBook, PoolSystem}; + use pallet_pool_system::tranches::TrancheLoc; + + use super::*; + + /// Returns the default investment account derived from the + /// `DEFAULT_POOL_ID` and its default tranche. + pub fn default_investment_account() -> AccountId { + InvestmentAccount { + investment_id: default_investment_id(), + } + .into_account_truncating() + } + + /// Returns the investment_id of the given pool and tranche ids. + pub fn investment_id( + pool_id: u64, + tranche_id: TrancheId, + ) -> cfg_types::tokens::TrancheCurrency { + ::TrancheCurrency::generate( + pool_id, tranche_id, + ) + } + + pub fn default_investment_id() -> cfg_types::tokens::TrancheCurrency { + ::TrancheCurrency::generate( + DEFAULT_POOL_ID, + default_tranche_id(DEFAULT_POOL_ID), + ) + } + + /// Returns the tranche id at index 0 for the given pool id. + pub fn default_tranche_id(pool_id: u64) -> TrancheId { + let pool_details = PoolSystem::pool(pool_id).expect("Pool should exist"); + pool_details + .tranches + .tranche_id(TrancheLoc::Index(0)) + .expect("Tranche at index 0 exists") + } + + /// Returns the derived general currency index. + /// + /// Throws if the provided currency_id is not + /// `CurrencyId::ForeignAsset(id)`. + pub fn general_currency_index(currency_id: CurrencyId) -> u128 { + pallet_liquidity_pools::Pallet::::try_get_general_index(currency_id) + .expect("ForeignAsset should convert into u128") + } +} diff --git a/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/transfers.rs b/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/transfers.rs new file mode 100644 index 0000000000..a4c9080b78 --- /dev/null +++ b/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/transfers.rs @@ -0,0 +1,458 @@ +// Copyright 2021 Centrifuge GmbH (centrifuge.io). +// This file is part of Centrifuge chain project. +// +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// Copyright 2021 Centrifuge GmbH (centrifuge.io). +// This file is part of Centrifuge chain project. +// +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +use cfg_primitives::{AccountId, Balance, PoolId, TrancheId, CFG}; +use cfg_traits::{ + investments::{OrderManager, TrancheCurrency as TrancheCurrencyT}, + liquidity_pools::InboundQueue, + Permissions as _, +}; +use cfg_types::{ + domain_address::{Domain, DomainAddress}, + permissions::{PermissionScope, PoolRole, Role}, + tokens::{ + CrossChainTransferability, CurrencyId, CurrencyId::ForeignAsset, CustomMetadata, + ForeignAssetId, + }, +}; +use development_runtime::{ + LiquidityPools, LocationToAccountId, OrmlTokens, Permissions, PoolSystem, + Runtime as DevelopmentRuntime, RuntimeOrigin, System, +}; +use frame_support::{assert_noop, assert_ok, dispatch::Weight, traits::fungibles::Mutate}; +use orml_traits::{asset_registry::AssetMetadata, FixedConversionRateProvider, MultiCurrency}; +use runtime_common::account_conversion::AccountConverter; +use sp_runtime::{ + traits::{Convert, One, Zero}, + BoundedVec, DispatchError, +}; +use xcm::{latest::MultiLocation, VersionedMultiLocation}; +use xcm_emulator::TestExt; + +use crate::{ + liquidity_pools::pallet::development::{ + setup::{dollar, ALICE, BOB}, + test_net::{Development, Moonbeam, RelayChain, TestNet}, + tests::liquidity_pools::setup::{ + asset_metadata, create_ausd_pool, create_currency_pool, + enable_liquidity_pool_transferability, + investments::{default_tranche_id, general_currency_index, investment_id}, + liquidity_pools_transferable_multilocation, setup_pre_requirements, + LiquidityPoolMessage, DEFAULT_BALANCE_GLMR, DEFAULT_DOMAIN_ADDRESS_MOONBEAM, + DEFAULT_POOL_ID, + }, + }, + utils::{AUSD_CURRENCY_ID, MOONBEAM_EVM_CHAIN_ID}, +}; + +#[test] +fn transfer_non_tranche_tokens_from_local() { + TestNet::reset(); + Development::execute_with(|| { + // Register GLMR and fund BOB + setup_pre_requirements(); + + let initial_balance = 100_000_000; + let amount = initial_balance / 2; + let dest_address = DEFAULT_DOMAIN_ADDRESS_MOONBEAM; + let currency_id = AUSD_CURRENCY_ID; + let source_account = BOB; + + // Mint sufficient balance + assert_ok!(OrmlTokens::mint_into( + currency_id, + &source_account.into(), + initial_balance + )); + assert_eq!( + OrmlTokens::free_balance(currency_id, &source_account.into()), + initial_balance + ); + + // Only `ForeignAsset` can be transferred + assert_noop!( + LiquidityPools::transfer( + RuntimeOrigin::signed(source_account.into()), + CurrencyId::Tranche(42u64, [0u8; 16]), + dest_address.clone(), + amount, + ), + pallet_liquidity_pools::Error::::InvalidTransferCurrency + ); + assert_noop!( + LiquidityPools::transfer( + RuntimeOrigin::signed(source_account.into()), + CurrencyId::Staking(cfg_types::tokens::StakingCurrency::BlockRewards), + dest_address.clone(), + amount, + ), + pallet_liquidity_pools::Error::::AssetNotFound + ); + assert_noop!( + LiquidityPools::transfer( + RuntimeOrigin::signed(source_account.into()), + CurrencyId::Native, + dest_address.clone(), + amount, + ), + pallet_liquidity_pools::Error::::AssetNotFound + ); + + // Cannot transfer as long as cross chain transferability is disabled + assert_noop!( + LiquidityPools::transfer( + RuntimeOrigin::signed(source_account.into()), + currency_id, + dest_address.clone(), + initial_balance, + ), + pallet_liquidity_pools::Error::::AssetNotLiquidityPoolsTransferable + ); + + // Enable LiquidityPools transferability + enable_liquidity_pool_transferability(currency_id); + + // Cannot transfer more than owned + assert_noop!( + LiquidityPools::transfer( + RuntimeOrigin::signed(source_account.into()), + currency_id, + dest_address.clone(), + initial_balance.saturating_add(1), + ), + orml_tokens::Error::::BalanceTooLow + ); + + assert_ok!(LiquidityPools::transfer( + RuntimeOrigin::signed(source_account.into()), + currency_id, + dest_address.clone(), + amount, + )); + + // The account to which the currency should have been transferred + // to on Centrifuge for bookkeeping purposes. + let domain_account: AccountId = Domain::convert(dest_address.domain()); + // Verify that the correct amount of the token was transferred + // to the dest domain account on Centrifuge. + assert_eq!( + OrmlTokens::free_balance(currency_id, &domain_account), + amount + ); + assert_eq!( + OrmlTokens::free_balance(currency_id, &source_account.into()), + initial_balance - amount + ); + }); +} + +#[test] +fn transfer_non_tranche_tokens_to_local() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + + let initial_balance = DEFAULT_BALANCE_GLMR; + let amount = DEFAULT_BALANCE_GLMR / 2; + let dest_address = DEFAULT_DOMAIN_ADDRESS_MOONBEAM; + let currency_id = AUSD_CURRENCY_ID; + let receiver: AccountId = BOB.into(); + + // Mock incoming decrease message + let msg = LiquidityPoolMessage::Transfer { + currency: general_currency_index(currency_id), + // sender is irrelevant for other -> local + sender: ALICE, + receiver: receiver.clone().into(), + amount, + }; + + assert!(OrmlTokens::total_issuance(currency_id).is_zero()); + + // Finally, verify that we can now transfer the tranche to the destination + // address + assert_ok!(LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg)); + + // Verify that the correct amount was minted + assert_eq!(OrmlTokens::total_issuance(currency_id), amount); + assert_eq!(OrmlTokens::free_balance(currency_id, &receiver), amount); + + // Verify empty transfers throw + assert_noop!( + LiquidityPools::submit( + DEFAULT_DOMAIN_ADDRESS_MOONBEAM, + LiquidityPoolMessage::Transfer { + currency: general_currency_index(currency_id), + sender: ALICE, + receiver: receiver.into(), + amount: 0, + }, + ), + pallet_liquidity_pools::Error::::InvalidTransferAmount + ); + }); +} + +#[test] +fn transfer_tranche_tokens_from_local() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + + let pool_id = DEFAULT_POOL_ID; + let amount = 100_000; + let dest_address: DomainAddress = DomainAddress::EVM(1284, [99; 20]); + let receiver = BOB; + + // Create the pool + create_ausd_pool(pool_id); + + let tranche_tokens: CurrencyId = + cfg_types::tokens::TrancheCurrency::generate(pool_id, default_tranche_id(pool_id)) + .into(); + + // Verify that we first need the destination address to be whitelisted + assert_noop!( + LiquidityPools::transfer_tranche_tokens( + RuntimeOrigin::signed(ALICE.into()), + pool_id, + default_tranche_id(pool_id), + dest_address.clone(), + amount, + ), + pallet_liquidity_pools::Error::::UnauthorizedTransfer + ); + + // Make receiver the MembersListAdmin of this Pool + assert_ok!(Permissions::add( + RuntimeOrigin::root(), + Role::PoolRole(PoolRole::PoolAdmin), + receiver.into(), + PermissionScope::Pool(pool_id), + Role::PoolRole(PoolRole::InvestorAdmin), + )); + + // Whitelist destination as TrancheInvestor of this Pool + let valid_until = u64::MAX; + assert_ok!(Permissions::add( + RuntimeOrigin::signed(receiver.into()), + Role::PoolRole(PoolRole::InvestorAdmin), + AccountConverter::::convert( + dest_address.clone() + ), + PermissionScope::Pool(pool_id), + Role::PoolRole(PoolRole::TrancheInvestor( + default_tranche_id(pool_id), + valid_until + )), + )); + + // Call the LiquidityPools::update_member which ensures the destination address + // is whitelisted. + assert_ok!(LiquidityPools::update_member( + RuntimeOrigin::signed(receiver.into()), + pool_id, + default_tranche_id(pool_id), + dest_address.clone(), + valid_until, + )); + + // Give receiver enough Tranche balance to be able to transfer it + OrmlTokens::deposit(tranche_tokens, &receiver.into(), amount); + + // Finally, verify that we can now transfer the tranche to the destination + // address + assert_ok!(LiquidityPools::transfer_tranche_tokens( + RuntimeOrigin::signed(receiver.into()), + pool_id, + default_tranche_id(pool_id), + dest_address.clone(), + amount, + )); + + // The account to which the tranche should have been transferred + // to on Centrifuge for bookkeeping purposes. + let domain_account: AccountId = Domain::convert(dest_address.domain()); + + // Verify that the correct amount of the Tranche token was transferred + // to the dest domain account on Centrifuge. + assert_eq!( + OrmlTokens::free_balance(tranche_tokens, &domain_account), + amount + ); + assert!(OrmlTokens::free_balance(tranche_tokens, &receiver.into()).is_zero()); + }); +} + +#[test] +fn transfer_tranche_tokens_to_local() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + + // Create new pool + let pool_id = DEFAULT_POOL_ID; + create_ausd_pool(pool_id); + + let amount = 100_000_000; + let receiver: AccountId = BOB.into(); + let sender: DomainAddress = DomainAddress::EVM(1284, [99; 20]); + let sending_domain_locator = Domain::convert(DEFAULT_DOMAIN_ADDRESS_MOONBEAM.domain()); + let tranche_id = default_tranche_id(pool_id); + let tranche_tokens: CurrencyId = + cfg_types::tokens::TrancheCurrency::generate(pool_id, tranche_id).into(); + let valid_until = u64::MAX; + + // Fund `DomainLocator` account of origination domain tranche tokens are + // transferred from this account instead of minting + assert_ok!(OrmlTokens::mint_into( + tranche_tokens, + &sending_domain_locator, + amount + )); + + // Mock incoming decrease message + let msg = LiquidityPoolMessage::TransferTrancheTokens { + pool_id, + tranche_id, + sender: sender.address(), + domain: Domain::Centrifuge, + receiver: receiver.clone().into(), + amount, + }; + + // Verify that we first need the receiver to be whitelisted + assert_noop!( + LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg.clone()), + pallet_liquidity_pools::Error::::UnauthorizedTransfer + ); + + // Make receiver the MembersListAdmin of this Pool + assert_ok!(Permissions::add( + RuntimeOrigin::root(), + Role::PoolRole(PoolRole::PoolAdmin), + receiver.clone(), + PermissionScope::Pool(pool_id), + Role::PoolRole(PoolRole::InvestorAdmin), + )); + + // Whitelist destination as TrancheInvestor of this Pool + assert_ok!(Permissions::add( + RuntimeOrigin::signed(receiver.clone()), + Role::PoolRole(PoolRole::InvestorAdmin), + receiver.clone(), + PermissionScope::Pool(pool_id), + Role::PoolRole(PoolRole::TrancheInvestor( + default_tranche_id(pool_id), + valid_until + )), + )); + + // Finally, verify that we can now transfer the tranche to the destination + // address + assert_ok!(LiquidityPools::submit(DEFAULT_DOMAIN_ADDRESS_MOONBEAM, msg)); + + // Verify that the correct amount of the Tranche token was transferred + // to the dest domain account on Centrifuge. + assert_eq!(OrmlTokens::free_balance(tranche_tokens, &receiver), amount); + assert!(OrmlTokens::free_balance(tranche_tokens, &sending_domain_locator).is_zero()); + }); +} + +#[test] +/// Try to transfer tranches for non-existing pools or invalid tranche ids for +/// existing pools. +fn transferring_invalid_tranche_tokens_should_fail() { + TestNet::reset(); + Development::execute_with(|| { + setup_pre_requirements(); + let dest_address: DomainAddress = DomainAddress::EVM(1284, [99; 20]); + + let valid_pool_id: u64 = 42; + create_ausd_pool(valid_pool_id); + let valid_tranche_id = default_tranche_id(valid_pool_id); + let valid_until = u64::MAX; + let transfer_amount = 42; + let invalid_pool_id = valid_pool_id + 1; + let invalid_tranche_id = valid_tranche_id.map(|i| i.saturating_add(1)); + assert!(PoolSystem::pool(invalid_pool_id).is_none()); + + // Make BOB the MembersListAdmin of both pools + assert_ok!(Permissions::add( + RuntimeOrigin::root(), + Role::PoolRole(PoolRole::PoolAdmin), + BOB.into(), + PermissionScope::Pool(valid_pool_id), + Role::PoolRole(PoolRole::InvestorAdmin), + )); + assert_ok!(Permissions::add( + RuntimeOrigin::root(), + Role::PoolRole(PoolRole::PoolAdmin), + BOB.into(), + PermissionScope::Pool(invalid_pool_id), + Role::PoolRole(PoolRole::InvestorAdmin), + )); + + // Give BOB investor role for (valid_pool_id, invalid_tranche_id) and + // (invalid_pool_id, valid_tranche_id) + assert_ok!(Permissions::add( + RuntimeOrigin::signed(BOB.into()), + Role::PoolRole(PoolRole::InvestorAdmin), + AccountConverter::::convert( + dest_address.clone() + ), + PermissionScope::Pool(invalid_pool_id), + Role::PoolRole(PoolRole::TrancheInvestor(valid_tranche_id, valid_until)), + )); + assert_ok!(Permissions::add( + RuntimeOrigin::signed(BOB.into()), + Role::PoolRole(PoolRole::InvestorAdmin), + AccountConverter::::convert( + dest_address.clone() + ), + PermissionScope::Pool(valid_pool_id), + Role::PoolRole(PoolRole::TrancheInvestor(invalid_tranche_id, valid_until)), + )); + assert_noop!( + LiquidityPools::transfer_tranche_tokens( + RuntimeOrigin::signed(BOB.into()), + invalid_pool_id, + valid_tranche_id, + dest_address.clone(), + transfer_amount + ), + pallet_liquidity_pools::Error::::PoolNotFound + ); + assert_noop!( + LiquidityPools::transfer_tranche_tokens( + RuntimeOrigin::signed(BOB.into()), + valid_pool_id, + invalid_tranche_id, + dest_address, + transfer_amount + ), + pallet_liquidity_pools::Error::::TrancheNotFound + ); + }); +} diff --git a/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/mod.rs b/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/mod.rs index 3c69d462d8..7ac3eef92a 100644 --- a/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/mod.rs +++ b/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/mod.rs @@ -6,17 +6,18 @@ use orml_traits::asset_registry::AssetMetadata; use runtime_common::xcm::general_key; use xcm::{ latest::MultiLocation, - prelude::{Parachain, X2}, + prelude::{GeneralIndex, PalletInstance, Parachain, X2, X3}, VersionedMultiLocation, }; -use crate::utils::AUSD_CURRENCY_ID; +use crate::utils::{AUSD_CURRENCY_ID, USDT_CURRENCY_ID}; mod liquidity_pools; mod routers; /// Register AUSD in the asset registry. -/// It should be executed within an externalities environment. +/// +/// NOTE: It should be executed within an externalities environment. pub fn register_ausd() { let meta: AssetMetadata = AssetMetadata { decimals: 12, @@ -43,3 +44,31 @@ pub fn register_ausd() { Some(AUSD_CURRENCY_ID) )); } + +/// Register USDT in the asset registry and enable LiquidityPools cross chain +/// transferability. +/// +/// NOTE: Assumes to be executed within an externalities environment. +fn register_usdt() { + let meta: AssetMetadata = AssetMetadata { + decimals: 6, + name: "Tether USDT".into(), + symbol: "USDT".into(), + existential_deposit: 10_000, + location: Some(VersionedMultiLocation::V3(MultiLocation::new( + 1, + X3(Parachain(1000), PalletInstance(50), GeneralIndex(1984)), + ))), + additional: CustomMetadata { + transferability: CrossChainTransferability::LiquidityPools, + pool_currency: true, + ..CustomMetadata::default() + }, + }; + + assert_ok!(OrmlAssetRegistry::register_asset( + RuntimeOrigin::root(), + meta, + Some(USDT_CURRENCY_ID) + )); +} diff --git a/runtime/integration-tests/src/utils/mod.rs b/runtime/integration-tests/src/utils/mod.rs index 3d5aa36e4e..686713f73b 100644 --- a/runtime/integration-tests/src/utils/mod.rs +++ b/runtime/integration-tests/src/utils/mod.rs @@ -28,10 +28,12 @@ pub mod time; pub mod tokens; /// The relay native token's asset id -pub const RELAY_ASSET_ID: CurrencyId = CurrencyId::ForeignAsset(1); +pub const RELAY_ASSET_ID: CurrencyId = CurrencyId::ForeignAsset(5); /// The Glimmer asset id -pub const GLIMMER_CURRENCY_ID: CurrencyId = CurrencyId::ForeignAsset(1000); +pub const GLMR_CURRENCY_ID: CurrencyId = CurrencyId::ForeignAsset(4); /// The AUSD asset id -pub const AUSD_CURRENCY_ID: CurrencyId = CurrencyId::ForeignAsset(2000); +pub const AUSD_CURRENCY_ID: CurrencyId = CurrencyId::ForeignAsset(3); +/// The USDT asset id +pub const USDT_CURRENCY_ID: CurrencyId = CurrencyId::ForeignAsset(1); /// The EVM Chain id of Moonbeam pub const MOONBEAM_EVM_CHAIN_ID: u64 = 1284;