diff --git a/Cargo.lock b/Cargo.lock index 8804f7d18..ef242b4dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -622,6 +622,7 @@ dependencies = [ "zrml-authorized", "zrml-court", "zrml-global-disputes", + "zrml-hybrid-router", "zrml-liquidity-mining", "zrml-market-commons", "zrml-neo-swaps", @@ -14487,6 +14488,7 @@ dependencies = [ "zrml-authorized", "zrml-court", "zrml-global-disputes", + "zrml-hybrid-router", "zrml-liquidity-mining", "zrml-market-commons", "zrml-neo-swaps", @@ -14584,6 +14586,45 @@ dependencies = [ "zrml-market-commons", ] +[[package]] +name = "zrml-hybrid-router" +version = "0.4.3" +dependencies = [ + "env_logger 0.10.1", + "frame-benchmarking", + "frame-support", + "frame-system", + "orml-asset-registry", + "orml-currencies", + "orml-tokens", + "orml-traits", + "pallet-balances", + "pallet-randomness-collective-flip", + "pallet-timestamp", + "pallet-treasury", + "pallet-xcm", + "parity-scale-codec", + "scale-info", + "serde", + "sp-io", + "sp-runtime", + "test-case", + "xcm", + "xcm-builder", + "zeitgeist-primitives", + "zrml-authorized", + "zrml-court", + "zrml-global-disputes", + "zrml-hybrid-router", + "zrml-liquidity-mining", + "zrml-market-commons", + "zrml-neo-swaps", + "zrml-orderbook", + "zrml-prediction-markets", + "zrml-simple-disputes", + "zrml-swaps", +] + [[package]] name = "zrml-liquidity-mining" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index ccb25afea..d6b1d7c20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ default-members = [ "runtime/zeitgeist", "zrml/authorized", "zrml/court", + "zrml/hybrid-router", "zrml/global-disputes", "zrml/liquidity-mining", "zrml/market-commons", @@ -32,6 +33,7 @@ members = [ "runtime/zeitgeist", "zrml/authorized", "zrml/court", + "zrml/hybrid-router", "zrml/global-disputes", "zrml/liquidity-mining", "zrml/market-commons", @@ -235,6 +237,7 @@ zeitgeist-primitives = { path = "primitives", default-features = false } zrml-authorized = { path = "zrml/authorized", default-features = false } zrml-court = { path = "zrml/court", default-features = false } zrml-global-disputes = { path = "zrml/global-disputes", default-features = false } +zrml-hybrid-router = { path = "zrml/hybrid-router", default-features = false } zrml-liquidity-mining = { path = "zrml/liquidity-mining", default-features = false } zrml-market-commons = { path = "zrml/market-commons", default-features = false } zrml-neo-swaps = { path = "zrml/neo-swaps", default-features = false } diff --git a/primitives/src/constants.rs b/primitives/src/constants.rs index 6bd7a4c32..d5f42caf9 100644 --- a/primitives/src/constants.rs +++ b/primitives/src/constants.rs @@ -1,4 +1,4 @@ -// Copyright 2022-2023 Forecasting Technologies LTD. +// Copyright 2022-2024 Forecasting Technologies LTD. // Copyright 2021-2022 Zeitgeist PM LLC. // // This file is part of Zeitgeist. @@ -22,6 +22,8 @@ clippy::arithmetic_side_effects )] +#[cfg(feature = "mock")] +pub mod base_multiples; #[cfg(feature = "mock")] pub mod mock; pub mod ztg; @@ -79,6 +81,10 @@ pub const GLOBAL_DISPUTES_PALLET_ID: PalletId = PalletId(*b"zge/gldp"); /// Lock identifier, mainly used for the locks on the accounts. pub const GLOBAL_DISPUTES_LOCK_ID: [u8; 8] = *b"zge/gdlk"; +// Hybrid Router +/// Pallet identifier, mainly used for named balance reserves. +pub const HYBRID_ROUTER_PALLET_ID: PalletId = PalletId(*b"zge/hybr"); + // Liqudity Mining /// Pallet identifier, mainly used for named balance reserves. pub const LM_PALLET_ID: PalletId = PalletId(*b"zge/lymg"); diff --git a/primitives/src/constants/base_multiples.rs b/primitives/src/constants/base_multiples.rs new file mode 100644 index 000000000..e714d61d5 --- /dev/null +++ b/primitives/src/constants/base_multiples.rs @@ -0,0 +1,70 @@ +// Copyright 2024 Forecasting Technologies LTD. +// +// This file is part of Zeitgeist. +// +// Zeitgeist 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. +// +// Zeitgeist 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. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +#![cfg(feature = "mock")] + +use crate::constants::BASE; + +pub const _1: u128 = BASE; +pub const _2: u128 = 2 * _1; +pub const _3: u128 = 3 * _1; +pub const _4: u128 = 4 * _1; +pub const _5: u128 = 5 * _1; +pub const _6: u128 = 6 * _1; +pub const _7: u128 = 7 * _1; +pub const _8: u128 = 8 * _1; +pub const _9: u128 = 9 * _1; +pub const _10: u128 = 10 * _1; +pub const _11: u128 = 11 * _1; +pub const _12: u128 = 12 * _1; +pub const _14: u128 = 14 * _1; +pub const _17: u128 = 17 * _1; +pub const _20: u128 = 20 * _1; +pub const _23: u128 = 23 * _1; +pub const _24: u128 = 24 * _1; +pub const _30: u128 = 30 * _1; +pub const _36: u128 = 36 * _1; +pub const _40: u128 = 40 * _1; +pub const _70: u128 = 70 * _1; +pub const _80: u128 = 80 * _1; +pub const _100: u128 = 100 * _1; +pub const _101: u128 = 101 * _1; +pub const _444: u128 = 444 * _1; +pub const _500: u128 = 500 * _1; +pub const _777: u128 = 777 * _1; +pub const _1000: u128 = 1_000 * _1; + +pub const _1_2: u128 = _1 / 2; + +pub const _1_3: u128 = _1 / 3; +pub const _2_3: u128 = _2 / 3; + +pub const _1_4: u128 = _1 / 4; +pub const _3_4: u128 = _3 / 4; + +pub const _1_5: u128 = _1 / 5; + +pub const _1_6: u128 = _1 / 6; +pub const _5_6: u128 = _5 / 6; + +pub const _1_10: u128 = _1 / 10; +pub const _2_10: u128 = _2 / 10; +pub const _3_10: u128 = _3 / 10; +pub const _4_10: u128 = _4 / 10; +pub const _9_10: u128 = _9 / 10; + +pub const _1_100: u128 = _1 / 100; diff --git a/primitives/src/constants/mock.rs b/primitives/src/constants/mock.rs index 11627c67c..0e25aa429 100644 --- a/primitives/src/constants/mock.rs +++ b/primitives/src/constants/mock.rs @@ -66,6 +66,12 @@ parameter_types! { pub const VotingOutcomeFee: Balance = 100 * CENT; } +// Hybrid Router parameters +parameter_types! { + pub const HybridRouterPalletId: PalletId = PalletId(*b"zge/hybr"); + pub const MaxOrders: u32 = 100; +} + // Liquidity Mining parameters parameter_types! { pub const LiquidityMiningPalletId: PalletId = PalletId(*b"zge/lymg"); diff --git a/primitives/src/hybrid_router_api_types.rs b/primitives/src/hybrid_router_api_types.rs new file mode 100644 index 000000000..57af32cc9 --- /dev/null +++ b/primitives/src/hybrid_router_api_types.rs @@ -0,0 +1,40 @@ +// Copyright 2024 Forecasting Technologies LTD. +// +// This file is part of Zeitgeist. +// +// Zeitgeist 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. +// +// Zeitgeist 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. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +use frame_support::pallet_prelude::*; +use scale_info::TypeInfo; + +#[derive(Clone, Copy, PartialEq, Eq, Encode, Decode, Debug, TypeInfo)] +pub struct AmmTrade { + pub amount_in: Balance, + pub amount_out: Balance, + pub swap_fee_amount: Balance, + pub external_fee_amount: Balance, +} + +#[derive(Clone, Copy, PartialEq, Eq, Encode, Decode, Debug, TypeInfo)] +pub struct ExternalFee { + pub account: AccountId, + pub amount: Balance, +} + +#[derive(Clone, Copy, PartialEq, Eq, Encode, Decode, Debug, TypeInfo)] +pub struct OrderbookTrade { + pub filled_maker_amount: Balance, + pub filled_taker_amount: Balance, + pub external_fee: ExternalFee, +} diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index a2197a950..de0033306 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -22,6 +22,7 @@ extern crate alloc; mod asset; pub mod constants; +pub mod hybrid_router_api_types; mod market; pub mod math; mod max_runtime_usize; diff --git a/primitives/src/traits.rs b/primitives/src/traits.rs index 7d5560773..7099387d3 100644 --- a/primitives/src/traits.rs +++ b/primitives/src/traits.rs @@ -20,6 +20,7 @@ mod complete_set_operations_api; mod deploy_pool_api; mod dispute_api; mod distribute_fees; +mod hybrid_router_amm_api; mod hybrid_router_orderbook_api; mod market_commons_pallet_api; mod market_id; @@ -31,6 +32,7 @@ pub use complete_set_operations_api::CompleteSetOperationsApi; pub use deploy_pool_api::DeployPoolApi; pub use dispute_api::{DisputeApi, DisputeMaxWeightApi, DisputeResolutionApi}; pub use distribute_fees::DistributeFees; +pub use hybrid_router_amm_api::HybridRouterAmmApi; pub use hybrid_router_orderbook_api::HybridRouterOrderbookApi; pub use market_commons_pallet_api::MarketCommonsPalletApi; pub use market_id::MarketId; diff --git a/primitives/src/traits/distribute_fees.rs b/primitives/src/traits/distribute_fees.rs index 7e443c55d..eeea0481d 100644 --- a/primitives/src/traits/distribute_fees.rs +++ b/primitives/src/traits/distribute_fees.rs @@ -15,6 +15,8 @@ // You should have received a copy of the GNU General Public License // along with Zeitgeist. If not, see . +use sp_runtime::Perbill; + /// Trait for distributing fees collected from trading to external recipients like the treasury. pub trait DistributeFees { type Asset; @@ -40,4 +42,11 @@ pub trait DistributeFees { account: &Self::AccountId, amount: Self::Balance, ) -> Self::Balance; + + /// Returns the percentage of the fee that is distributed. + /// + /// # Arguments + /// + /// - `market_id`: The market on which the fees belong to. + fn fee_percentage(market_id: Self::MarketId) -> Perbill; } diff --git a/primitives/src/traits/hybrid_router_amm_api.rs b/primitives/src/traits/hybrid_router_amm_api.rs new file mode 100644 index 000000000..0320ffd7f --- /dev/null +++ b/primitives/src/traits/hybrid_router_amm_api.rs @@ -0,0 +1,137 @@ +// Copyright 2024 Forecasting Technologies LTD. +// +// This file is part of Zeitgeist. +// +// Zeitgeist 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. +// +// Zeitgeist 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. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +use crate::hybrid_router_api_types::AmmTrade; +use frame_support::dispatch::DispatchError; + +/// A type alias for the return struct of AMM buy and sell. +pub type AmmTradeOf = AmmTrade<::Balance>; + +/// Trait for handling the AMM part of the hybrid router. +pub trait HybridRouterAmmApi { + type AccountId; + type Asset; + type Balance; + type MarketId; + + /// Checks if a pool exists for the given market ID. + /// + /// # Arguments + /// + /// - `market_id`: The market ID to check. + /// + /// # Returns + /// + /// Returns `true` if the pool exists, `false` otherwise. + fn pool_exists(market_id: Self::MarketId) -> bool; + + /// Gets the spot price for the given market ID and asset. + /// + /// # Arguments + /// + /// - `market_id`: The market ID. + /// - `asset`: The asset to get the spot price for. + /// + /// # Returns + /// + /// Returns the spot price as a `Result` containing the balance, or an error if the spot price + /// cannot be retrieved. + fn get_spot_price( + market_id: Self::MarketId, + asset: Self::Asset, + ) -> Result; + + /// Calculates the amount a user has to buy to move the price of `asset` to `until`. Returns + /// zero if the current spot price is above or equal to `until`. + /// + /// # Arguments + /// + /// - `market_id`: The market ID for which to calculate the buy amount. + /// - `asset`: The asset to calculate the buy amount for. + /// - `until`: The maximum price. + /// + /// # Returns + /// + /// Returns the buy amount as a `Result` containing the balance, or an error if the buy amount + /// cannot be calculated. + fn calculate_buy_amount_until( + market_id: Self::MarketId, + asset: Self::Asset, + until: Self::Balance, + ) -> Result; + + /// Executes a buy transaction. + /// + /// # Arguments + /// + /// - `who`: The account ID of the user performing the buy. + /// - `market_id`: The market ID. + /// - `asset_out`: The asset to receive from the buy. + /// - `amount_in`: The base asset amount to input for the buy. + /// - `min_amount_out`: The minimum amount to receive from the buy. + /// + /// # Returns + /// + /// Returns `Ok(())` if the buy is successful, or an error if the buy fails. + fn buy( + who: Self::AccountId, + market_id: Self::MarketId, + asset_out: Self::Asset, + amount_in: Self::Balance, + min_amount_out: Self::Balance, + ) -> Result, DispatchError>; + + /// Calculates the amount a user has to sell to move the price of `asset` to `until`. Returns + /// zero if the current spot price is below or equal to `until`. + /// + /// # Arguments + /// + /// - `market_id`: The market ID for which to calculate the sell amount. + /// - `asset`: The asset to calculate the sell amount for. + /// - `until`: The minimum price. + /// + /// # Returns + /// + /// Returns the sell amount as a `Result` containing the balance, or an error if the sell amount + /// cannot be calculated. + fn calculate_sell_amount_until( + market_id: Self::MarketId, + asset: Self::Asset, + until: Self::Balance, + ) -> Result; + + /// Executes a sell transaction. + /// + /// # Arguments + /// + /// - `who`: The account ID of the user performing the sell. + /// - `market_id`: The market ID. + /// - `asset_in`: The asset to sell. + /// - `amount_in`: The amount to input for the sell. + /// - `min_amount_out`: The minimum amount to receive from the sell. + /// + /// # Returns + /// + /// Returns `Ok(())` if the sell is successful, or an error if the sell fails. + fn sell( + who: Self::AccountId, + market_id: Self::MarketId, + asset_in: Self::Asset, + amount_in: Self::Balance, + min_amount_out: Self::Balance, + ) -> Result, DispatchError>; +} diff --git a/primitives/src/traits/hybrid_router_orderbook_api.rs b/primitives/src/traits/hybrid_router_orderbook_api.rs index 76d4c12ec..95763aaf0 100644 --- a/primitives/src/traits/hybrid_router_orderbook_api.rs +++ b/primitives/src/traits/hybrid_router_orderbook_api.rs @@ -17,6 +17,14 @@ use frame_support::dispatch::{DispatchError, DispatchResult}; +use crate::hybrid_router_api_types::OrderbookTrade; + +/// A type alias for the return struct of orderbook trades. +pub type OrderbookTradeOf = OrderbookTrade< + ::AccountId, + ::Balance, +>; + /// Trait for handling the order book part of the hybrid router. pub trait HybridRouterOrderbookApi { type AccountId; @@ -41,12 +49,12 @@ pub trait HybridRouterOrderbookApi { /// - `order_id`: The id of the order to fill. /// - `maker_partial_fill`: The amount to fill the order with. /// - /// Returns the filled order amount. + /// Returns the trade information about the filled maker and taker amounts, and the external fee. fn fill_order( who: Self::AccountId, order_id: Self::OrderId, maker_partial_fill: Option, - ) -> DispatchResult; + ) -> Result, DispatchError>; /// Places an order on the order book. /// diff --git a/runtime/battery-station/Cargo.toml b/runtime/battery-station/Cargo.toml index 6031e0c83..60556a284 100644 --- a/runtime/battery-station/Cargo.toml +++ b/runtime/battery-station/Cargo.toml @@ -111,6 +111,7 @@ zeitgeist-primitives = { workspace = true } zrml-authorized = { workspace = true } zrml-court = { workspace = true } zrml-global-disputes = { workspace = true, optional = true } +zrml-hybrid-router = { workspace = true } zrml-liquidity-mining = { workspace = true } zrml-market-commons = { workspace = true } zrml-neo-swaps = { workspace = true } @@ -211,6 +212,7 @@ runtime-benchmarks = [ "xcm-builder?/runtime-benchmarks", "zrml-authorized/runtime-benchmarks", "zrml-court/runtime-benchmarks", + "zrml-hybrid-router/runtime-benchmarks", "zrml-liquidity-mining/runtime-benchmarks", "zrml-neo-swaps/runtime-benchmarks", "zrml-parimutuel/runtime-benchmarks", @@ -325,6 +327,7 @@ std = [ "zeitgeist-primitives/std", "zrml-authorized/std", "zrml-court/std", + "zrml-hybrid-router/std", "zrml-liquidity-mining/std", "zrml-market-commons/std", "zrml-neo-swaps/std", @@ -381,6 +384,7 @@ try-runtime = [ # Zeitgeist runtime pallets "zrml-authorized/try-runtime", "zrml-court/try-runtime", + "zrml-hybrid-router/try-runtime", "zrml-liquidity-mining/try-runtime", "zrml-market-commons/try-runtime", "zrml-neo-swaps/try-runtime", diff --git a/runtime/battery-station/src/parameters.rs b/runtime/battery-station/src/parameters.rs index a7175708e..4e531ce4b 100644 --- a/runtime/battery-station/src/parameters.rs +++ b/runtime/battery-station/src/parameters.rs @@ -164,6 +164,11 @@ parameter_types! { /// The maximum number of public proposals that can exist at any time. pub const MaxProposals: u32 = 100; + // Hybrid Router parameters + pub const HybridRouterPalletId: PalletId = HYBRID_ROUTER_PALLET_ID; + /// Maximum number of orders that can be placed in a single trade transaction. + pub const MaxOrders: u32 = 100; + // Identity /// The amount held on deposit for a registered identity pub const BasicDeposit: Balance = deposit(1, 258); diff --git a/runtime/common/src/fees.rs b/runtime/common/src/fees.rs index daf99543a..350db0a20 100644 --- a/runtime/common/src/fees.rs +++ b/runtime/common/src/fees.rs @@ -80,7 +80,10 @@ macro_rules! impl_foreign_fees { asset_registry::Inspect as AssetRegistryInspect, }; use pallet_asset_tx_payment::HandleCredit; - use sp_runtime::traits::{Convert, DispatchInfoOf, PostDispatchInfoOf}; + use sp_runtime::{ + traits::{Convert, DispatchInfoOf, PostDispatchInfoOf}, + Perbill, + }; use zeitgeist_primitives::{math::fixed::FixedMul, types::TxPaymentAssetId}; #[repr(u8)] @@ -259,6 +262,10 @@ macro_rules! impl_market_creator_fees { Self::do_distribute(market_id, asset, account, amount) .unwrap_or_else(|_| 0u8.saturated_into()) } + + fn fee_percentage(market_id: Self::MarketId) -> Perbill { + Self::fee_percentage(market_id).unwrap_or(Perbill::zero()) + } } impl MarketCreatorFee { @@ -268,8 +275,8 @@ macro_rules! impl_market_creator_fees { account: &AccountId, amount: Balance, ) -> Result { - let market = MarketCommons::market(&market_id)?; // Should never fail - let fee_amount = market.creator_fee.mul_floor(amount); + let market = MarketCommons::market(&market_id)?; + let fee_amount = Self::fee_percentage(market_id)?.mul_floor(amount); // Might fail if the transaction is too small >::transfer( asset, @@ -279,6 +286,11 @@ macro_rules! impl_market_creator_fees { )?; Ok(fee_amount) } + + fn fee_percentage(market_id: MarketId) -> Result { + let market = MarketCommons::market(&market_id)?; + Ok(market.creator_fee) + } } }; } diff --git a/runtime/common/src/lib.rs b/runtime/common/src/lib.rs index ef51b939b..7c8211aa6 100644 --- a/runtime/common/src/lib.rs +++ b/runtime/common/src/lib.rs @@ -192,6 +192,7 @@ macro_rules! decl_common_types { AuthorizedPalletId::get(), CourtPalletId::get(), GlobalDisputesPalletId::get(), + HybridRouterPalletId::get(), LiquidityMiningPalletId::get(), OrderbookPalletId::get(), ParimutuelPalletId::get(), @@ -315,6 +316,7 @@ macro_rules! create_runtime { NeoSwaps: zrml_neo_swaps::{Call, Event, Pallet, Storage} = 60, Orderbook: zrml_orderbook::{Call, Event, Pallet, Storage} = 61, Parimutuel: zrml_parimutuel::{Call, Event, Pallet, Storage} = 62, + HybridRouter: zrml_hybrid_router::{Call, Event, Pallet, Storage} = 63, $($additional_pallets)* } @@ -1278,6 +1280,21 @@ macro_rules! impl_config_traits { type PalletId = ParimutuelPalletId; type WeightInfo = zrml_parimutuel::weights::WeightInfo; } + + impl zrml_hybrid_router::Config for Runtime { + type AssetManager = AssetManager; + #[cfg(feature = "runtime-benchmarks")] + type AmmPoolDeployer = NeoSwaps; + #[cfg(feature = "runtime-benchmarks")] + type CompleteSetOperations = PredictionMarkets; + type MarketCommons = MarketCommons; + type Amm = NeoSwaps; + type OrderBook = Orderbook; + type MaxOrders = MaxOrders; + type RuntimeEvent = RuntimeEvent; + type PalletId = HybridRouterPalletId; + type WeightInfo = zrml_hybrid_router::weights::WeightInfo; + } }; } @@ -1388,6 +1405,7 @@ macro_rules! create_runtime_api { list_benchmark!(list, extra, zrml_global_disputes, GlobalDisputes); list_benchmark!(list, extra, zrml_orderbook, Orderbook); list_benchmark!(list, extra, zrml_parimutuel, Parimutuel); + list_benchmark!(list, extra, zrml_hybrid_router, HybridRouter); #[cfg(not(feature = "parachain"))] list_benchmark!(list, extra, zrml_prediction_markets, PredictionMarkets); list_benchmark!(list, extra, zrml_liquidity_mining, LiquidityMining); @@ -1492,6 +1510,7 @@ macro_rules! create_runtime_api { add_benchmark!(params, batches, zrml_global_disputes, GlobalDisputes); add_benchmark!(params, batches, zrml_orderbook, Orderbook); add_benchmark!(params, batches, zrml_parimutuel, Parimutuel); + add_benchmark!(params, batches, zrml_hybrid_router, HybridRouter); #[cfg(not(feature = "parachain"))] add_benchmark!(params, batches, zrml_prediction_markets, PredictionMarkets); add_benchmark!(params, batches, zrml_liquidity_mining, LiquidityMining); diff --git a/runtime/zeitgeist/Cargo.toml b/runtime/zeitgeist/Cargo.toml index 59d4edf57..d25c98752 100644 --- a/runtime/zeitgeist/Cargo.toml +++ b/runtime/zeitgeist/Cargo.toml @@ -110,6 +110,7 @@ zeitgeist-primitives = { workspace = true } zrml-authorized = { workspace = true } zrml-court = { workspace = true } zrml-global-disputes = { workspace = true, optional = true } +zrml-hybrid-router = { workspace = true } zrml-liquidity-mining = { workspace = true } zrml-market-commons = { workspace = true } zrml-neo-swaps = { workspace = true } @@ -209,6 +210,7 @@ runtime-benchmarks = [ "xcm-builder?/runtime-benchmarks", "zrml-authorized/runtime-benchmarks", "zrml-court/runtime-benchmarks", + "zrml-hybrid-router/runtime-benchmarks", "zrml-liquidity-mining/runtime-benchmarks", "zrml-neo-swaps/runtime-benchmarks", "zrml-parimutuel/runtime-benchmarks", @@ -315,6 +317,7 @@ std = [ "zeitgeist-primitives/std", "zrml-authorized/std", "zrml-court/std", + "zrml-hybrid-router/std", "zrml-liquidity-mining/std", "zrml-market-commons/std", "zrml-neo-swaps/std", @@ -371,6 +374,7 @@ try-runtime = [ # Zeitgeist runtime pallets "zrml-authorized/try-runtime", "zrml-court/try-runtime", + "zrml-hybrid-router/try-runtime", "zrml-liquidity-mining/try-runtime", "zrml-market-commons/try-runtime", "zrml-neo-swaps/try-runtime", diff --git a/runtime/zeitgeist/src/parameters.rs b/runtime/zeitgeist/src/parameters.rs index d3e171c22..250173ea3 100644 --- a/runtime/zeitgeist/src/parameters.rs +++ b/runtime/zeitgeist/src/parameters.rs @@ -164,6 +164,11 @@ parameter_types! { /// The maximum number of public proposals that can exist at any time. pub const MaxProposals: u32 = 100; + // Hybrid Router parameters + pub const HybridRouterPalletId: PalletId = HYBRID_ROUTER_PALLET_ID; + /// Maximum number of orders that can be placed in a single trade transaction. + pub const MaxOrders: u32 = 100; + // Identity /// The amount held on deposit for a registered identity pub const BasicDeposit: Balance = deposit(1, 258); diff --git a/scripts/benchmarks/configuration.sh b/scripts/benchmarks/configuration.sh index 6d7a293b1..9f13d1edf 100644 --- a/scripts/benchmarks/configuration.sh +++ b/scripts/benchmarks/configuration.sh @@ -27,8 +27,8 @@ export ORML_PALLETS_STEPS="${ORML_PALLETS_STEPS:-50}" export ORML_WEIGHT_TEMPLATE="./misc/orml_weight_template.hbs" export ZEITGEIST_PALLETS=( - zrml_authorized zrml_court zrml_global_disputes zrml_liquidity_mining zrml_neo_swaps \ - zrml_orderbook zrml_parimutuel zrml_prediction_markets zrml_swaps zrml_styx \ + zrml_authorized zrml_court zrml_global_disputes zrml_hybrid_router zrml_liquidity_mining \ + zrml_neo_swaps zrml_orderbook zrml_parimutuel zrml_prediction_markets zrml_swaps zrml_styx \ ) export ZEITGEIST_PALLETS_RUNS="${ZEITGEIST_PALLETS_RUNS:-20}" export ZEITGEIST_PALLETS_STEPS="${ZEITGEIST_PALLETS_STEPS:-50}" diff --git a/zrml/hybrid-router/Cargo.toml b/zrml/hybrid-router/Cargo.toml new file mode 100644 index 000000000..a222f647a --- /dev/null +++ b/zrml/hybrid-router/Cargo.toml @@ -0,0 +1,97 @@ +[dependencies] +frame-benchmarking = { workspace = true, optional = true } +frame-support = { workspace = true } +frame-system = { workspace = true } +orml-traits = { workspace = true } +parity-scale-codec = { workspace = true, features = ["derive", "max-encoded-len"] } +scale-info = { workspace = true, features = ["derive"] } +sp-runtime = { workspace = true } +zeitgeist-primitives = { workspace = true } +zrml-market-commons = { workspace = true } + + +orml-asset-registry = { workspace = true, optional = true } +orml-currencies = { workspace = true, optional = true } +orml-tokens = { workspace = true, optional = true } +pallet-balances = { workspace = true, optional = true } +pallet-randomness-collective-flip = { workspace = true, optional = true } +pallet-timestamp = { workspace = true, optional = true } +pallet-treasury = { workspace = true, optional = true } +pallet-xcm = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +sp-io = { workspace = true, optional = true } +xcm = { workspace = true, optional = true } +xcm-builder = { workspace = true, optional = true } +zrml-authorized = { workspace = true, optional = true } +zrml-court = { workspace = true, optional = true } +zrml-global-disputes = { workspace = true, optional = true } +zrml-liquidity-mining = { workspace = true, optional = true } +zrml-neo-swaps = { workspace = true, optional = true } +zrml-orderbook = { workspace = true, optional = true } +zrml-prediction-markets = { workspace = true, optional = true } +zrml-simple-disputes = { workspace = true, optional = true } +zrml-swaps = { workspace = true, optional = true } + +[dev-dependencies] +env_logger = { workspace = true } +test-case = { workspace = true } +zrml-hybrid-router = { workspace = true, features = ["mock"] } + +[features] +default = ["std"] +mock = [ + "orml-asset-registry/default", + "orml-currencies/default", + "orml-tokens/default", + "pallet-balances/default", + "pallet-randomness-collective-flip/default", + "pallet-timestamp/default", + "pallet-treasury/default", + "pallet-xcm/default", + "serde/default", + "sp-io/default", + "xcm/default", + "zeitgeist-primitives/mock", + "zrml-market-commons/default", + "zrml-neo-swaps/default", + "zrml-orderbook/default", + "zrml-prediction-markets/default", + "zrml-prediction-markets/mock", + "zrml-simple-disputes/default", + "zrml-swaps/default", + "zrml-authorized/default", + "zrml-court/default", + "zrml-global-disputes/default", + "zrml-liquidity-mining/default", +] +parachain = [ + "zrml-prediction-markets/parachain", +] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "xcm-builder/runtime-benchmarks", + "pallet-xcm/runtime-benchmarks", +] +std = [ + "frame-benchmarking?/std", + "frame-support/std", + "frame-system/std", + "orml-traits/std", + "parity-scale-codec/std", + "sp-runtime/std", + "xcm-builder/std", + "pallet-xcm/std", + "zeitgeist-primitives/std", + "zrml-market-commons/std", +] +try-runtime = [ + "frame-support/try-runtime", +] + +[package] +authors = ["Zeitgeist PM "] +edition = "2021" +name = "zrml-hybrid-router" +version = "0.4.3" diff --git a/zrml/hybrid-router/README.md b/zrml/hybrid-router/README.md new file mode 100644 index 000000000..993ef09d7 --- /dev/null +++ b/zrml/hybrid-router/README.md @@ -0,0 +1,46 @@ +# Hybrid Router + +## Overview + +The Hybrid Router pallet provides a mechanism for routing limit orders to either +an automated market maker or an order book. The decision is made based on which +option would result in the most favourable execution price for the order. + +### Terminology + +- **Limit Order**: An order to buy or sell a certain quantity of an asset at a + specified price or better. +- **Automated Market Maker (AMM)**: A type of decentralized exchange protocol + that relies on a mathematical formula to price assets. +- **Order Book**: A list of buy and sell orders for a specific asset, organized + by price level. +- **Strategy**: The strategy used when placing an order in a trading + environment. Two strategies are supported: `ImmediateOrCancel` and + `LimitOrder`. +- **TxType**: The type of transaction, either `Buy` or `Sell`. + +### Features + +- **Order Routing**: Routes orders to the most favourable execution venue. +- **Limit Order Support**: Supports the creation and execution of limit orders. +- **Integration**: Seamlessly integrates with both AMMs and order books. +- **Buy and Sell Orders**: Supports both buy and sell orders with a strategy to + handle the remaining order when the price limit is reached. +- **Strategies**: Supports two strategies when placing an order: + `ImmediateOrCancel` and `LimitOrder`. + +### Usage + +The Hybrid Router pallet provides two main functions: `buy` and `sell`. Both +functions take the following parameters: + +- `market_id`: The ID of the market to buy from or sell on. +- `asset_count`: The number of assets traded on the market. +- `asset`: The asset to buy or sell. +- `amount_in`: The amount of the market's base asset to sell or the amount of + `asset` to sell. +- `max_price` or `min_price`: The maximum price to buy at or the minimum price + to sell at. +- `orders`: A list of orders from the book to use. +- `strategy`: The strategy to handle the remaining order when the `max_price` or + `min_price` is reached. diff --git a/zrml/hybrid-router/src/benchmarking.rs b/zrml/hybrid-router/src/benchmarking.rs new file mode 100644 index 000000000..79faf80d0 --- /dev/null +++ b/zrml/hybrid-router/src/benchmarking.rs @@ -0,0 +1,258 @@ +// Copyright 2024 Forecasting Technologies LTD. +// +// This file is part of Zeitgeist. +// +// Zeitgeist 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. +// +// Zeitgeist 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. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +#![allow( + // Auto-generated code is a no man's land + clippy::arithmetic_side_effects +)] +#![cfg(feature = "runtime-benchmarks")] + +#[cfg(test)] +use crate::Pallet as HybridRouter; + +use crate::*; +use frame_benchmarking::v2::*; +use frame_support::{ + assert_ok, + storage::{with_transaction, TransactionOutcome::*}, +}; +use frame_system::RawOrigin; +use orml_traits::MultiCurrency; +use sp_runtime::{Perbill, SaturatedConversion}; +use types::Strategy; +use zeitgeist_primitives::{ + constants::{base_multiples::*, CENT}, + math::fixed::{BaseProvider, FixedDiv, ZeitgeistBase}, + traits::{CompleteSetOperationsApi, DeployPoolApi, HybridRouterOrderbookApi}, + types::{Asset, Market, MarketCreation, MarketPeriod, MarketStatus, MarketType, ScoringRule}, +}; +use zrml_market_commons::MarketCommonsPalletApi; + +// Same behavior as `assert_ok!`, except that it wraps the call inside a transaction layer. Required +// when calling into functions marked `require_transactional` to avoid a `Transactional(NoLayer)` +// error. +macro_rules! assert_ok_with_transaction { + ($expr:expr) => {{ + assert_ok!(with_transaction(|| match $expr { + Ok(val) => Commit(Ok(val)), + Err(err) => Rollback(Err(err)), + })); + }}; +} + +fn create_spot_prices(asset_count: u16) -> Vec> { + let base = ZeitgeistBase::::get().unwrap(); + let amount = base / asset_count as u128; + let remainder = (base % asset_count as u128).saturated_into::>(); + + let mut amounts = vec![amount.saturated_into::>(); asset_count as usize]; + amounts[0] += remainder; + + amounts +} + +fn create_market(caller: T::AccountId, base_asset: AssetOf, asset_count: u16) -> MarketIdOf +where + T: Config, +{ + let market = Market { + base_asset, + creation: MarketCreation::Permissionless, + creator_fee: Perbill::zero(), + creator: caller.clone(), + oracle: caller, + metadata: vec![0, 50], + market_type: MarketType::Categorical(asset_count), + period: MarketPeriod::Block(0u32.into()..1u32.into()), + deadlines: Default::default(), + scoring_rule: ScoringRule::AmmCdaHybrid, + status: MarketStatus::Active, + report: None, + resolved_outcome: None, + dispute_mechanism: None, + bonds: Default::default(), + early_close: None, + }; + let maybe_market_id = T::MarketCommons::push_market(market); + maybe_market_id.unwrap() +} + +fn create_market_and_deploy_pool( + caller: T::AccountId, + base_asset: AssetOf, + asset_count: u16, + amount: BalanceOf, +) -> MarketIdOf +where + T: Config, +{ + let market_id = create_market::(caller.clone(), base_asset, asset_count); + let total_cost = amount + T::AssetManager::minimum_balance(base_asset); + assert_ok!(T::AssetManager::deposit(base_asset, &caller, total_cost)); + assert_ok_with_transaction!(T::CompleteSetOperations::buy_complete_set( + caller.clone(), + market_id, + amount + )); + assert_ok_with_transaction!(T::AmmPoolDeployer::deploy_pool( + caller, + market_id, + amount, + create_spot_prices::(asset_count), + CENT.saturated_into(), + )); + market_id +} + +#[benchmarks] +mod benchmarks { + use super::*; + + #[benchmark] + fn buy(n: Linear<2, 16>, o: Linear<0, 10>) { + let buyer: T::AccountId = whitelisted_caller(); + let base_asset = Asset::Ztg; + let asset_count = n.try_into().unwrap(); + let market_id = create_market_and_deploy_pool::( + buyer.clone(), + base_asset, + asset_count, + _100.saturated_into(), + ); + + let asset = Asset::CategoricalOutcome(market_id, 0u16); + let amount_in = _1000.saturated_into(); + assert_ok!(T::AssetManager::deposit(base_asset, &buyer, amount_in)); + + let spot_prices = create_spot_prices::(asset_count); + let first_spot_price = spot_prices[0]; + + let max_price = _9_10.saturated_into(); + let orders = (0u128..o as u128).collect::>(); + let maker_asset = asset; + let maker_amount = _20.saturated_into(); + let taker_asset = base_asset; + let taker_amount: BalanceOf = _11.saturated_into(); + assert!(taker_amount.bdiv_floor(maker_amount).unwrap() > first_spot_price); + for (i, order_id) in orders.iter().enumerate() { + let order_creator: T::AccountId = account("order_creator", *order_id as u32, 0); + let surplus = ((i + 1) as u128) * _1_2; + let taker_amount = taker_amount + surplus.saturated_into::>(); + assert_ok!(T::AssetManager::deposit(maker_asset, &order_creator, maker_amount)); + assert_ok!(T::OrderBook::place_order( + order_creator, + market_id, + maker_asset, + maker_amount, + taker_asset, + taker_amount, + )); + } + let strategy = Strategy::LimitOrder; + + #[extrinsic_call] + buy( + RawOrigin::Signed(buyer.clone()), + market_id, + asset_count, + asset, + amount_in, + max_price, + orders, + strategy, + ); + + let buyer_limit_order = T::OrderBook::order(o as u128).unwrap(); + assert_eq!(buyer_limit_order.market_id, market_id); + assert_eq!(buyer_limit_order.maker, buyer); + assert_eq!(buyer_limit_order.maker_asset, base_asset); + assert_eq!(buyer_limit_order.taker_asset, asset); + } + + #[benchmark] + fn sell(n: Linear<2, 10>, o: Linear<0, 10>) { + let seller: T::AccountId = whitelisted_caller(); + let base_asset = Asset::Ztg; + let asset_count = n.try_into().unwrap(); + let market_id = create_market_and_deploy_pool::( + seller.clone(), + base_asset, + asset_count, + _100.saturated_into(), + ); + + let asset = Asset::CategoricalOutcome(market_id, 0u16); + let amount_in = (_1000 * 100).saturated_into(); + assert_ok!(T::AssetManager::deposit(asset, &seller, amount_in)); + // seller base asset amount needs to exist, + // otherwise repatriate_reserved_named from order book fails + // with DeadAccount for base asset repatriate to seller beneficiary + let min_balance = T::AssetManager::minimum_balance(base_asset); + assert_ok!(T::AssetManager::deposit(base_asset, &seller, min_balance)); + + let spot_prices = create_spot_prices::(asset_count); + let first_spot_price = spot_prices[0]; + + let min_price = _1_100.saturated_into(); + let orders = (0u128..o as u128).collect::>(); + let maker_asset = base_asset; + let maker_amount: BalanceOf = _9.saturated_into(); + let taker_asset = asset; + let taker_amount = _100.saturated_into(); + assert!(maker_amount.bdiv_floor(taker_amount).unwrap() < first_spot_price); + for (i, order_id) in orders.iter().enumerate() { + let order_creator: T::AccountId = account("order_creator", *order_id as u32, 0); + let surplus = ((i + 1) as u128) * _1_2; + let taker_amount = taker_amount + surplus.saturated_into::>(); + assert_ok!(T::AssetManager::deposit(maker_asset, &order_creator, maker_amount)); + T::OrderBook::place_order( + order_creator, + market_id, + maker_asset, + maker_amount, + taker_asset, + taker_amount, + ) + .unwrap(); + } + let strategy = Strategy::LimitOrder; + + #[extrinsic_call] + sell( + RawOrigin::Signed(seller.clone()), + market_id, + asset_count, + asset, + amount_in, + min_price, + orders, + strategy, + ); + + let seller_limit_order = T::OrderBook::order(o as u128).unwrap(); + assert_eq!(seller_limit_order.market_id, market_id); + assert_eq!(seller_limit_order.maker, seller); + assert_eq!(seller_limit_order.maker_asset, asset); + assert_eq!(seller_limit_order.taker_asset, base_asset); + } + + impl_benchmark_test_suite!( + HybridRouter, + crate::mock::ExtBuilder::default().build(), + crate::mock::Runtime + ); +} diff --git a/zrml/hybrid-router/src/lib.rs b/zrml/hybrid-router/src/lib.rs new file mode 100644 index 000000000..06734544c --- /dev/null +++ b/zrml/hybrid-router/src/lib.rs @@ -0,0 +1,712 @@ +// Copyright 2024 Forecasting Technologies LTD. +// +// This file is part of Zeitgeist. +// +// Zeitgeist 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. +// +// Zeitgeist 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. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +#![doc = include_str!("../README.md")] +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; +#[cfg(test)] +mod mock; +mod tests; +mod types; +mod utils; +pub mod weights; + +pub use pallet::*; + +#[frame_support::pallet] +mod pallet { + use crate::{ + types::{Strategy, Trade, TradeEventInfo, TxType}, + weights::WeightInfoZeitgeist, + }; + use alloc::{vec, vec::Vec}; + use core::marker::PhantomData; + use frame_support::{ + ensure, + pallet_prelude::DispatchError, + require_transactional, + traits::{IsType, StorageVersion}, + PalletId, + }; + use frame_system::{ + ensure_signed, + pallet_prelude::{BlockNumberFor, OriginFor}, + }; + use orml_traits::MultiCurrency; + use sp_runtime::{ + traits::{Get, Zero}, + DispatchResult, + }; + #[cfg(feature = "runtime-benchmarks")] + use zeitgeist_primitives::traits::{CompleteSetOperationsApi, DeployPoolApi}; + use zeitgeist_primitives::{ + hybrid_router_api_types::{AmmTrade, OrderbookTrade}, + math::{ + checked_ops_res::CheckedSubRes, + fixed::{BaseProvider, FixedDiv, FixedMul, ZeitgeistBase}, + }, + orderbook::{Order, OrderId}, + traits::{HybridRouterAmmApi, HybridRouterOrderbookApi}, + types::{Asset, Market, MarketType, ScalarPosition}, + }; + use zrml_market_commons::MarketCommonsPalletApi; + + #[pallet::config] + pub trait Config: frame_system::Config { + /// The API to handle different asset classes. + type AssetManager: MultiCurrency>; + + #[cfg(feature = "runtime-benchmarks")] + type AmmPoolDeployer: DeployPoolApi< + AccountId = AccountIdOf, + Balance = BalanceOf, + MarketId = MarketIdOf, + >; + + #[cfg(feature = "runtime-benchmarks")] + type CompleteSetOperations: CompleteSetOperationsApi< + AccountId = AccountIdOf, + Balance = BalanceOf, + MarketId = MarketIdOf, + >; + + /// The identifier of individual markets. + type MarketCommons: MarketCommonsPalletApi< + AccountId = Self::AccountId, + BlockNumber = Self::BlockNumber, + Balance = BalanceOf, + >; + + /// The API to handle the Automated Market Maker (AMM). + type Amm: HybridRouterAmmApi< + AccountId = AccountIdOf, + MarketId = MarketIdOf, + Asset = AssetOf, + Balance = BalanceOf, + >; + + /// The maximum number of orders that can be used to execute a trade. + #[pallet::constant] + type MaxOrders: Get; + + /// The API to handle the order book. + type OrderBook: HybridRouterOrderbookApi< + AccountId = AccountIdOf, + MarketId = MarketIdOf, + Balance = BalanceOf, + Asset = AssetOf, + Order = OrderOf, + OrderId = OrderId, + >; + + type PalletId: Get; + + /// The event type for this pallet. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + + /// Weights generated by benchmarks. + type WeightInfo: WeightInfoZeitgeist; + } + + /// The current storage version. + const STORAGE_VERSION: StorageVersion = StorageVersion::new(0); + + pub(crate) type AssetOf = Asset>; + pub(crate) type AccountIdOf = ::AccountId; + pub(crate) type BalanceOf = + <::AssetManager as MultiCurrency>>::Balance; + pub(crate) type OrderOf = Order, BalanceOf, MarketIdOf>; + pub(crate) type MarketIdOf = + <::MarketCommons as MarketCommonsPalletApi>::MarketId; + pub(crate) type MomentOf = <::MarketCommons as MarketCommonsPalletApi>::Moment; + pub(crate) type MarketOf = + Market, BalanceOf, BlockNumberFor, MomentOf, Asset>>; + pub(crate) type AmmTradeOf = AmmTrade>; + pub(crate) type OrderTradeOf = OrderbookTrade, BalanceOf>; + + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] + pub struct Pallet(PhantomData); + + #[pallet::event] + #[pallet::generate_deposit(pub(crate) fn deposit_event)] + pub enum Event + where + T: Config, + { + /// A trade was executed. + HybridRouterExecuted { + /// The type of transaction (Buy or Sell). + tx_type: TxType, + /// The account ID of the user performing the trade. + who: AccountIdOf, + /// The ID of the market. + market_id: MarketIdOf, + /// The maximum price limit for buying or the minimum price limit for selling. + price_limit: BalanceOf, + /// The asset provided by the trader. + asset_in: AssetOf, + /// The amount of the `asset_in` provided by the trader. + /// It includes swap and external fees. + /// It is an amount before fees are deducted. + amount_in: BalanceOf, + /// The asset received by the trader. + asset_out: AssetOf, + /// The aggregated amount of the `asset_out` already received + /// by the trader from AMM and orderbook. + /// It is an amount after fees are deducted. + amount_out: BalanceOf, + /// The external fee amount paid in the base asset. + external_fee_amount: BalanceOf, + /// The swap fee amount paid in the base asset. + swap_fee_amount: BalanceOf, + }, + } + + #[pallet::error] + pub enum Error { + /// The specified amount is zero. + AmountIsZero, + /// The price limit is too high. + PriceLimitTooHigh, + /// The price of an order is above the specified maximum price. + OrderPriceAboveMaxPrice, + /// The price of an order is below the specified minimum price. + OrderPriceBelowMinPrice, + /// The asset of an order is not equal to the maker asset of the order book. + AssetNotEqualToOrderBookMakerAsset, + /// The asset of an order is not equal to the taker asset of the order book. + AssetNotEqualToOrderBookTakerAsset, + /// The strategy "immediate or cancel" was applied. + CancelStrategyApplied, + /// The asset count does not match the markets asset count. + AssetCountMismatch, + /// The maximum number of orders was exceeded. + MaxOrdersExceeded, + } + + #[pallet::call] + impl Pallet { + /// Routes a buy order to AMM and CDA to achieve the best average execution price. + /// + /// # Parameters + /// + /// * `market_id`: The ID of the market to buy from. + /// * `asset_count`: The number of assets traded on the market. + /// * `asset`: The asset to buy. + /// * `amount_in`: The amount of the market's base asset to sell. + /// * `max_price`: The maximum price to buy at. + /// * `orders`: A list of orders from the book to use. + /// * `strategy`: The strategy to handle the remaining order when the `max_price` is reached. + /// + /// The elements of `orders` are the orders that the router may use to execute the order. If any of + /// these orders are already filled, they are ignored. It is not necessary for the router to use all + /// specified orders. The smaller the vector, the larger the risk that the AMM is used to fill large + /// chunks of the order. + /// + /// The `orders` vector **must** be sorted in ascending order by the price of their associated + /// orders. Failing this, the behavior of `buy` is undefined. + /// + /// If the maximum price is reached before the entire buy order is filled, the `strategy` parameter + /// decides if the order is rolled back (`Strategy::ImmediateOrCancel`) or if a limit order for the + /// remaining amount is placed (`Strategy::LimitOrder`). + /// + /// Complexity: `O(n)` + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::buy(*asset_count as u32, orders.len() as u32))] + #[frame_support::transactional] + pub fn buy( + origin: OriginFor, + market_id: MarketIdOf, + #[pallet::compact] asset_count: u16, + asset: AssetOf, + #[pallet::compact] amount_in: BalanceOf, + #[pallet::compact] max_price: BalanceOf, + orders: Vec, + strategy: Strategy, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + Self::do_trade( + TxType::Buy, + who, + market_id, + asset_count, + asset, + amount_in, + max_price, + orders, + strategy, + )?; + + Ok(()) + } + + /// Routes a sell order to AMM and CDA to achieve the best average execution price. + /// + /// # Parameters + /// + /// * `market_id`: The ID of the market to sell on. + /// * `asset_count`: The number of assets traded on the market. + /// * `asset`: The asset to sell. + /// * `amount_in`: The amount of `asset` to sell. + /// * `min_price`: The minimum price to sell at. + /// * `orders`: A list of orders from the book to use. + /// * `strategy`: The strategy to handle the remaining order when the `min_price` is reached. + /// + /// The elements of `orders` are the orders that the router may use to execute the order. If any of + /// these orders are already filled, they are ignored. It is not necessary for the router to use all + /// specified orders. The smaller the vector, the larger the risk that the AMM is used to fill large + /// chunks of the order. + /// + /// The `orders` vector **must** be sorted in ascending order by the price of their associated + /// orders. Failing this, the behavior of `sell` is undefined. + /// + /// If the maximum price is reached before the entire buy order is filled, the `strategy` parameter + /// decides if the order is rolled back (`Strategy::ImmediateOrCancel`) or if a limit order for the + /// remaining amount is placed (`Strategy::LimitOrder`). + /// + /// Complexity: `O(n)` + #[pallet::call_index(1)] + #[pallet::weight(T::WeightInfo::sell(*asset_count as u32, orders.len() as u32))] + #[frame_support::transactional] + pub fn sell( + origin: OriginFor, + market_id: MarketIdOf, + #[pallet::compact] asset_count: u16, + asset: AssetOf, + #[pallet::compact] amount_in: BalanceOf, + #[pallet::compact] min_price: BalanceOf, + orders: Vec, + strategy: Strategy, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + Self::do_trade( + TxType::Sell, + who, + market_id, + asset_count, + asset, + amount_in, + min_price, + orders, + strategy, + )?; + + Ok(()) + } + } + + impl Pallet + where + T: Config, + { + /// Returns a vector of assets corresponding to the given market ID and market type. + /// For scalar outcomes, the returned vector is [LONG, SHORT]. + /// For categorical outcomes, + /// the vector starts with the lowest and ends with the highest categorical outcome. + /// + /// # Arguments + /// + /// * `market_id` - The ID of the market. + /// * `market` - A reference to the market. + pub fn outcome_assets(market_id: MarketIdOf, market: &MarketOf) -> Vec> { + match market.market_type { + MarketType::Categorical(categories) => { + let mut assets = Vec::new(); + for i in 0..categories { + assets.push(Asset::CategoricalOutcome(market_id, i)); + } + assets + } + MarketType::Scalar(_) => { + vec![ + Asset::ScalarOutcome(market_id, ScalarPosition::Long), + Asset::ScalarOutcome(market_id, ScalarPosition::Short), + ] + } + } + } + + /// Fills the order from the Automated Market Maker (AMM) if it exists. + /// The order is filled until the `price_limit` is reached. + /// + /// # Arguments + /// + /// * `tx_type` - The type of transaction (Buy or Sell). + /// * `who` - The account ID of the user performing the transaction. + /// * `market_id` - The ID of the market. + /// * `asset` - The asset to be traded. + /// * `amount_in` - The amount to be traded. + /// * `price_limit` - The maximum or minimum price at which the trade can be executed. + fn maybe_fill_from_amm( + tx_type: TxType, + who: &AccountIdOf, + market_id: MarketIdOf, + asset: AssetOf, + amount_in: BalanceOf, + price_limit: BalanceOf, + ) -> Result<(BalanceOf, Option>), DispatchError> { + if !T::Amm::pool_exists(market_id) { + return Ok((amount_in, None)); + } + + let spot_price = T::Amm::get_spot_price(market_id, asset)?; + + let amm_amount_in = match tx_type { + TxType::Buy => { + if spot_price >= price_limit { + return Ok((amount_in, None)); + } + T::Amm::calculate_buy_amount_until(market_id, asset, price_limit)? + } + TxType::Sell => { + if spot_price <= price_limit { + return Ok((amount_in, None)); + } + T::Amm::calculate_sell_amount_until(market_id, asset, price_limit)? + } + }; + + let amm_amount_in = amm_amount_in.min(amount_in); + + if amm_amount_in.is_zero() { + return Ok((amount_in, None)); + } + + let amm_trade_info = match tx_type { + TxType::Buy => T::Amm::buy( + who.clone(), + market_id, + asset, + amm_amount_in, + BalanceOf::::zero(), + )?, + TxType::Sell => T::Amm::sell( + who.clone(), + market_id, + asset, + amm_amount_in, + BalanceOf::::zero(), + )?, + }; + + Ok((amount_in.checked_sub_res(&amm_amount_in)?, Some(amm_trade_info))) + } + + /// Fills the order from the order book if it exists and meets the price conditions. + /// If the order is partially filled, the remaining amount is returned. + /// + /// # Arguments + /// + /// * `tx_type` - The type of transaction (Buy or Sell). + /// * `orders` - A list of orders from the order book. + /// * `remaining` - The amount to be traded. + /// * `who` - The account ID of the user performing the transaction. + /// * `market_id` - The ID of the market. + /// * `base_asset` - The base asset of the market. + /// * `asset` - The asset to be traded. + /// * `price_limit` - The maximum or minimum price at which the trade can be executed. + fn maybe_fill_orders( + tx_type: TxType, + orders: &[OrderId], + mut remaining: BalanceOf, + who: &AccountIdOf, + market_id: MarketIdOf, + base_asset: AssetOf, + asset: AssetOf, + price_limit: BalanceOf, + ) -> Result<(BalanceOf, Vec>, Vec>), DispatchError> + { + let mut amm_trades = Vec::new(); + let mut order_trades = Vec::new(); + for &order_id in orders { + if remaining.is_zero() { + break; + } + + let order = match T::OrderBook::order(order_id) { + Ok(order) => order, + Err(_) => continue, + }; + + let order_price = order.price(base_asset)?; + + match tx_type { + TxType::Buy => { + // existing order is willing to give the required `asset` as the `maker_asset` + ensure!( + asset == order.maker_asset, + Error::::AssetNotEqualToOrderBookMakerAsset + ); + ensure!(order_price <= price_limit, Error::::OrderPriceAboveMaxPrice); + } + TxType::Sell => { + // existing order is willing to receive the required `asset` as the `taker_asset` + ensure!( + asset == order.taker_asset, + Error::::AssetNotEqualToOrderBookTakerAsset + ); + ensure!(order_price >= price_limit, Error::::OrderPriceBelowMinPrice); + } + } + + let amm_trade_info = Self::maybe_fill_from_amm( + tx_type, + who, + market_id, + asset, + remaining, + order_price, + )?; + + if let Some(t) = amm_trade_info.1 { + amm_trades.push(t); + } + remaining = amm_trade_info.0; + + if remaining.is_zero() { + break; + } + + // `remaining` is always denominated in the `taker_asset` + // because this is what the order owner (maker) wants to receive + let (_taker_fill, maker_fill) = + order.taker_and_maker_fill_from_taker_amount(remaining)?; + // and the `maker_partial_fill` of `fill_order` is specified in `taker_asset` + let order_trade = + T::OrderBook::fill_order(who.clone(), order_id, Some(maker_fill))?; + order_trades.push(order_trade); + // `maker_fill` is the amount the order owner (maker) wants to receive + remaining = remaining.checked_sub_res(&maker_fill)?; + } + + Ok((remaining, amm_trades, order_trades)) + } + + /// Places a limit order if the strategy is `Strategy::LimitOrder`. + /// If the strategy is `Strategy::ImmediateOrCancel`, an error is returned. + /// + /// # Arguments + /// + /// * `strategy` - The strategy to handle the remaining non-zero amount when the `max_price` is reached. + /// * `who` - The account ID of the user performing the transaction. + /// * `market_id` - The ID of the market. + /// * `maker_asset` - The asset to provide. + /// * `maker_amount` - The amount of the `maker_asset` to be provided. + /// * `taker_asset` - The asset to be received. + /// * `taker_amount` - The amount of the `taker_asset` to be received. + fn maybe_place_limit_order( + strategy: Strategy, + who: &AccountIdOf, + market_id: MarketIdOf, + maker_asset: AssetOf, + maker_amount: BalanceOf, + taker_asset: AssetOf, + taker_amount: BalanceOf, + ) -> DispatchResult { + match strategy { + Strategy::ImmediateOrCancel => { + return Err(Error::::CancelStrategyApplied.into()); + } + Strategy::LimitOrder => { + T::OrderBook::place_order( + who.clone(), + market_id, + maker_asset, + maker_amount, + taker_asset, + taker_amount, + )?; + } + } + + Ok(()) + } + + /// Executes a trade by routing the order to the Automated Market Maker (AMM) and the Order Book + /// to achieve the best average execution price. + /// + /// # Arguments + /// + /// * `tx_type` - The type of transaction (Buy or Sell). + /// * `who` - The account ID of the user performing the transaction. + /// * `market_id` - The ID of the market. + /// * `asset_count` - The number of assets traded on the market. + /// * `asset` - The asset to be traded. + /// * `amount_in` - The amount to be traded. + /// * `price_limit` - The maximum or minimum price at which the trade can be executed. + /// * `orders` - A list of orders from the order book. + /// * `strategy` - The strategy to handle the remaining non-zero amount when the `max_price` is reached. + #[require_transactional] + pub(crate) fn do_trade( + tx_type: TxType, + who: AccountIdOf, + market_id: MarketIdOf, + asset_count: u16, + asset: AssetOf, + amount_in: BalanceOf, + price_limit: BalanceOf, + orders: Vec, + strategy: Strategy, + ) -> DispatchResult { + ensure!(amount_in > BalanceOf::::zero(), Error::::AmountIsZero); + ensure!( + price_limit <= ZeitgeistBase::>::get()?, + Error::::PriceLimitTooHigh + ); + ensure!(orders.len() as u32 <= T::MaxOrders::get(), Error::::MaxOrdersExceeded); + let market = T::MarketCommons::market(&market_id)?; + let assets = Self::outcome_assets(market_id, &market); + ensure!(asset_count as usize == assets.len(), Error::::AssetCountMismatch); + + let (asset_in, asset_out) = match tx_type { + TxType::Buy => (market.base_asset, asset), + TxType::Sell => (asset, market.base_asset), + }; + T::AssetManager::ensure_can_withdraw(asset_in, &who, amount_in)?; + + let mut amm_trades: Vec> = Vec::new(); + let mut remaining = amount_in; + + let order_amm_trades_info = Self::maybe_fill_orders( + tx_type, + &orders, + remaining, + &who, + market_id, + market.base_asset, + asset, + price_limit, + )?; + + remaining = order_amm_trades_info.0; + amm_trades.extend(order_amm_trades_info.1); + let orderbook_trades = order_amm_trades_info.2; + + if !remaining.is_zero() { + let amm_trade_info = Self::maybe_fill_from_amm( + tx_type, + &who, + market_id, + asset, + remaining, + price_limit, + )?; + + amm_trades.extend(amm_trade_info.1); + remaining = amm_trade_info.0; + } + + if !remaining.is_zero() { + let (maker_asset, maker_amount, taker_asset, taker_amount) = match tx_type { + TxType::Buy => { + let maker_asset = market.base_asset; + let maker_amount = remaining; + let taker_asset = asset; + let taker_amount = remaining.bdiv_ceil(price_limit)?; + (maker_asset, maker_amount, taker_asset, taker_amount) + } + TxType::Sell => { + let maker_asset = asset; + let maker_amount = remaining; + let taker_asset = market.base_asset; + let taker_amount = price_limit.bmul_floor(remaining)?; + (maker_asset, maker_amount, taker_asset, taker_amount) + } + }; + + Self::maybe_place_limit_order( + strategy, + &who, + market_id, + maker_asset, + maker_amount, + taker_asset, + taker_amount, + )?; + } + + let TradeEventInfo { amount_out, external_fee_amount, swap_fee_amount } = + Self::get_event_info(&who, &orderbook_trades, &amm_trades)?; + + Self::deposit_event(Event::HybridRouterExecuted { + tx_type, + who, + market_id, + price_limit, + asset_in, + amount_in, + asset_out, + amount_out, + external_fee_amount, + swap_fee_amount, + }); + + Ok(()) + } + + fn get_event_info( + who: &AccountIdOf, + orderbook_trades: &[OrderTradeOf], + amm_trades: &[AmmTradeOf], + ) -> Result, DispatchError> { + orderbook_trades + .iter() + .map(|trade| Trade::::Orderbook(trade)) + .chain(amm_trades.iter().map(|trade| Trade::Amm(*trade))) + .try_fold(TradeEventInfo::::new(), |event_info: TradeEventInfo, trade| { + Self::update_event_info(who, trade, event_info) + }) + } + + fn update_event_info( + who: &AccountIdOf, + trade: Trade, + mut event_info: TradeEventInfo, + ) -> Result, DispatchError> { + match trade { + Trade::Orderbook(orderbook_trade) => { + let external_fee_amount = if &orderbook_trade.external_fee.account == who { + orderbook_trade.external_fee.amount + } else { + BalanceOf::::zero() + }; + event_info.add_amount_out_minus_fees(TradeEventInfo:: { + amount_out: orderbook_trade.filled_maker_amount, + external_fee_amount, + swap_fee_amount: BalanceOf::::zero(), + })?; + } + Trade::Amm(amm_trade) => { + event_info.add_amount_out_and_fees(TradeEventInfo:: { + amount_out: amm_trade.amount_out, + external_fee_amount: amm_trade.external_fee_amount, + swap_fee_amount: amm_trade.swap_fee_amount, + })?; + } + } + + Ok(event_info) + } + } +} diff --git a/zrml/hybrid-router/src/mock.rs b/zrml/hybrid-router/src/mock.rs new file mode 100644 index 000000000..e0f785d22 --- /dev/null +++ b/zrml/hybrid-router/src/mock.rs @@ -0,0 +1,502 @@ +// Copyright 2024 Forecasting Technologies LTD. +// +// This file is part of Zeitgeist. +// +// Zeitgeist 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. +// +// Zeitgeist 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. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +#![cfg(feature = "mock")] +#![allow( + // Mocks are only used for fuzzing and unit tests + clippy::arithmetic_side_effects, + clippy::too_many_arguments, +)] + +use crate as zrml_hybrid_router; +use crate::{AssetOf, BalanceOf, MarketIdOf}; +use core::marker::PhantomData; +use frame_support::{ + construct_runtime, ord_parameter_types, parameter_types, + traits::{Contains, Everything, NeverEnsureOrigin}, +}; +use frame_system::{EnsureRoot, EnsureSignedBy}; +#[cfg(feature = "parachain")] +use orml_asset_registry::AssetMetadata; +use orml_traits::MultiCurrency; +use sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, Get, IdentityLookup, Zero}, + Perbill, Percent, SaturatedConversion, +}; +#[cfg(feature = "parachain")] +use zeitgeist_primitives::types::Asset; +use zeitgeist_primitives::{ + constants::mock::{ + AddOutcomePeriod, AggregationPeriod, AppealBond, AppealPeriod, AuthorizedPalletId, + BlockHashCount, BlocksPerYear, CloseEarlyBlockPeriod, CloseEarlyDisputeBond, + CloseEarlyProtectionBlockPeriod, CloseEarlyProtectionTimeFramePeriod, + CloseEarlyRequestBond, CloseEarlyTimeFramePeriod, CorrectionPeriod, CourtPalletId, + ExistentialDeposit, ExistentialDeposits, GdVotingPeriod, GetNativeCurrencyId, + GlobalDisputeLockId, GlobalDisputesPalletId, HybridRouterPalletId, InflationPeriod, + LiquidityMiningPalletId, LockId, MaxAppeals, MaxApprovals, MaxCourtParticipants, + MaxCreatorFee, MaxDelegations, MaxDisputeDuration, MaxDisputes, MaxEditReasonLen, + MaxGlobalDisputeVotes, MaxGracePeriod, MaxLiquidityTreeDepth, MaxLocks, MaxMarketLifetime, + MaxOracleDuration, MaxOrders, MaxOwners, MaxRejectReasonLen, MaxReserves, MaxSelectedDraws, + MaxYearlyInflation, MinCategories, MinDisputeDuration, MinJurorStake, MinOracleDuration, + MinOutcomeVoteAmount, MinimumPeriod, NeoMaxSwapFee, NeoSwapsPalletId, OrderbookPalletId, + OutcomeBond, OutcomeFactor, OutsiderBond, PmPalletId, RemoveKeysLimit, RequestInterval, + SimpleDisputesPalletId, TreasuryPalletId, VotePeriod, VotingOutcomeFee, BASE, CENT, + MAX_ASSETS, + }, + traits::DistributeFees, + types::{ + AccountIdTest, Amount, Balance, BasicCurrencyAdapter, BlockNumber, BlockTest, CurrencyId, + Hash, Index, MarketId, Moment, UncheckedExtrinsicTest, + }, +}; + +pub const ALICE: AccountIdTest = 0; +#[allow(unused)] +pub const BOB: AccountIdTest = 1; +pub const CHARLIE: AccountIdTest = 2; +pub const DAVE: AccountIdTest = 3; +pub const EVE: AccountIdTest = 4; +pub const FEE_ACCOUNT: AccountIdTest = 5; +pub const SUDO: AccountIdTest = 123456; +pub const EXTERNAL_FEES: Balance = CENT; +pub const INITIAL_BALANCE: Balance = 100 * BASE; +pub const MARKET_CREATOR: AccountIdTest = ALICE; + +#[cfg(feature = "parachain")] +pub const FOREIGN_ASSET: Asset = Asset::ForeignAsset(1); + +parameter_types! { + pub const FeeAccount: AccountIdTest = FEE_ACCOUNT; +} +ord_parameter_types! { + pub const AuthorizedDisputeResolutionUser: AccountIdTest = ALICE; +} +ord_parameter_types! { + pub const Sudo: AccountIdTest = SUDO; +} +parameter_types! { + pub storage NeoMinSwapFee: Balance = 0; +} +parameter_types! { + pub const AdvisoryBond: Balance = 0; + pub const AdvisoryBondSlashPercentage: Percent = Percent::from_percent(10); + pub const OracleBond: Balance = 0; + pub const ValidityBond: Balance = 0; + pub const DisputeBond: Balance = 0; + pub const MaxCategories: u16 = MAX_ASSETS + 1; +} + +pub fn fee_percentage() -> Perbill { + Perbill::from_rational(EXTERNAL_FEES, BASE) +} + +pub fn calculate_fee(amount: BalanceOf) -> BalanceOf { + fee_percentage().mul_floor(amount.saturated_into::>()) +} + +pub struct ExternalFees(PhantomData, PhantomData); + +impl DistributeFees for ExternalFees +where + F: Get, +{ + type Asset = AssetOf; + type AccountId = T::AccountId; + type Balance = BalanceOf; + type MarketId = MarketIdOf; + + fn distribute( + _market_id: Self::MarketId, + asset: Self::Asset, + account: &Self::AccountId, + amount: Self::Balance, + ) -> Self::Balance { + let fees = calculate_fee::(amount); + match T::AssetManager::transfer(asset, account, &F::get(), fees) { + Ok(_) => fees, + Err(_) => Zero::zero(), + } + } + + fn fee_percentage(_market_id: Self::MarketId) -> Perbill { + fee_percentage() + } +} + +pub type UncheckedExtrinsic = UncheckedExtrinsicTest; + +pub struct DustRemovalWhitelist; + +impl Contains for DustRemovalWhitelist { + fn contains(account_id: &AccountIdTest) -> bool { + *account_id == FEE_ACCOUNT + } +} + +construct_runtime!( + pub enum Runtime + where + Block = BlockTest, + NodeBlock = BlockTest, + UncheckedExtrinsic = UncheckedExtrinsic, + { + HybridRouter: zrml_hybrid_router::{Pallet, Call, Storage, Event}, + OrderBook: zrml_orderbook::{Call, Event, Pallet, Storage}, + NeoSwaps: zrml_neo_swaps::{Call, Event, Pallet}, + Authorized: zrml_authorized::{Event, Pallet, Storage}, + Balances: pallet_balances::{Call, Config, Event, Pallet, Storage}, + Court: zrml_court::{Event, Pallet, Storage}, + AssetManager: orml_currencies::{Call, Pallet, Storage}, + LiquidityMining: zrml_liquidity_mining::{Config, Event, Pallet}, + MarketCommons: zrml_market_commons::{Pallet, Storage}, + PredictionMarkets: zrml_prediction_markets::{Event, Pallet, Storage}, + RandomnessCollectiveFlip: pallet_randomness_collective_flip::{Pallet, Storage}, + SimpleDisputes: zrml_simple_disputes::{Event, Pallet, Storage}, + GlobalDisputes: zrml_global_disputes::{Event, Pallet, Storage}, + System: frame_system::{Call, Config, Event, Pallet, Storage}, + Timestamp: pallet_timestamp::{Pallet}, + Tokens: orml_tokens::{Config, Event, Pallet, Storage}, + Treasury: pallet_treasury::{Call, Event, Pallet, Storage}, + } +); + +impl crate::Config for Runtime { + type AssetManager = AssetManager; + #[cfg(feature = "runtime-benchmarks")] + type AmmPoolDeployer = NeoSwaps; + type Amm = NeoSwaps; + #[cfg(feature = "runtime-benchmarks")] + type CompleteSetOperations = PredictionMarkets; + type MarketCommons = MarketCommons; + type OrderBook = OrderBook; + type RuntimeEvent = RuntimeEvent; + type MaxOrders = MaxOrders; + type PalletId = HybridRouterPalletId; + type WeightInfo = zrml_hybrid_router::weights::WeightInfo; +} + +impl zrml_orderbook::Config for Runtime { + type AssetManager = AssetManager; + type ExternalFees = ExternalFees; + type RuntimeEvent = RuntimeEvent; + type MarketCommons = MarketCommons; + type PalletId = OrderbookPalletId; + type WeightInfo = zrml_orderbook::weights::WeightInfo; +} + +impl zrml_neo_swaps::Config for Runtime { + type MultiCurrency = AssetManager; + type CompleteSetOperations = PredictionMarkets; + type ExternalFees = ExternalFees; + type MarketCommons = MarketCommons; + type RuntimeEvent = RuntimeEvent; + type MaxLiquidityTreeDepth = MaxLiquidityTreeDepth; + type MaxSwapFee = NeoMaxSwapFee; + type PalletId = NeoSwapsPalletId; + type WeightInfo = zrml_neo_swaps::weights::WeightInfo; +} + +impl pallet_randomness_collective_flip::Config for Runtime {} + +impl zrml_prediction_markets::Config for Runtime { + type AdvisoryBond = AdvisoryBond; + type AdvisoryBondSlashPercentage = AdvisoryBondSlashPercentage; + type ApproveOrigin = EnsureSignedBy; + #[cfg(feature = "parachain")] + type AssetRegistry = MockRegistry; + type Authorized = Authorized; + type CloseEarlyBlockPeriod = CloseEarlyBlockPeriod; + type CloseEarlyDisputeBond = CloseEarlyDisputeBond; + type CloseEarlyTimeFramePeriod = CloseEarlyTimeFramePeriod; + type CloseEarlyProtectionBlockPeriod = CloseEarlyProtectionBlockPeriod; + type CloseEarlyProtectionTimeFramePeriod = CloseEarlyProtectionTimeFramePeriod; + type CloseEarlyRequestBond = CloseEarlyRequestBond; + type CloseMarketEarlyOrigin = EnsureSignedBy; + type CloseOrigin = EnsureSignedBy; + type Court = Court; + type Currency = Balances; + type DeployPool = NeoSwaps; + type DestroyOrigin = EnsureSignedBy; + type DisputeBond = DisputeBond; + type RuntimeEvent = RuntimeEvent; + type GlobalDisputes = GlobalDisputes; + type LiquidityMining = LiquidityMining; + type MaxCategories = MaxCategories; + type MaxDisputes = MaxDisputes; + type MinDisputeDuration = MinDisputeDuration; + type MinOracleDuration = MinOracleDuration; + type MaxCreatorFee = MaxCreatorFee; + type MaxDisputeDuration = MaxDisputeDuration; + type MaxGracePeriod = MaxGracePeriod; + type MaxOracleDuration = MaxOracleDuration; + type MaxMarketLifetime = MaxMarketLifetime; + type MinCategories = MinCategories; + type MaxEditReasonLen = MaxEditReasonLen; + type MaxRejectReasonLen = MaxRejectReasonLen; + type OracleBond = OracleBond; + type OutsiderBond = OutsiderBond; + type PalletId = PmPalletId; + type RejectOrigin = EnsureSignedBy; + type RequestEditOrigin = EnsureSignedBy; + type ResolveOrigin = EnsureSignedBy; + type AssetManager = AssetManager; + type SimpleDisputes = SimpleDisputes; + type Slash = Treasury; + type ValidityBond = ValidityBond; + type WeightInfo = zrml_prediction_markets::weights::WeightInfo; +} + +impl zrml_authorized::Config for Runtime { + type AuthorizedDisputeResolutionOrigin = + EnsureSignedBy; + type CorrectionPeriod = CorrectionPeriod; + type Currency = Balances; + type RuntimeEvent = RuntimeEvent; + type DisputeResolution = zrml_prediction_markets::Pallet; + type MarketCommons = MarketCommons; + type PalletId = AuthorizedPalletId; + type WeightInfo = zrml_authorized::weights::WeightInfo; +} + +impl zrml_court::Config for Runtime { + type AppealBond = AppealBond; + type BlocksPerYear = BlocksPerYear; + type DisputeResolution = zrml_prediction_markets::Pallet; + type VotePeriod = VotePeriod; + type AggregationPeriod = AggregationPeriod; + type AppealPeriod = AppealPeriod; + type LockId = LockId; + type Currency = Balances; + type RuntimeEvent = RuntimeEvent; + type InflationPeriod = InflationPeriod; + type MarketCommons = MarketCommons; + type MaxAppeals = MaxAppeals; + type MaxDelegations = MaxDelegations; + type MaxSelectedDraws = MaxSelectedDraws; + type MaxCourtParticipants = MaxCourtParticipants; + type MaxYearlyInflation = MaxYearlyInflation; + type MinJurorStake = MinJurorStake; + type MonetaryGovernanceOrigin = EnsureRoot; + type PalletId = CourtPalletId; + type Random = RandomnessCollectiveFlip; + type RequestInterval = RequestInterval; + type Slash = Treasury; + type TreasuryPalletId = TreasuryPalletId; + type WeightInfo = zrml_court::weights::WeightInfo; +} + +impl zrml_liquidity_mining::Config for Runtime { + type Currency = Balances; + type RuntimeEvent = RuntimeEvent; + type MarketCommons = MarketCommons; + type MarketId = MarketId; + type PalletId = LiquidityMiningPalletId; + type WeightInfo = zrml_liquidity_mining::weights::WeightInfo; +} + +impl frame_system::Config for Runtime { + type AccountData = pallet_balances::AccountData; + type AccountId = AccountIdTest; + type BaseCallFilter = Everything; + type BlockHashCount = BlockHashCount; + type BlockLength = (); + type BlockNumber = BlockNumber; + type BlockWeights = (); + type RuntimeCall = RuntimeCall; + type DbWeight = (); + type RuntimeEvent = RuntimeEvent; + type Hash = Hash; + type Hashing = BlakeTwo256; + type Header = Header; + type Index = Index; + type Lookup = IdentityLookup; + type MaxConsumers = frame_support::traits::ConstU32<16>; + type OnKilledAccount = (); + type OnNewAccount = (); + type OnSetCode = (); + type RuntimeOrigin = RuntimeOrigin; + type PalletInfo = PalletInfo; + type SS58Prefix = (); + type SystemWeightInfo = (); + type Version = (); +} + +impl orml_currencies::Config for Runtime { + type GetNativeCurrencyId = GetNativeCurrencyId; + type MultiCurrency = Tokens; + type NativeCurrency = BasicCurrencyAdapter; + type WeightInfo = (); +} + +impl orml_tokens::Config for Runtime { + type Amount = Amount; + type Balance = Balance; + type CurrencyId = CurrencyId; + type DustRemovalWhitelist = DustRemovalWhitelist; + type RuntimeEvent = RuntimeEvent; + type ExistentialDeposits = ExistentialDeposits; + type MaxLocks = MaxLocks; + type MaxReserves = MaxReserves; + type CurrencyHooks = (); + type ReserveIdentifier = [u8; 8]; + type WeightInfo = (); +} + +impl pallet_balances::Config for Runtime { + type AccountStore = System; + type Balance = Balance; + type DustRemoval = (); + type RuntimeEvent = RuntimeEvent; + type ExistentialDeposit = ExistentialDeposit; + type MaxLocks = MaxLocks; + type MaxReserves = MaxReserves; + type ReserveIdentifier = [u8; 8]; + type WeightInfo = (); +} + +impl zrml_market_commons::Config for Runtime { + type Balance = Balance; + type MarketId = MarketId; + type Timestamp = Timestamp; +} + +impl pallet_timestamp::Config for Runtime { + type MinimumPeriod = MinimumPeriod; + type Moment = Moment; + type OnTimestampSet = (); + type WeightInfo = (); +} + +impl zrml_simple_disputes::Config for Runtime { + type Currency = Balances; + type RuntimeEvent = RuntimeEvent; + type OutcomeBond = OutcomeBond; + type OutcomeFactor = OutcomeFactor; + type DisputeResolution = zrml_prediction_markets::Pallet; + type MarketCommons = MarketCommons; + type MaxDisputes = MaxDisputes; + type PalletId = SimpleDisputesPalletId; + type WeightInfo = zrml_simple_disputes::weights::WeightInfo; +} + +impl zrml_global_disputes::Config for Runtime { + type AddOutcomePeriod = AddOutcomePeriod; + type RuntimeEvent = RuntimeEvent; + type DisputeResolution = zrml_prediction_markets::Pallet; + type MarketCommons = MarketCommons; + type Currency = Balances; + type GlobalDisputeLockId = GlobalDisputeLockId; + type GlobalDisputesPalletId = GlobalDisputesPalletId; + type MaxGlobalDisputeVotes = MaxGlobalDisputeVotes; + type MaxOwners = MaxOwners; + type MinOutcomeVoteAmount = MinOutcomeVoteAmount; + type RemoveKeysLimit = RemoveKeysLimit; + type GdVotingPeriod = GdVotingPeriod; + type VotingOutcomeFee = VotingOutcomeFee; + type WeightInfo = zrml_global_disputes::weights::WeightInfo; +} + +impl pallet_treasury::Config for Runtime { + type ApproveOrigin = EnsureSignedBy; + type Burn = (); + type BurnDestination = (); + type Currency = Balances; + type RuntimeEvent = RuntimeEvent; + type MaxApprovals = MaxApprovals; + type OnSlash = (); + type PalletId = TreasuryPalletId; + type ProposalBond = (); + type ProposalBondMinimum = (); + type ProposalBondMaximum = (); + type RejectOrigin = EnsureSignedBy; + type SpendFunds = (); + type SpendOrigin = NeverEnsureOrigin; + type SpendPeriod = (); + type WeightInfo = (); +} + +#[cfg(feature = "parachain")] +zrml_prediction_markets::impl_mock_registry! { + MockRegistry, + CurrencyId, + Balance, + zeitgeist_primitives::types::CustomMetadata +} + +#[allow(unused)] +pub struct ExtBuilder { + balances: Vec<(AccountIdTest, Balance)>, +} + +// TODO(#1222): Remove this in favor of adding whatever the account need in the individual tests. +#[allow(unused)] +impl Default for ExtBuilder { + fn default() -> Self { + Self { + balances: vec![ + (ALICE, INITIAL_BALANCE), + (CHARLIE, INITIAL_BALANCE), + (DAVE, INITIAL_BALANCE), + (EVE, INITIAL_BALANCE), + ], + } + } +} + +#[allow(unused)] +impl ExtBuilder { + pub fn build(self) -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::default().build_storage::().unwrap(); + // see the logs in tests when using `RUST_LOG=debug cargo test -- --nocapture` + let _ = env_logger::builder().is_test(true).try_init(); + pallet_balances::GenesisConfig:: { balances: self.balances } + .assimilate_storage(&mut t) + .unwrap(); + #[cfg(feature = "parachain")] + { + use frame_support::traits::GenesisBuild; + orml_tokens::GenesisConfig:: { + balances: vec![(ALICE, FOREIGN_ASSET, INITIAL_BALANCE)], + } + .assimilate_storage(&mut t) + .unwrap(); + let custom_metadata = zeitgeist_primitives::types::CustomMetadata { + allow_as_base_asset: true, + ..Default::default() + }; + orml_asset_registry_mock::GenesisConfig { + metadata: vec![( + FOREIGN_ASSET, + AssetMetadata { + decimals: 18, + name: "MKL".as_bytes().to_vec(), + symbol: "MKL".as_bytes().to_vec(), + existential_deposit: 0, + location: None, + additional: custom_metadata, + }, + )], + } + .assimilate_storage(&mut t) + .unwrap(); + } + let mut test_ext: sp_io::TestExternalities = t.into(); + test_ext.execute_with(|| System::set_block_number(1)); + test_ext + } +} diff --git a/zrml/hybrid-router/src/tests/buy.rs b/zrml/hybrid-router/src/tests/buy.rs new file mode 100644 index 000000000..a000c9165 --- /dev/null +++ b/zrml/hybrid-router/src/tests/buy.rs @@ -0,0 +1,862 @@ +// Copyright 2024 Forecasting Technologies LTD. +// +// This file is part of Zeitgeist. +// +// Zeitgeist 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. +// +// Zeitgeist 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. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +use super::*; + +#[test] +fn buy_from_amm_and_then_fill_specified_order() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let pivot = _1_100; + let spot_prices = vec![_1_2 - pivot, _1_2 + pivot]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount_in = _2; + + let order_maker_amount = _12; + let order_taker_amount = _6; + assert_ok!(AssetManager::deposit(asset, &CHARLIE, order_maker_amount)); + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(CHARLIE), + market_id, + asset, + order_maker_amount, + BASE_ASSET, + order_taker_amount, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + + let max_price = _3_4.saturated_into::>(); + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount_in, + max_price, + order_ids, + strategy, + )); + + let amm_amount_in = 2832657984; + System::assert_has_event( + NeoSwapsEvent::::BuyExecuted { + who: ALICE, + market_id, + asset_out: asset, + amount_in: amm_amount_in, + amount_out: 5608094333, + swap_fee_amount: 28326580, + external_fee_amount: 28326579, + } + .into(), + ); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_ids.len(), 1); + let order = Orders::::get(order_ids[0]).unwrap(); + let unfilled_base_asset_amount = 42832657984; + assert_eq!( + order, + Order { + market_id, + maker: CHARLIE, + maker_asset: Asset::CategoricalOutcome(market_id, 0), + maker_amount: 85665315968, + taker_asset: BASE_ASSET, + taker_amount: unfilled_base_asset_amount, + } + ); + let filled_base_asset_amount = order_taker_amount - unfilled_base_asset_amount; + assert_eq!(filled_base_asset_amount, 17167342016); + assert_eq!(amm_amount_in + filled_base_asset_amount, amount_in); + }); +} + +#[test] +fn buy_from_amm_if_specified_order_has_higher_prices_than_the_amm() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_4, _3_4]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = _2; + + let order_maker_amount = _4; + let order_taker_amount = _2; + assert_ok!(AssetManager::deposit(asset, &CHARLIE, order_maker_amount)); + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(CHARLIE), + market_id, + asset, + order_maker_amount, + BASE_ASSET, + order_taker_amount, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + + let max_price = _3_4.saturated_into::>(); + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + max_price, + order_ids, + strategy, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_ids.len(), 1); + let order = Orders::::get(order_ids[0]).unwrap(); + assert_eq!( + order, + Order { + market_id, + maker: CHARLIE, + maker_asset: Asset::CategoricalOutcome(market_id, 0), + maker_amount: _4, + taker_asset: BASE_ASSET, + taker_amount: _2, + } + ); + }); +} + +#[test] +fn buy_fill_multiple_orders_if_amm_spot_price_higher_than_order_prices() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2, _1_2]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount_in = _2; + + let order_maker_amount = _1; + let order_taker_amount = _1_2; + assert_ok!(AssetManager::deposit(asset, &CHARLIE, 2 * order_maker_amount)); + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(CHARLIE), + market_id, + asset, + order_maker_amount, + BASE_ASSET, + order_taker_amount, + )); + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(CHARLIE), + market_id, + asset, + order_maker_amount, + BASE_ASSET, + order_taker_amount, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + + let max_price = _3_4.saturated_into::>(); + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount_in, + max_price, + order_ids, + strategy, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_ids.len(), 0); + }); +} + +#[test] +fn buy_fill_specified_order_partially_if_amm_spot_price_higher() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2, _1_2]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = _2; + + let order_maker_amount = _8; + let order_taker_amount = _4; + assert_ok!(AssetManager::deposit(asset, &CHARLIE, order_maker_amount)); + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(CHARLIE), + market_id, + asset, + order_maker_amount, + BASE_ASSET, + order_taker_amount, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_ids.len(), 1); + let order_id = order_ids[0]; + + let max_price = _3_4.saturated_into::>(); + let orders = vec![order_id]; + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + max_price, + orders, + strategy, + )); + + let order = Orders::::get(order_id).unwrap(); + assert_eq!( + order, + Order { + market_id, + maker: CHARLIE, + maker_asset: Asset::CategoricalOutcome(market_id, 0), + maker_amount: _4, + taker_asset: BASE_ASSET, + taker_amount: _2, + } + ); + }); +} + +#[test] +fn buy_fails_if_asset_not_equal_to_order_book_maker_asset() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2, _1_2]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = _2; + let order_maker_amount = _1; + assert_ok!(AssetManager::deposit(BASE_ASSET, &CHARLIE, order_maker_amount)); + + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(CHARLIE), + market_id, + BASE_ASSET, + order_maker_amount, + Asset::CategoricalOutcome(market_id, 0), + _2, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_ids.len(), 1); + let order_id = order_ids[0]; + + let max_price = _3_4.saturated_into::>(); + let orders = vec![order_id]; + let strategy = Strategy::LimitOrder; + assert_noop!( + HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + max_price, + orders, + strategy, + ), + Error::::AssetNotEqualToOrderBookMakerAsset + ); + }); +} + +#[test] +fn buy_fails_if_order_price_above_max_price() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2, _1_2]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = _2; + + let order_maker_amount = _1; + assert_ok!(AssetManager::deposit(asset, &CHARLIE, order_maker_amount)); + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(CHARLIE), + market_id, + Asset::CategoricalOutcome(market_id, 0), + order_maker_amount, + BASE_ASSET, + _2, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_ids.len(), 1); + let order_id = order_ids[0]; + + let max_price = _3_4.saturated_into::>(); + let orders = vec![order_id]; + let strategy = Strategy::LimitOrder; + assert_noop!( + HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + max_price, + orders, + strategy, + ), + Error::::OrderPriceAboveMaxPrice + ); + }); +} + +#[test] +fn buy_from_amm() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2, _1_2]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = _2; + let max_price = _3_4.saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + max_price, + orders, + strategy, + )); + + System::assert_has_event( + NeoSwapsEvent::::BuyExecuted { + who: ALICE, + market_id, + asset_out: asset, + amount_in: 20000000000, + amount_out: 36852900215, + swap_fee_amount: 200000000, + external_fee_amount: 200000000, + } + .into(), + ); + }); +} + +#[test] +fn buy_max_price_lower_than_amm_spot_price_results_in_place_order() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2 + 1u128, _1_2 - 1u128]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + let market = Markets::::get(market_id).unwrap(); + let base_asset = market.base_asset; + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = _2; + //* max_price is just 1 smaller than the spot price of the AMM + //* this results in no buy on the AMM, but places an order on the order book + let max_price = (_1_2).saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + max_price, + orders, + strategy, + )); + + let order_keys = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_keys.len(), 1); + let order_id = order_keys[0]; + let order = Orders::::get(order_id).unwrap(); + assert_eq!( + order, + Order { + market_id, + maker: ALICE, + maker_asset: base_asset, + maker_amount: _2, + taker_asset: asset, + taker_amount: _4, + } + ); + }); +} + +#[test] +fn buy_from_amm_but_low_amount() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2, _1_2]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + let market = Markets::::get(market_id).unwrap(); + let base_asset = market.base_asset; + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount_in = _2; + //* max_price is just 1 larger than the spot price of the AMM + //* this results in a low buy amount_in on the AMM + let max_price = (_1_2 + 1u128).saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount_in, + max_price, + orders, + strategy, + )); + + System::assert_has_event( + NeoSwapsEvent::::BuyExecuted { + who: ALICE, + market_id, + asset_out: asset, + amount_in: 30, + amount_out: 60, + swap_fee_amount: 0, + external_fee_amount: 0, + } + .into(), + ); + + let order_keys = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_keys.len(), 1); + let order_id = order_keys[0]; + let order = Orders::::get(order_id).unwrap(); + assert_eq!( + order, + Order { + market_id, + maker: ALICE, + maker_asset: base_asset, + maker_amount: 19999999970, + taker_asset: asset, + taker_amount: 39999999933, + } + ); + }); +} + +#[test] +fn buy_from_amm_only() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2, _1_2]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = _2; + let max_price = _3_4.saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::ImmediateOrCancel; + assert_ok!(HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + max_price, + orders, + strategy, + )); + + System::assert_has_event( + NeoSwapsEvent::::BuyExecuted { + who: ALICE, + market_id, + asset_out: asset, + amount_in: 20000000000, + amount_out: 36852900215, + swap_fee_amount: 200000000, + external_fee_amount: 200000000, + } + .into(), + ); + + let order_keys = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_keys.len(), 0); + }); +} + +#[test] +fn buy_places_limit_order_no_pool() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + let base_asset = market.base_asset; + let required_asset_count = match &market.market_type { + MarketType::Scalar(_) => panic!("Categorical market type is expected!"), + MarketType::Categorical(categories) => *categories, + }; + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let asset_count = required_asset_count; + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = 10 * BASE; + let max_price = (BASE / 2).saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + max_price, + orders, + strategy, + )); + + let order_keys = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_keys.len(), 1); + let order_id = order_keys[0]; + let order = Orders::::get(order_id).unwrap(); + assert_eq!( + order, + Order { + market_id, + maker: ALICE, + maker_asset: base_asset, + maker_amount: 10 * BASE, + taker_asset: asset, + taker_amount: 20 * BASE, + } + ); + }); +} + +#[test] +fn buy_fails_if_balance_too_low() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + let required_asset_count = match &market.market_type { + MarketType::Scalar(_) => panic!("Categorical market type is expected!"), + MarketType::Categorical(categories) => *categories, + }; + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let asset_count = required_asset_count; + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = 10 * BASE; + + assert_ok!(Balances::set_balance(RuntimeOrigin::root(), ALICE, amount - 1, 0)); + let max_price = (BASE / 2).saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::LimitOrder; + assert_noop!( + HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + max_price, + orders, + strategy, + ), + CurrenciesError::::BalanceTooLow + ); + }); +} + +#[test] +fn buy_emits_event() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let pivot = _1_100; + let spot_prices = vec![_1_2 + pivot, _1_2 - pivot]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount_in = _1000 * 100; + + assert_ok!(AssetManager::deposit(BASE_ASSET, &ALICE, amount_in)); + + let max_price = _9_10.saturated_into::>(); + let orders = (0u128..10u128).collect::>(); + let maker_asset = asset; + let maker_amount: BalanceOf = _20.saturated_into(); + let taker_asset = BASE_ASSET; + let taker_amount = _11.saturated_into::>(); + for (i, _) in orders.iter().enumerate() { + let order_creator = i as AccountIdTest; + let surplus = ((i + 1) as u128) * _1_2; + let taker_amount = taker_amount + surplus.saturated_into::>(); + assert_ok!(AssetManager::deposit(maker_asset, &order_creator, maker_amount)); + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(order_creator), + market_id, + maker_asset, + maker_amount, + taker_asset, + taker_amount, + )); + } + + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount_in, + max_price, + orders, + strategy, + )); + + System::assert_last_event( + Event::::HybridRouterExecuted { + tx_type: TxType::Buy, + who: ALICE, + market_id, + price_limit: max_price, + asset_in: BASE_ASSET, + amount_in, + asset_out: asset, + amount_out: 2301256894490, + external_fee_amount: 3423314400, + swap_fee_amount: 2273314407, + } + .into(), + ); + }); +} + +#[test] +fn buy_fails_if_asset_count_mismatch() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + let required_asset_count = match &market.market_type { + MarketType::Scalar(_) => panic!("Categorical market type is expected!"), + MarketType::Categorical(categories) => *categories, + }; + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let asset_count = 2; + assert_ne!(required_asset_count, asset_count); + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = 2 * BASE; + let max_price = (BASE / 2).saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::ImmediateOrCancel; + assert_noop!( + HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + max_price, + orders, + strategy, + ), + Error::::AssetCountMismatch + ); + }); +} + +#[test] +fn buy_fails_if_cancel_strategy_applied() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + let required_asset_count = match &market.market_type { + MarketType::Scalar(_) => panic!("Categorical market type is expected!"), + MarketType::Categorical(categories) => *categories, + }; + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let asset_count = required_asset_count; + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = 10 * BASE; + let max_price = (BASE / 2).saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::ImmediateOrCancel; + assert_noop!( + HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + max_price, + orders, + strategy, + ), + Error::::CancelStrategyApplied + ); + }); +} + +#[test] +fn buy_fails_if_market_does_not_exist() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let asset_count = 2; + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = 10 * BASE; + let max_price = (BASE / 2).saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::ImmediateOrCancel; + assert_noop!( + HybridRouter::buy( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + max_price, + orders, + strategy, + ), + MError::::MarketDoesNotExist + ); + }); +} diff --git a/zrml/hybrid-router/src/tests/mod.rs b/zrml/hybrid-router/src/tests/mod.rs new file mode 100644 index 000000000..649ed0ee7 --- /dev/null +++ b/zrml/hybrid-router/src/tests/mod.rs @@ -0,0 +1,97 @@ +// Copyright 2024 Forecasting Technologies LTD. +// +// This file is part of Zeitgeist. +// +// Zeitgeist 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. +// +// Zeitgeist 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. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +#![cfg(all(feature = "mock", test))] + +use crate::{mock::*, types::*, utils::*, AccountIdOf, BalanceOf, MarketIdOf, *}; +use frame_support::{assert_noop, assert_ok}; +use orml_currencies::Error as CurrenciesError; +use orml_tokens::Error as TokensError; +use orml_traits::MultiCurrency; +use sp_runtime::{Perbill, SaturatedConversion}; +use zeitgeist_primitives::{ + constants::{base_multiples::*, BASE, CENT}, + orderbook::Order, + types::{ + AccountIdTest, Asset, Deadlines, MarketCreation, MarketPeriod, MarketStatus, MarketType, + MultiHash, ScoringRule, + }, +}; +use zrml_market_commons::{Error as MError, MarketCommonsPalletApi, Markets}; +use zrml_neo_swaps::Event as NeoSwapsEvent; +use zrml_orderbook::Orders; + +mod buy; +mod sell; + +#[cfg(not(feature = "parachain"))] +const BASE_ASSET: Asset> = Asset::Ztg; +#[cfg(feature = "parachain")] +const BASE_ASSET: Asset> = FOREIGN_ASSET; + +fn create_market( + creator: AccountIdTest, + base_asset: Asset>, + market_type: MarketType, + scoring_rule: ScoringRule, +) -> MarketIdOf { + let mut metadata = [2u8; 50]; + metadata[0] = 0x15; + metadata[1] = 0x30; + assert_ok!(PredictionMarkets::create_market( + RuntimeOrigin::signed(creator), + base_asset, + Perbill::zero(), + EVE, + MarketPeriod::Block(0..2), + Deadlines { + grace_period: 0_u32.into(), + oracle_duration: ::MinOracleDuration::get(), + dispute_duration: 0_u32.into(), + }, + MultiHash::Sha3_384(metadata), + MarketCreation::Permissionless, + market_type, + None, + scoring_rule, + )); + MarketCommons::latest_market_id().unwrap() +} + +fn create_market_and_deploy_pool( + creator: AccountIdOf, + base_asset: Asset>, + market_type: MarketType, + amount: BalanceOf, + spot_prices: Vec>, + swap_fee: BalanceOf, +) -> MarketIdOf { + let market_id = create_market(creator, base_asset, market_type, ScoringRule::AmmCdaHybrid); + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(ALICE), + market_id, + amount, + )); + assert_ok!(NeoSwaps::deploy_pool( + RuntimeOrigin::signed(ALICE), + market_id, + amount, + spot_prices.clone(), + swap_fee, + )); + market_id +} diff --git a/zrml/hybrid-router/src/tests/sell.rs b/zrml/hybrid-router/src/tests/sell.rs new file mode 100644 index 000000000..20a805d09 --- /dev/null +++ b/zrml/hybrid-router/src/tests/sell.rs @@ -0,0 +1,900 @@ +// Copyright 2024 Forecasting Technologies LTD. +// +// This file is part of Zeitgeist. +// +// Zeitgeist 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. +// +// Zeitgeist 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. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +use super::*; + +#[test] +fn sell_to_amm_and_then_fill_specified_order() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let pivot = _1_100; + let spot_prices = vec![_1_2 + pivot, _1_2 - pivot]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount_in = _2; + + let order_maker_amount = _6; + let order_taker_amount = _12; + assert_ok!(AssetManager::deposit(BASE_ASSET, &CHARLIE, order_maker_amount)); + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(CHARLIE), + market_id, + BASE_ASSET, + order_maker_amount, + asset, + order_taker_amount, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, amount_in, 0)); + + let min_price = _1_4.saturated_into::>(); + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount_in, + min_price, + order_ids, + strategy, + )); + + let amm_amount_in = 5608094330; + System::assert_has_event( + NeoSwapsEvent::::SellExecuted { + who: ALICE, + market_id, + asset_in: asset, + amount_in: amm_amount_in, + amount_out: 2832089506, + swap_fee_amount: 28320895, + external_fee_amount: 28320895, + } + .into(), + ); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_ids.len(), 1); + let order = Orders::::get(order_ids[0]).unwrap(); + let unfilled_base_asset_amount = 105608094330; + assert_eq!( + order, + Order { + market_id, + maker: CHARLIE, + maker_asset: BASE_ASSET, + maker_amount: 52804047165, + taker_asset: Asset::CategoricalOutcome(market_id, 0), + taker_amount: unfilled_base_asset_amount, + } + ); + let filled_base_asset_amount = order_taker_amount - unfilled_base_asset_amount; + assert_eq!(filled_base_asset_amount, 14391905670); + assert_eq!(amm_amount_in + filled_base_asset_amount, amount_in); + }); +} + +#[test] +fn sell_to_amm_if_specified_order_has_lower_prices_than_the_amm() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_9_10, _1_10]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = _1; + + let order_maker_amount = _1; + let order_taker_amount = _2; + assert_ok!(AssetManager::deposit(BASE_ASSET, &CHARLIE, order_maker_amount)); + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(CHARLIE), + market_id, + BASE_ASSET, + order_maker_amount, + asset, + order_taker_amount, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, amount, 0)); + + let min_price = _1_4.saturated_into::>(); + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + min_price, + order_ids, + strategy, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_ids.len(), 1); + let order = Orders::::get(order_ids[0]).unwrap(); + let order_price = order.price(BASE_ASSET).unwrap(); + assert_eq!(order_price, _1_2); + assert_eq!( + order, + Order { + market_id, + maker: CHARLIE, + maker_asset: BASE_ASSET, + maker_amount: _1, + taker_asset: Asset::CategoricalOutcome(market_id, 0), + taker_amount: _2, + } + ); + }); +} + +#[test] +fn sell_fill_multiple_orders_if_amm_spot_price_lower_than_order_prices() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2 - 1, _1_2 + 1]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount_in = _2; + + let order_maker_amount = _1_2; + let order_taker_amount = _1; + assert_ok!(AssetManager::deposit(BASE_ASSET, &CHARLIE, 2 * order_maker_amount)); + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(CHARLIE), + market_id, + BASE_ASSET, + order_maker_amount, + asset, + order_taker_amount, + )); + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(CHARLIE), + market_id, + BASE_ASSET, + order_maker_amount, + asset, + order_taker_amount, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, amount_in, 0)); + + let min_price = _1_4.saturated_into::>(); + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount_in, + min_price, + order_ids, + strategy, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_ids.len(), 0); + }); +} + +#[test] +fn sell_fill_specified_order_partially_if_amm_spot_price_lower() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2 - 1, _1_2 + 1]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = _2; + + let order_maker_amount = _4; + let order_taker_amount = _8; + assert_ok!(AssetManager::deposit(BASE_ASSET, &CHARLIE, order_maker_amount)); + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(CHARLIE), + market_id, + BASE_ASSET, + order_maker_amount, + asset, + order_taker_amount, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_ids.len(), 1); + let order_id = order_ids[0]; + + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, amount, 0)); + + let min_price = _1_4.saturated_into::>(); + let orders = vec![order_id]; + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + min_price, + orders, + strategy, + )); + + let order = Orders::::get(order_id).unwrap(); + assert_eq!( + order, + Order { + market_id, + maker: CHARLIE, + maker_asset: BASE_ASSET, + maker_amount: _3, + taker_asset: Asset::CategoricalOutcome(market_id, 0), + taker_amount: _6, + } + ); + }); +} + +#[test] +fn sell_fails_if_asset_not_equal_to_order_book_taker_asset() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2, _1_2]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount_in = _2; + + let maker_amount = _1; + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), CHARLIE, asset, maker_amount, 0)); + + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(CHARLIE), + market_id, + asset, + maker_amount, + BASE_ASSET, + amount_in, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_ids.len(), 1); + let order_id = order_ids[0]; + + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, amount_in, 0)); + + let min_price = _1_4.saturated_into::>(); + let orders = vec![order_id]; + let strategy = Strategy::LimitOrder; + assert_noop!( + HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount_in, + min_price, + orders, + strategy, + ), + Error::::AssetNotEqualToOrderBookTakerAsset + ); + }); +} + +#[test] +fn sell_fails_if_order_price_below_min_price() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2, _1_2]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = _2; + + let order_maker_amount = _1; + assert_ok!(AssetManager::deposit(BASE_ASSET, &CHARLIE, order_maker_amount)); + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(CHARLIE), + market_id, + BASE_ASSET, + order_maker_amount, + Asset::CategoricalOutcome(market_id, 0), + amount, + )); + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_ids.len(), 1); + let order_id = order_ids[0]; + + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, 5 * amount, 0)); + + let min_price = _3_4.saturated_into::>(); + let orders = vec![order_id]; + let strategy = Strategy::LimitOrder; + assert_noop!( + HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + min_price, + orders, + strategy, + ), + Error::::OrderPriceBelowMinPrice + ); + }); +} + +#[test] +fn sell_to_amm() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2, _1_2]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = _2; + + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, amount, 0)); + + let min_price = _1_4.saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + min_price, + orders, + strategy, + )); + + System::assert_has_event( + NeoSwapsEvent::::SellExecuted { + who: ALICE, + market_id, + asset_in: asset, + amount_in: 20000000000, + amount_out: 9653703575, + swap_fee_amount: 96537036, + external_fee_amount: 96537035, + } + .into(), + ); + }); +} + +#[test] +fn sell_min_price_higher_than_amm_spot_price_results_in_place_order() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2 - 1u128, _1_2 + 1u128]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + let market = Markets::::get(market_id).unwrap(); + let base_asset = market.base_asset; + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = _2; + + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, amount, 0)); + + //* spot price of the AMM is 1 smaller than the min_price + //* this results in no sell on the AMM, but places an order on the order book + let min_price = (_1_2).saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + min_price, + orders, + strategy, + )); + + let order_keys = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_keys.len(), 1); + let order_id = order_keys[0]; + let order = Orders::::get(order_id).unwrap(); + assert_eq!( + order, + Order { + market_id, + maker: ALICE, + maker_asset: asset, + maker_amount: _2, + taker_asset: base_asset, + taker_amount: _1, + } + ); + }); +} + +#[test] +fn sell_to_amm_but_low_amount() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2, _1_2]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + let market = Markets::::get(market_id).unwrap(); + let base_asset = market.base_asset; + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount_in = _2; + + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, amount_in, 0)); + + //* min_price is just 1 smaller than the spot price of the AMM + //* this results in a low sell amount_in on the AMM + let min_price = (_1_2 - 1u128).saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount_in, + min_price, + orders, + strategy, + )); + + System::assert_has_event( + NeoSwapsEvent::::SellExecuted { + who: ALICE, + market_id, + asset_in: asset, + amount_in: 58, + amount_out: 29, + swap_fee_amount: 0, + external_fee_amount: 0, + } + .into(), + ); + + let order_keys = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_keys.len(), 1); + let order_id = order_keys[0]; + let order = Orders::::get(order_id).unwrap(); + assert_eq!( + order, + Order { + market_id, + maker: ALICE, + maker_asset: asset, + maker_amount: 19999999942, + taker_asset: base_asset, + taker_amount: 9999999969, + } + ); + }); +} + +#[test] +fn sell_to_amm_only() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let spot_prices = vec![_1_2, _1_2]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = _2; + + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, amount, 0)); + + let min_price = _1_4.saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::ImmediateOrCancel; + assert_ok!(HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + min_price, + orders, + strategy, + )); + + System::assert_has_event( + NeoSwapsEvent::::SellExecuted { + who: ALICE, + market_id, + asset_in: asset, + amount_in: 20000000000, + amount_out: 9653703575, + swap_fee_amount: 96537036, + external_fee_amount: 96537035, + } + .into(), + ); + + let order_keys = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_keys.len(), 0); + }); +} + +#[test] +fn sell_places_limit_order_no_pool() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + let base_asset = market.base_asset; + let required_asset_count = match &market.market_type { + MarketType::Scalar(_) => panic!("Categorical market type is expected!"), + MarketType::Categorical(categories) => *categories, + }; + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let asset_count = required_asset_count; + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = 10 * BASE; + + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, amount, 0)); + + let min_price = (BASE / 2).saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + min_price, + orders, + strategy, + )); + + let order_keys = Orders::::iter().map(|(k, _)| k).collect::>(); + assert_eq!(order_keys.len(), 1); + let order_id = order_keys[0]; + let order = Orders::::get(order_id).unwrap(); + assert_eq!( + order, + Order { + market_id, + maker: ALICE, + maker_asset: asset, + maker_amount: 10 * BASE, + taker_asset: base_asset, + taker_amount: 5 * BASE, + } + ); + }); +} + +#[test] +fn sell_fails_if_balance_too_low() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + let required_asset_count = match &market.market_type { + MarketType::Scalar(_) => panic!("Categorical market type is expected!"), + MarketType::Categorical(categories) => *categories, + }; + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let asset_count = required_asset_count; + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount = 10 * BASE; + + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, amount - 1, 0)); + + let min_price = (BASE / 2).saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::ImmediateOrCancel; + assert_noop!( + HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount, + min_price, + orders, + strategy, + ), + TokensError::::BalanceTooLow + ); + }); +} + +#[test] +fn sell_emits_event() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let pivot = _1_100; + let spot_prices = vec![_1_2 + pivot, _1_2 - pivot]; + let swap_fee = CENT; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(asset_count), + liquidity, + spot_prices.clone(), + swap_fee, + ); + + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount_in = _1000 * 100; + + let min_price = _1_100.saturated_into::>(); + let orders = (0u128..50u128).collect::>(); + let maker_asset = BASE_ASSET; + let maker_amount: BalanceOf = _9.saturated_into(); + let taker_asset = asset; + let taker_amount = _100.saturated_into::>(); + for (i, _) in orders.iter().enumerate() { + let order_creator = i as AccountIdTest; + let surplus = ((i + 1) as u128) * _1_2; + let taker_amount = taker_amount + surplus.saturated_into::>(); + assert_ok!(AssetManager::deposit(maker_asset, &order_creator, maker_amount)); + assert_ok!(OrderBook::place_order( + RuntimeOrigin::signed(order_creator), + market_id, + maker_asset, + maker_amount, + taker_asset, + taker_amount, + )); + } + + let order_ids = Orders::::iter().map(|(k, _)| k).collect::>(); + + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, amount_in, 0)); + + let strategy = Strategy::LimitOrder; + assert_ok!(HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount_in, + min_price, + order_ids, + strategy, + )); + + System::assert_last_event( + Event::::HybridRouterExecuted { + tx_type: TxType::Sell, + who: ALICE, + market_id, + price_limit: min_price, + asset_in: asset, + amount_in, + asset_out: BASE_ASSET, + amount_out: 4551619284973, + external_fee_amount: 45985911066, + swap_fee_amount: 985911072, + } + .into(), + ); + }); +} + +#[test] +fn sell_fails_if_asset_count_mismatch() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + let required_asset_count = match &market.market_type { + MarketType::Scalar(_) => panic!("Categorical market type is expected!"), + MarketType::Categorical(categories) => *categories, + }; + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let asset_count = 2; + assert_ne!(required_asset_count, asset_count); + let asset = Asset::CategoricalOutcome(market_id, 0); + + let amount_in = 2 * BASE; + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, amount_in, 0)); + + let max_price = (BASE / 2).saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::ImmediateOrCancel; + assert_noop!( + HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount_in, + max_price, + orders, + strategy, + ), + Error::::AssetCountMismatch + ); + }); +} + +#[test] +fn sell_fails_if_cancel_strategy_applied() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + let required_asset_count = match &market.market_type { + MarketType::Scalar(_) => panic!("Categorical market type is expected!"), + MarketType::Categorical(categories) => *categories, + }; + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let asset_count = required_asset_count; + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount_in = 10 * BASE; + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, amount_in, 0)); + let max_price = (BASE / 2).saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::ImmediateOrCancel; + assert_noop!( + HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount_in, + max_price, + orders, + strategy, + ), + Error::::CancelStrategyApplied + ); + }); +} + +#[test] +fn sell_fails_if_market_does_not_exist() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let asset_count = 2; + let asset = Asset::CategoricalOutcome(market_id, 0); + let amount_in = 10 * BASE; + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), ALICE, asset, amount_in, 0)); + let max_price = (BASE / 2).saturated_into::>(); + let orders = vec![]; + let strategy = Strategy::ImmediateOrCancel; + assert_noop!( + HybridRouter::sell( + RuntimeOrigin::signed(ALICE), + market_id, + asset_count, + asset, + amount_in, + max_price, + orders, + strategy, + ), + MError::::MarketDoesNotExist + ); + }); +} diff --git a/zrml/hybrid-router/src/types.rs b/zrml/hybrid-router/src/types.rs new file mode 100644 index 000000000..6e70db0d5 --- /dev/null +++ b/zrml/hybrid-router/src/types.rs @@ -0,0 +1,93 @@ +// Copyright 2024 Forecasting Technologies LTD. +// +// This file is part of Zeitgeist. +// +// Zeitgeist 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. +// +// Zeitgeist 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. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +use crate::{AmmTradeOf, BalanceOf, Config, OrderTradeOf}; +use frame_support::pallet_prelude::*; +use scale_info::TypeInfo; +use sp_runtime::traits::Zero; +use zeitgeist_primitives::math::checked_ops_res::{CheckedAddRes, CheckedSubRes}; + +/// Represents the strategy used when placing an order in a trading environment. +#[derive( + Copy, + Clone, + Eq, + PartialEq, + Ord, + PartialOrd, + Encode, + Decode, + RuntimeDebug, + MaxEncodedLen, + TypeInfo, +)] +pub enum Strategy { + /// The trade is rolled back if it cannot be executed fully. + ImmediateOrCancel, + /// Partially fulfills the order if possible, placing the remainder in the order book. Favors + /// achieving a specific price rather than immediate execution. + LimitOrder, +} + +#[derive(Clone, Copy, Debug, Decode, Encode, PartialEq, TypeInfo)] +pub enum TxType { + Buy, + Sell, +} + +#[derive(Clone, Debug, Decode, Encode, PartialEq, TypeInfo)] +pub enum Trade<'a, T: Config> { + Orderbook(&'a OrderTradeOf), + Amm(AmmTradeOf), +} + +#[derive(Clone, Copy, Debug, Decode, Encode, PartialEq, TypeInfo)] +pub struct TradeEventInfo { + pub amount_out: BalanceOf, + pub external_fee_amount: BalanceOf, + pub swap_fee_amount: BalanceOf, +} + +impl TradeEventInfo { + pub fn new() -> Self { + Self { + amount_out: BalanceOf::::zero(), + external_fee_amount: BalanceOf::::zero(), + swap_fee_amount: BalanceOf::::zero(), + } + } + + pub fn add_amount_out_minus_fees(&mut self, additional: Self) -> Result<(), DispatchError> { + self.external_fee_amount = + self.external_fee_amount.checked_add_res(&additional.external_fee_amount)?; + self.swap_fee_amount = self.swap_fee_amount.checked_add_res(&additional.swap_fee_amount)?; + let fees = additional.external_fee_amount.checked_add_res(&additional.swap_fee_amount)?; + let amount_minus_fees = additional.amount_out.checked_sub_res(&fees)?; + self.amount_out = self.amount_out.checked_add_res(&amount_minus_fees)?; + + Ok(()) + } + + pub fn add_amount_out_and_fees(&mut self, additional: Self) -> Result<(), DispatchError> { + self.external_fee_amount = + self.external_fee_amount.checked_add_res(&additional.external_fee_amount)?; + self.swap_fee_amount = self.swap_fee_amount.checked_add_res(&additional.swap_fee_amount)?; + self.amount_out = self.amount_out.checked_add_res(&additional.amount_out)?; + + Ok(()) + } +} diff --git a/zrml/hybrid-router/src/utils.rs b/zrml/hybrid-router/src/utils.rs new file mode 100644 index 000000000..69ff304c3 --- /dev/null +++ b/zrml/hybrid-router/src/utils.rs @@ -0,0 +1,54 @@ +// Copyright 2024 Forecasting Technologies LTD. +// +// This file is part of Zeitgeist. +// +// Zeitgeist 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. +// +// Zeitgeist 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. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +#![cfg(all(feature = "mock", test))] +pub(crate) fn market_mock(creator: T::AccountId) -> crate::MarketOf +where + T: crate::Config, +{ + use sp_runtime::{traits::AccountIdConversion, Perbill}; + use zeitgeist_primitives::{ + constants::mock::PmPalletId, + types::{ + Asset, Deadlines, MarketBonds, MarketCreation, MarketDisputeMechanism, MarketPeriod, + MarketStatus, MarketType, ScoringRule, + }, + }; + + zeitgeist_primitives::types::Market { + base_asset: Asset::Ztg, + creation: MarketCreation::Permissionless, + creator_fee: Perbill::zero(), + creator, + market_type: MarketType::Categorical(10u16), + dispute_mechanism: Some(MarketDisputeMechanism::Court), + metadata: Default::default(), + oracle: PmPalletId::get().into_account_truncating(), + period: MarketPeriod::Block(Default::default()), + deadlines: Deadlines { + grace_period: 1_u32.into(), + oracle_duration: 1_u32.into(), + dispute_duration: 1_u32.into(), + }, + report: None, + resolved_outcome: None, + scoring_rule: ScoringRule::AmmCdaHybrid, + status: MarketStatus::Active, + bonds: MarketBonds::default(), + early_close: None, + } +} diff --git a/zrml/hybrid-router/src/weights.rs b/zrml/hybrid-router/src/weights.rs new file mode 100644 index 000000000..6e8745bc2 --- /dev/null +++ b/zrml/hybrid-router/src/weights.rs @@ -0,0 +1,137 @@ +// Copyright 2022-2024 Forecasting Technologies LTD. +// Copyright 2021-2022 Zeitgeist PM LLC. +// +// This file is part of Zeitgeist. +// +// Zeitgeist 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. +// +// Zeitgeist 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. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +//! Autogenerated weights for zrml_hybrid_router +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev +//! DATE: `2024-03-25`, STEPS: `20`, REPEAT: `50`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `msi-pro-b650-s`, CPU: `AMD Ryzen 9 7950X3D 16-Core Processor` +//! EXECUTION: `Some(Wasm)`, WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` + +// Executed Command: +// ./target/release/zeitgeist +// benchmark +// pallet +// --chain=dev +// --steps=20 +// --repeat=50 +// --pallet=zrml_hybrid_router +// --extrinsic=* +// --execution=wasm +// --wasm-execution=compiled +// --heap-pages=4096 +// --template=./misc/weight_template.hbs +// --header=./HEADER_GPL3 +// --output=./zrml/hybrid-router/src/weights.rs + +#![allow(unused_parens)] +#![allow(unused_imports)] + +use core::marker::PhantomData; +use frame_support::{traits::Get, weights::Weight}; + +/// Trait containing the required functions for weight retrival within +/// zrml_hybrid_router (automatically generated) +pub trait WeightInfoZeitgeist { + fn buy(n: u32, o: u32) -> Weight; + fn sell(n: u32, o: u32) -> Weight; +} + +/// Weight functions for zrml_hybrid_router (automatically generated) +pub struct WeightInfo(PhantomData); +impl WeightInfoZeitgeist for WeightInfo { + /// Storage: MarketCommons Markets (r:1 w:0) + /// Proof: MarketCommons Markets (max_values: None, max_size: Some(678), added: 3153, mode: MaxEncodedLen) + /// Storage: Orderbook Orders (r:10 w:11) + /// Proof: Orderbook Orders (max_values: None, max_size: Some(142), added: 2617, mode: MaxEncodedLen) + /// Storage: NeoSwaps Pools (r:1 w:1) + /// Proof: NeoSwaps Pools (max_values: None, max_size: Some(144745), added: 147220, mode: MaxEncodedLen) + /// Storage: System Account (r:12 w:12) + /// Proof: System Account (max_values: None, max_size: Some(132), added: 2607, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:27 w:27) + /// Proof: Tokens Accounts (max_values: None, max_size: Some(123), added: 2598, mode: MaxEncodedLen) + /// Storage: Tokens TotalIssuance (r:16 w:16) + /// Proof: Tokens TotalIssuance (max_values: None, max_size: Some(43), added: 2518, mode: MaxEncodedLen) + /// Storage: Tokens Reserves (r:10 w:10) + /// Proof: Tokens Reserves (max_values: None, max_size: Some(1276), added: 3751, mode: MaxEncodedLen) + /// Storage: Orderbook NextOrderId (r:1 w:1) + /// Proof: Orderbook NextOrderId (max_values: Some(1), max_size: Some(16), added: 511, mode: MaxEncodedLen) + /// Storage: Balances Reserves (r:1 w:1) + /// Proof: Balances Reserves (max_values: None, max_size: Some(1249), added: 3724, mode: MaxEncodedLen) + /// The range of component `n` is `[2, 16]`. + /// The range of component `o` is `[0, 10]`. + fn buy(n: u32, o: u32) -> Weight { + // Proof Size summary in bytes: + // Measured: `2979 + n * (197 ±0) + o * (610 ±0)` + // Estimated: `162420 + o * (11573 ±0) + n * (5116 ±0)` + // Minimum execution time: 385_729 nanoseconds. + Weight::from_parts(391_339_000, 162420) + // Standard Error: 688_692 + .saturating_add(Weight::from_parts(19_248_287, 0).saturating_mul(n.into())) + // Standard Error: 1_123_878 + .saturating_add(Weight::from_parts(251_339_260, 0).saturating_mul(o.into())) + .saturating_add(T::DbWeight::get().reads(7)) + .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().reads((4_u64).saturating_mul(o.into()))) + .saturating_add(T::DbWeight::get().writes(7)) + .saturating_add(T::DbWeight::get().writes((2_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes((4_u64).saturating_mul(o.into()))) + .saturating_add(Weight::from_parts(0, 11573).saturating_mul(o.into())) + .saturating_add(Weight::from_parts(0, 5116).saturating_mul(n.into())) + } + /// Storage: MarketCommons Markets (r:1 w:0) + /// Proof: MarketCommons Markets (max_values: None, max_size: Some(678), added: 3153, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:21 w:21) + /// Proof: Tokens Accounts (max_values: None, max_size: Some(123), added: 2598, mode: MaxEncodedLen) + /// Storage: Orderbook Orders (r:10 w:11) + /// Proof: Orderbook Orders (max_values: None, max_size: Some(142), added: 2617, mode: MaxEncodedLen) + /// Storage: NeoSwaps Pools (r:1 w:1) + /// Proof: NeoSwaps Pools (max_values: None, max_size: Some(144745), added: 147220, mode: MaxEncodedLen) + /// Storage: System Account (r:12 w:12) + /// Proof: System Account (max_values: None, max_size: Some(132), added: 2607, mode: MaxEncodedLen) + /// Storage: Tokens TotalIssuance (r:10 w:10) + /// Proof: Tokens TotalIssuance (max_values: None, max_size: Some(43), added: 2518, mode: MaxEncodedLen) + /// Storage: Balances Reserves (r:10 w:10) + /// Proof: Balances Reserves (max_values: None, max_size: Some(1249), added: 3724, mode: MaxEncodedLen) + /// Storage: Orderbook NextOrderId (r:1 w:1) + /// Proof: Orderbook NextOrderId (max_values: Some(1), max_size: Some(16), added: 511, mode: MaxEncodedLen) + /// Storage: Tokens Reserves (r:1 w:1) + /// Proof: Tokens Reserves (max_values: None, max_size: Some(1276), added: 3751, mode: MaxEncodedLen) + /// The range of component `n` is `[2, 10]`. + /// The range of component `o` is `[0, 10]`. + fn sell(n: u32, o: u32) -> Weight { + // Proof Size summary in bytes: + // Measured: `3118 + n * (196 ±0) + o * (421 ±0)` + // Estimated: `162447 + n * (5116 ±0) + o * (11546 ±0)` + // Minimum execution time: 315_329 nanoseconds. + Weight::from_parts(319_350_000, 162447) + // Standard Error: 1_088_722 + .saturating_add(Weight::from_parts(30_773_014, 0).saturating_mul(n.into())) + // Standard Error: 1_117_505 + .saturating_add(Weight::from_parts(213_114_369, 0).saturating_mul(o.into())) + .saturating_add(T::DbWeight::get().reads(7)) + .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().reads((4_u64).saturating_mul(o.into()))) + .saturating_add(T::DbWeight::get().writes(7)) + .saturating_add(T::DbWeight::get().writes((2_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes((4_u64).saturating_mul(o.into()))) + .saturating_add(Weight::from_parts(0, 5116).saturating_mul(n.into())) + .saturating_add(Weight::from_parts(0, 11546).saturating_mul(o.into())) + } +} diff --git a/zrml/neo-swaps/src/benchmarking.rs b/zrml/neo-swaps/src/benchmarking.rs index 88ec7d9fd..042745799 100644 --- a/zrml/neo-swaps/src/benchmarking.rs +++ b/zrml/neo-swaps/src/benchmarking.rs @@ -19,7 +19,6 @@ use super::*; use crate::{ - consts::*, liquidity_tree::{traits::LiquidityTreeHelper, types::LiquidityTree}, traits::{liquidity_shares_manager::LiquiditySharesManager, pool_operations::PoolOperations}, AssetOf, BalanceOf, MarketIdOf, Pallet as NeoSwaps, Pools, MIN_SPOT_PRICE, @@ -34,7 +33,7 @@ use frame_system::RawOrigin; use orml_traits::MultiCurrency; use sp_runtime::{traits::Get, Perbill, SaturatedConversion}; use zeitgeist_primitives::{ - constants::CENT, + constants::{base_multiples::*, CENT}, math::fixed::{BaseProvider, FixedDiv, FixedMul, ZeitgeistBase}, traits::CompleteSetOperationsApi, types::{Asset, Market, MarketCreation, MarketPeriod, MarketStatus, MarketType, ScoringRule}, diff --git a/zrml/neo-swaps/src/consts.rs b/zrml/neo-swaps/src/consts.rs index 19d0af5f7..82758a191 100644 --- a/zrml/neo-swaps/src/consts.rs +++ b/zrml/neo-swaps/src/consts.rs @@ -23,51 +23,3 @@ pub(crate) const EXP_NUMERICAL_LIMIT: u128 = 10; pub(crate) const LN_NUMERICAL_LIMIT: u128 = BASE / 10; /// The maximum number of assets allowed in a pool. pub(crate) const MAX_ASSETS: u16 = 128; - -pub(crate) const _1: u128 = BASE; -pub(crate) const _2: u128 = 2 * _1; -pub(crate) const _3: u128 = 3 * _1; -pub(crate) const _4: u128 = 4 * _1; -pub(crate) const _5: u128 = 5 * _1; -pub(crate) const _6: u128 = 6 * _1; -pub(crate) const _7: u128 = 7 * _1; -pub(crate) const _8: u128 = 8 * _1; -pub(crate) const _9: u128 = 9 * _1; -pub(crate) const _10: u128 = 10 * _1; -pub(crate) const _11: u128 = 11 * _1; -pub(crate) const _12: u128 = 12 * _1; -pub(crate) const _14: u128 = 14 * _1; -pub(crate) const _17: u128 = 17 * _1; -pub(crate) const _20: u128 = 20 * _1; -pub(crate) const _23: u128 = 23 * _1; -pub(crate) const _24: u128 = 24 * _1; -pub(crate) const _30: u128 = 30 * _1; -pub(crate) const _36: u128 = 36 * _1; -pub(crate) const _40: u128 = 40 * _1; -pub(crate) const _70: u128 = 70 * _1; -pub(crate) const _80: u128 = 80 * _1; -pub(crate) const _100: u128 = 100 * _1; -pub(crate) const _101: u128 = 101 * _1; -pub(crate) const _444: u128 = 444 * _1; -pub(crate) const _500: u128 = 500 * _1; -pub(crate) const _777: u128 = 777 * _1; -pub(crate) const _1000: u128 = 1_000 * _1; - -pub(crate) const _1_2: u128 = _1 / 2; - -pub(crate) const _1_3: u128 = _1 / 3; -pub(crate) const _2_3: u128 = _2 / 3; - -pub(crate) const _1_4: u128 = _1 / 4; -pub(crate) const _3_4: u128 = _3 / 4; - -pub(crate) const _1_5: u128 = _1 / 5; - -pub(crate) const _1_6: u128 = _1 / 6; -pub(crate) const _5_6: u128 = _5 / 6; - -pub(crate) const _1_10: u128 = _1 / 10; -pub(crate) const _2_10: u128 = _2 / 10; -pub(crate) const _3_10: u128 = _3 / 10; -pub(crate) const _4_10: u128 = _4 / 10; -pub(crate) const _9_10: u128 = _9 / 10; diff --git a/zrml/neo-swaps/src/lib.rs b/zrml/neo-swaps/src/lib.rs index 1ca79145f..66c6f9be1 100644 --- a/zrml/neo-swaps/src/lib.rs +++ b/zrml/neo-swaps/src/lib.rs @@ -61,15 +61,16 @@ mod pallet { use scale_info::TypeInfo; use sp_runtime::{ traits::{AccountIdConversion, CheckedSub, Saturating, Zero}, - DispatchError, DispatchResult, SaturatedConversion, + DispatchError, DispatchResult, Perbill, SaturatedConversion, }; use zeitgeist_primitives::{ constants::{BASE, CENT}, + hybrid_router_api_types::AmmTrade, math::{ checked_ops_res::{CheckedAddRes, CheckedSubRes}, fixed::{BaseProvider, FixedDiv, FixedMul, ZeitgeistBase}, }, - traits::{CompleteSetOperationsApi, DeployPoolApi, DistributeFees}, + traits::{CompleteSetOperationsApi, DeployPoolApi, DistributeFees, HybridRouterAmmApi}, types::{Asset, MarketStatus, MarketType, ScalarPosition, ScoringRule}, }; use zrml_market_commons::MarketCommonsPalletApi; @@ -100,6 +101,7 @@ mod pallet { <::MarketCommons as MarketCommonsPalletApi>::MarketId; pub(crate) type LiquidityTreeOf = LiquidityTree::MaxLiquidityTreeDepth>; pub(crate) type PoolOf = Pool>; + pub(crate) type AmmTradeOf = AmmTrade>; #[pallet::config] pub trait Config: frame_system::Config { @@ -164,8 +166,7 @@ mod pallet { external_fee_amount: BalanceOf, }, /// Informant sold a position. `amount_out` is the amount of collateral received by `who`, - /// with swap and external fees not yet deducted. The actual amount received is - /// `amount_out - swap_fee_amount - external_fee_amount`. + /// with swap and external fees already deducted. SellExecuted { who: T::AccountId, market_id: MarketIdOf, @@ -324,7 +325,7 @@ mod pallet { let who = ensure_signed(origin)?; let asset_count_real = T::MarketCommons::market(&market_id)?.outcomes(); ensure!(asset_count == asset_count_real, Error::::IncorrectAssetCount); - Self::do_buy(who, market_id, asset_out, amount_in, min_amount_out)?; + let _ = Self::do_buy(who, market_id, asset_out, amount_in, min_amount_out)?; Ok(Some(T::WeightInfo::buy(asset_count as u32)).into()) } @@ -368,7 +369,7 @@ mod pallet { let who = ensure_signed(origin)?; let asset_count_real = T::MarketCommons::market(&market_id)?.outcomes(); ensure!(asset_count == asset_count_real, Error::::IncorrectAssetCount); - Self::do_sell(who, market_id, asset_in, amount_in, min_amount_out)?; + let _ = Self::do_sell(who, market_id, asset_in, amount_in, min_amount_out)?; Ok(Some(T::WeightInfo::sell(asset_count as u32)).into()) } @@ -535,7 +536,7 @@ mod pallet { asset_out: AssetOf, amount_in: BalanceOf, min_amount_out: BalanceOf, - ) -> DispatchResult { + ) -> Result, DispatchError> { ensure!(amount_in != Zero::zero(), Error::::ZeroAmount); let market = T::MarketCommons::market(&market_id)?; ensure!(market.status == MarketStatus::Active, Error::::MarketNotActive); @@ -584,7 +585,7 @@ mod pallet { swap_fee_amount, external_fee_amount, }); - Ok(()) + Ok(AmmTrade { amount_in, amount_out, swap_fee_amount, external_fee_amount }) }) } @@ -595,7 +596,7 @@ mod pallet { asset_in: AssetOf, amount_in: BalanceOf, min_amount_out: BalanceOf, - ) -> DispatchResult { + ) -> Result, DispatchError> { ensure!(amount_in != Zero::zero(), Error::::ZeroAmount); let market = T::MarketCommons::market(&market_id)?; ensure!(market.status == MarketStatus::Active, Error::::MarketNotActive); @@ -654,11 +655,16 @@ mod pallet { market_id, asset_in, amount_in, - amount_out, + amount_out: amount_out_minus_fees, swap_fee_amount, external_fee_amount, }); - Ok(()) + Ok(AmmTrade { + amount_in, + amount_out: amount_out_minus_fees, + swap_fee_amount, + external_fee_amount, + }) }) } @@ -979,4 +985,89 @@ mod pallet { Self::do_deploy_pool(who, market_id, amount, spot_prices, swap_fee) } } + + impl Pallet { + fn amount_including_fee_surplus( + amount: BalanceOf, + fee_fractional: BalanceOf, + ) -> Result, DispatchError> { + let fee_divisor = ZeitgeistBase::>::get()? + .checked_sub(&fee_fractional) + .ok_or(Error::::Unexpected)?; + amount.bdiv(fee_divisor) + } + + fn total_fee_fractional( + swap_fee: BalanceOf, + external_fee_percentage: Perbill, + ) -> Result, DispatchError> { + let external_fee_fractional = + external_fee_percentage.mul_floor(ZeitgeistBase::>::get()?); + swap_fee.checked_add_res(&external_fee_fractional) + } + } + + impl HybridRouterAmmApi for Pallet { + type AccountId = T::AccountId; + type MarketId = MarketIdOf; + type Balance = BalanceOf; + type Asset = AssetOf; + + fn pool_exists(market_id: Self::MarketId) -> bool { + Pools::::contains_key(market_id) + } + + fn get_spot_price( + market_id: Self::MarketId, + asset: Self::Asset, + ) -> Result { + let pool = Pools::::get(market_id).ok_or(Error::::PoolNotFound)?; + pool.calculate_spot_price(asset) + } + + fn calculate_buy_amount_until( + market_id: Self::MarketId, + asset: Self::Asset, + until: Self::Balance, + ) -> Result { + let pool = Pools::::get(market_id).ok_or(Error::::PoolNotFound)?; + let buy_amount = pool.calculate_buy_amount_until(asset, until)?; + let total_fee_fractional = Self::total_fee_fractional( + pool.swap_fee, + T::ExternalFees::fee_percentage(market_id), + )?; + let buy_amount_plus_fees = + Self::amount_including_fee_surplus(buy_amount, total_fee_fractional)?; + Ok(buy_amount_plus_fees) + } + + fn buy( + who: Self::AccountId, + market_id: Self::MarketId, + asset_out: Self::Asset, + amount_in: Self::Balance, + min_amount_out: Self::Balance, + ) -> Result, DispatchError> { + Self::do_buy(who, market_id, asset_out, amount_in, min_amount_out) + } + + fn calculate_sell_amount_until( + market_id: Self::MarketId, + asset: Self::Asset, + until: Self::Balance, + ) -> Result { + let pool = Pools::::get(market_id).ok_or(Error::::PoolNotFound)?; + pool.calculate_sell_amount_until(asset, until) + } + + fn sell( + who: Self::AccountId, + market_id: Self::MarketId, + asset_out: Self::Asset, + amount_in: Self::Balance, + min_amount_out: Self::Balance, + ) -> Result, DispatchError> { + Self::do_sell(who, market_id, asset_out, amount_in, min_amount_out) + } + } } diff --git a/zrml/neo-swaps/src/liquidity_tree/tests/mod.rs b/zrml/neo-swaps/src/liquidity_tree/tests/mod.rs index 9b23c8e38..49ff92aab 100644 --- a/zrml/neo-swaps/src/liquidity_tree/tests/mod.rs +++ b/zrml/neo-swaps/src/liquidity_tree/tests/mod.rs @@ -1,4 +1,4 @@ -// Copyright 2023 Forecasting Technologies LTD. +// Copyright 2023-2024 Forecasting Technologies LTD. // // This file is part of Zeitgeist. // @@ -18,9 +18,7 @@ #![cfg(all(feature = "mock", test))] use crate::{ - assert_liquidity_tree_state, - consts::*, - create_b_tree_map, + assert_liquidity_tree_state, create_b_tree_map, liquidity_tree::{ traits::liquidity_tree_helper::LiquidityTreeHelper, types::{LiquidityTreeError, Node}, @@ -32,6 +30,7 @@ use crate::{ use alloc::collections::BTreeMap; use frame_support::assert_err; use sp_runtime::traits::Zero; +use zeitgeist_primitives::constants::base_multiples::*; mod deposit_fees; mod exit; diff --git a/zrml/neo-swaps/src/math.rs b/zrml/neo-swaps/src/math.rs index 276b20fc8..92aa104be 100644 --- a/zrml/neo-swaps/src/math.rs +++ b/zrml/neo-swaps/src/math.rs @@ -44,7 +44,10 @@ use crate::{ use alloc::vec::Vec; use core::marker::PhantomData; use fixed::FixedU128; -use sp_runtime::{DispatchError, SaturatedConversion}; +use sp_runtime::{ + traits::{One, Zero}, + DispatchError, SaturatedConversion, +}; use typenum::U80; type Fractional = U80; @@ -81,6 +84,18 @@ pub(crate) trait MathOps { amount: BalanceOf, liquidity: BalanceOf, ) -> Result, DispatchError>; + + fn calculate_buy_amount_until( + until: BalanceOf, + liquidity: BalanceOf, + spot_price: BalanceOf, + ) -> Result, DispatchError>; + + fn calculate_sell_amount_until( + until: BalanceOf, + liquidity: BalanceOf, + spot_price: BalanceOf, + ) -> Result, DispatchError>; } pub(crate) struct Math(PhantomData); @@ -149,6 +164,32 @@ impl MathOps for Math { .map(|result| result.saturated_into()) .ok_or_else(|| Error::::MathError.into()) } + + fn calculate_buy_amount_until( + until: BalanceOf, + liquidity: BalanceOf, + spot_price: BalanceOf, + ) -> Result, DispatchError> { + let until = until.saturated_into(); + let liquidity = liquidity.saturated_into(); + let spot_price = spot_price.saturated_into(); + detail::calculate_buy_amount_until(until, liquidity, spot_price) + .map(|result| result.saturated_into()) + .ok_or_else(|| Error::::MathError.into()) + } + + fn calculate_sell_amount_until( + until: BalanceOf, + liquidity: BalanceOf, + spot_price: BalanceOf, + ) -> Result, DispatchError> { + let until = until.saturated_into(); + let liquidity = liquidity.saturated_into(); + let spot_price = spot_price.saturated_into(); + detail::calculate_sell_amount_until(until, liquidity, spot_price) + .map(|result| result.saturated_into()) + .ok_or_else(|| Error::::MathError.into()) + } } mod detail { @@ -219,6 +260,32 @@ mod detail { from_fixed(result_fixed) } + pub(super) fn calculate_buy_amount_until( + until: u128, + liquidity: u128, + spot_price: u128, + ) -> Option { + let result_fixed = calculate_buy_amount_until_fixed( + to_fixed(until)?, + to_fixed(liquidity)?, + to_fixed(spot_price)?, + )?; + from_fixed(result_fixed) + } + + pub(super) fn calculate_sell_amount_until( + until: u128, + liquidity: u128, + spot_price: u128, + ) -> Option { + let result_fixed = calculate_sell_amount_until_fixed( + to_fixed(until)?, + to_fixed(liquidity)?, + to_fixed(spot_price)?, + )?; + from_fixed(result_fixed) + } + fn to_fixed(value: B) -> Option where B: Into + From, @@ -308,6 +375,47 @@ mod detail { }; exp_x_over_b.checked_add(exp_neg_r_over_b)?.checked_sub(FixedType::checked_from_num(1)?) } + + /// Calculate `-b * ln( (1-q) / (1-p_i(r)) )` where `q = until` if `q > p_i(r)`; otherwise, + /// return zero. + pub(super) fn calculate_buy_amount_until_fixed( + until: FixedType, + liquidity: FixedType, + spot_price: FixedType, + ) -> Option { + let numerator = FixedType::one().checked_sub(until)?; + let denominator = FixedType::one().checked_sub(spot_price)?; + let ln_arg = numerator.checked_div(denominator)?; + let (ln_result, ln_neg) = ln(ln_arg).ok()?; + if !ln_neg { + return Some(FixedType::zero()); + } + liquidity.checked_mul(ln_result) + } + + /// Calculate `b * ln( (1 / (1 / p_i(r) - 1)) - (1 / q * (1 / p_i(r) - 1)) )` where `q = until` + /// if `q < p_i(r)`; otherwise, return zero. + pub(super) fn calculate_sell_amount_until_fixed( + until: FixedType, + liquidity: FixedType, + spot_price: FixedType, + ) -> Option { + let first_numerator = FixedType::one(); + let first_denominator = + (FixedType::one().checked_div(spot_price)?).checked_sub(FixedType::one())?; + let second_numerator = FixedType::one(); + let second_denominator = until.checked_mul( + FixedType::one().checked_div(spot_price)?.checked_sub(FixedType::one())?, + )?; + let first_term = first_numerator.checked_div(first_denominator)?; + let second_term = second_numerator.checked_div(second_denominator)?; + let ln_arg = second_term.checked_sub(first_term)?; + let (ln_result, ln_neg) = ln(ln_arg).ok()?; + if ln_neg { + return Some(FixedType::zero()); + } + liquidity.checked_mul(ln_result) + } } mod transcendental { @@ -397,10 +505,11 @@ mod transcendental { #[cfg(test)] mod tests { use super::*; - use crate::{consts::*, mock::Runtime as MockRuntime}; + use crate::{mock::Runtime as MockRuntime, MAX_SPOT_PRICE, MIN_SPOT_PRICE}; use alloc::str::FromStr; use frame_support::assert_err; use test_case::test_case; + use zeitgeist_primitives::constants::base_multiples::*; type MockBalance = BalanceOf; type MockMath = Math; @@ -606,4 +715,106 @@ mod tests { exp(FixedType::checked_from_num(EXP_OVERFLOW_THRESHOLD).unwrap(), neg).unwrap(); assert_eq!(result, expected); } + + #[test_case(_9_10, _10, _1_10, 219722457734)] // Large price shift + #[test_case(_4_10, _10, _3_10, 15415067983)] // Small price shift + #[test_case(_3_10, _10, _4_10, 0)] // Zero buy amount + #[test_case(_4_10, _10, _4_10, 0)] // Zero buy amount + #[test_case(MAX_SPOT_PRICE, 188_739_165_817, MIN_SPOT_PRICE, 999_053_937_034; "leap_up")] + #[test_case(MIN_SPOT_PRICE, _10, MAX_SPOT_PRICE, 0; "leap_down")] + #[test_case( + MIN_SPOT_PRICE + 100_000, + 132_117_416_072, + MIN_SPOT_PRICE, + 1_327_820; + "step_up_low" + )] + #[test_case( + MAX_SPOT_PRICE, + 11_324_349_949, + MAX_SPOT_PRICE - 100_000, + 22_626_081; + "step_up_high" + )] + #[test_case(MIN_SPOT_PRICE, _1, MIN_SPOT_PRICE + 100_000, 0; "step_down_low")] + #[test_case(MAX_SPOT_PRICE - 100_000, _1, MAX_SPOT_PRICE, 0; "step_down_high")] + fn calculate_buy_amount_until_works( + until: MockBalance, + liquidity: MockBalance, + spot_price: MockBalance, + expected: MockBalance, + ) { + assert_eq!( + MockMath::calculate_buy_amount_until(until, liquidity, spot_price).unwrap(), + expected + ); + } + + #[test_case(_1, _10, _1_2; "until too large")] + #[test_case(_1_2, _10, _1; "spot price too large")] + #[test_case(u128::MAX, _10, _1_2; "until limit")] + #[test_case(_3_4, u128::MAX, _1_2; "liquidity limit")] + #[test_case(_3_4, _10, u128::MAX; "spot price limit")] + fn calculate_buy_amount_until_throws_math_error( + until: MockBalance, + liquidity: MockBalance, + spot_price: MockBalance, + ) { + assert_err!( + MockMath::calculate_buy_amount_until(until, liquidity, spot_price), + Error::::MathError + ); + } + + #[test_case(_1_10, _10, _9_10, 439444915467)] // Large price shift + #[test_case(_1_10, _10, _2_10, 81093021622)] // Small price shift + #[test_case(_2_10, _10, _1_10, 0)] // Zero sell amount + #[test_case(_1_10, _10, _1_10, 0)] // Zero sell amount + #[test_case(_1_100, _10, _1_2, 459511985013)] // Very small until + #[test_case(1_2, _10, 1_100, 451815891780)] // Very small spot_price + #[test_case(MAX_SPOT_PRICE, 188_739_165_817, MIN_SPOT_PRICE, 0; "leap_up")] + #[test_case(MIN_SPOT_PRICE, 11_324_349_949, MAX_SPOT_PRICE, 119_886_472_444; "leap_down")] + #[test_case(MIN_SPOT_PRICE + 100_000, 132_117_416_072, MIN_SPOT_PRICE, 0; "step_up_low")] + #[test_case(MAX_SPOT_PRICE, 11_324_349_949, MAX_SPOT_PRICE - 100_000, 0; "step_up_high")] + #[test_case( + MIN_SPOT_PRICE, + 186_922_262_798, + MIN_SPOT_PRICE + 100_000, + 375_349_804; + "step_down_low" + )] + #[test_case( + MAX_SPOT_PRICE - 100_000, + 43_410_008_138, + MAX_SPOT_PRICE, + 87_169_596; + "step_down_high" + )] + fn calculate_sell_amount_until_fixed_works( + until: MockBalance, + liquidity: MockBalance, + spot_price: MockBalance, + expected: MockBalance, + ) { + assert_eq!( + MockMath::calculate_sell_amount_until(until, liquidity, spot_price).unwrap(), + expected + ); + } + + #[test_case(0, _10, _1_2; "until too small")] + #[test_case(_1_2, _10, _1; "spot price too large")] + #[test_case(u128::MAX, _10, _3_4; "until limit")] + #[test_case(_1_2, u128::MAX, _3_4; "liquidity limit")] + #[test_case(_1_2, _10, u128::MAX; "spot price limit")] + fn calculate_sell_amount_until_throws_math_error( + until: MockBalance, + liquidity: MockBalance, + spot_price: MockBalance, + ) { + assert_err!( + MockMath::calculate_sell_amount_until(until, liquidity, spot_price), + Error::::MathError + ); + } } diff --git a/zrml/neo-swaps/src/mock.rs b/zrml/neo-swaps/src/mock.rs index a6d519fd8..2be6543b0 100644 --- a/zrml/neo-swaps/src/mock.rs +++ b/zrml/neo-swaps/src/mock.rs @@ -37,26 +37,29 @@ use orml_traits::MultiCurrency; use sp_runtime::{ testing::Header, traits::{BlakeTwo256, Get, IdentityLookup, Zero}, - DispatchResult, Percent, SaturatedConversion, + DispatchResult, Perbill, Percent, SaturatedConversion, }; #[cfg(feature = "parachain")] use zeitgeist_primitives::types::Asset; use zeitgeist_primitives::{ - constants::mock::{ - AddOutcomePeriod, AggregationPeriod, AppealBond, AppealPeriod, AuthorizedPalletId, - BlockHashCount, BlocksPerYear, CloseEarlyBlockPeriod, CloseEarlyDisputeBond, - CloseEarlyProtectionBlockPeriod, CloseEarlyProtectionTimeFramePeriod, - CloseEarlyRequestBond, CloseEarlyTimeFramePeriod, CorrectionPeriod, CourtPalletId, - ExistentialDeposit, ExistentialDeposits, GdVotingPeriod, GetNativeCurrencyId, - GlobalDisputeLockId, GlobalDisputesPalletId, InflationPeriod, LiquidityMiningPalletId, - LockId, MaxAppeals, MaxApprovals, MaxCourtParticipants, MaxCreatorFee, MaxDelegations, - MaxDisputeDuration, MaxDisputes, MaxEditReasonLen, MaxGlobalDisputeVotes, MaxGracePeriod, - MaxLiquidityTreeDepth, MaxLocks, MaxMarketLifetime, MaxOracleDuration, MaxOwners, - MaxRejectReasonLen, MaxReserves, MaxSelectedDraws, MaxYearlyInflation, MinCategories, - MinDisputeDuration, MinJurorStake, MinOracleDuration, MinOutcomeVoteAmount, MinimumPeriod, - NeoMaxSwapFee, NeoSwapsPalletId, OutcomeBond, OutcomeFactor, OutsiderBond, PmPalletId, - RemoveKeysLimit, RequestInterval, SimpleDisputesPalletId, TreasuryPalletId, VotePeriod, - VotingOutcomeFee, CENT, + constants::{ + base_multiples::*, + mock::{ + AddOutcomePeriod, AggregationPeriod, AppealBond, AppealPeriod, AuthorizedPalletId, + BlockHashCount, BlocksPerYear, CloseEarlyBlockPeriod, CloseEarlyDisputeBond, + CloseEarlyProtectionBlockPeriod, CloseEarlyProtectionTimeFramePeriod, + CloseEarlyRequestBond, CloseEarlyTimeFramePeriod, CorrectionPeriod, CourtPalletId, + ExistentialDeposit, ExistentialDeposits, GdVotingPeriod, GetNativeCurrencyId, + GlobalDisputeLockId, GlobalDisputesPalletId, InflationPeriod, LiquidityMiningPalletId, + LockId, MaxAppeals, MaxApprovals, MaxCourtParticipants, MaxCreatorFee, MaxDelegations, + MaxDisputeDuration, MaxDisputes, MaxEditReasonLen, MaxGlobalDisputeVotes, + MaxGracePeriod, MaxLiquidityTreeDepth, MaxLocks, MaxMarketLifetime, MaxOracleDuration, + MaxOwners, MaxRejectReasonLen, MaxReserves, MaxSelectedDraws, MaxYearlyInflation, + MinCategories, MinDisputeDuration, MinJurorStake, MinOracleDuration, + MinOutcomeVoteAmount, MinimumPeriod, NeoMaxSwapFee, NeoSwapsPalletId, OutcomeBond, + OutcomeFactor, OutsiderBond, PmPalletId, RemoveKeysLimit, RequestInterval, + SimpleDisputesPalletId, TreasuryPalletId, VotePeriod, VotingOutcomeFee, BASE, CENT, + }, }, math::fixed::FixedMul, traits::{DeployPoolApi, DistributeFees}, @@ -142,6 +145,10 @@ where Err(_) => Zero::zero(), } } + + fn fee_percentage(_market_id: Self::MarketId) -> Perbill { + Perbill::from_rational(EXTERNAL_FEES, BASE) + } } pub type UncheckedExtrinsic = UncheckedExtrinsicTest; diff --git a/zrml/neo-swaps/src/tests/mod.rs b/zrml/neo-swaps/src/tests/mod.rs index a513fafa3..8dc8eb78d 100644 --- a/zrml/neo-swaps/src/tests/mod.rs +++ b/zrml/neo-swaps/src/tests/mod.rs @@ -1,4 +1,4 @@ -// Copyright 2023 Forecasting Technologies LTD. +// Copyright 2023-2024 Forecasting Technologies LTD. // // This file is part of Zeitgeist. // @@ -31,7 +31,7 @@ use frame_support::{assert_noop, assert_ok}; use orml_traits::MultiCurrency; use sp_runtime::Perbill; use zeitgeist_primitives::{ - constants::CENT, + constants::{base_multiples::*, CENT}, math::fixed::{FixedDiv, FixedMul}, types::{ AccountIdTest, Asset, Deadlines, MarketCreation, MarketId, MarketPeriod, MarketStatus, diff --git a/zrml/neo-swaps/src/traits/pool_operations.rs b/zrml/neo-swaps/src/traits/pool_operations.rs index 8f1a58edb..57c352661 100644 --- a/zrml/neo-swaps/src/traits/pool_operations.rs +++ b/zrml/neo-swaps/src/traits/pool_operations.rs @@ -1,4 +1,4 @@ -// Copyright 2023 Forecasting Technologies LTD. +// Copyright 2023-2024 Forecasting Technologies LTD. // // This file is part of Zeitgeist. // @@ -94,4 +94,30 @@ pub(crate) trait PoolOperations { asset: AssetOf, amount_in: BalanceOf, ) -> Result, DispatchError>; + + /// Calculates the amount a user has to buy to move the price of `asset` to `until`. Returns + /// zero if the current spot price is above or equal to `until`. + /// + /// # Parameters + /// + /// - `asset`: The asset to calculate the buy amount for. + /// - `until`: The maximum price. + fn calculate_buy_amount_until( + &self, + asset: AssetOf, + until: BalanceOf, + ) -> Result, DispatchError>; + + /// Calculates the amount a user has to sell to move the price of `asset` to `until`. Returns + /// zero if the current spot price is below or equal to `until`. + /// + /// # Parameters + /// + /// - `asset`: The asset to calculate the sell amount for. + /// - `until`: The minimum price. + fn calculate_sell_amount_until( + &self, + asset: AssetOf, + until: BalanceOf, + ) -> Result, DispatchError>; } diff --git a/zrml/neo-swaps/src/types/pool.rs b/zrml/neo-swaps/src/types/pool.rs index 301dc89d0..dd7cf6ede 100644 --- a/zrml/neo-swaps/src/types/pool.rs +++ b/zrml/neo-swaps/src/types/pool.rs @@ -116,6 +116,26 @@ where let reserve = self.reserve_of(&asset)?; Math::::calculate_buy_ln_argument(reserve, amount_in, self.liquidity_parameter) } + + fn calculate_buy_amount_until( + &self, + asset: AssetOf, + until: BalanceOf, + ) -> Result, DispatchError> { + let reserve = self.reserve_of(&asset)?; + let spot_price = Math::::calculate_spot_price(reserve, self.liquidity_parameter)?; + Math::::calculate_buy_amount_until(until, self.liquidity_parameter, spot_price) + } + + fn calculate_sell_amount_until( + &self, + asset: AssetOf, + until: BalanceOf, + ) -> Result, DispatchError> { + let reserve = self.reserve_of(&asset)?; + let spot_price = Math::::calculate_spot_price(reserve, self.liquidity_parameter)?; + Math::::calculate_sell_amount_until(until, self.liquidity_parameter, spot_price) + } } // TODO(#1214): Replace BTreeMap with BoundedBTreeMap and remove the unnecessary `MaxEncodedLen` diff --git a/zrml/orderbook/src/lib.rs b/zrml/orderbook/src/lib.rs index 8fbc2c25c..3eeedd49f 100644 --- a/zrml/orderbook/src/lib.rs +++ b/zrml/orderbook/src/lib.rs @@ -37,6 +37,7 @@ use orml_traits::{BalanceStatus, MultiCurrency, NamedMultiReservableCurrency}; pub use pallet::*; use sp_runtime::traits::{Get, Zero}; use zeitgeist_primitives::{ + hybrid_router_api_types::{ExternalFee, OrderbookTrade}, math::checked_ops_res::{CheckedAddRes, CheckedSubRes}, orderbook::{Order, OrderId}, traits::{DistributeFees, HybridRouterOrderbookApi, MarketCommonsPalletApi}, @@ -109,6 +110,8 @@ mod pallet { MomentOf, AssetOf, >; + pub(crate) type OrderbookTradeOf = OrderbookTrade, BalanceOf>; + pub(crate) type ExternalFeeOf = ExternalFee, BalanceOf>; #[pallet::pallet] #[pallet::storage_version(STORAGE_VERSION)] @@ -134,6 +137,7 @@ mod pallet { filled_taker_amount: BalanceOf, unfilled_maker_amount: BalanceOf, unfilled_taker_amount: BalanceOf, + external_fee: ExternalFeeOf, }, OrderPlaced { order_id: OrderId, @@ -206,7 +210,7 @@ mod pallet { ) -> DispatchResult { let taker = ensure_signed(origin)?; - Self::do_fill_order(order_id, taker, maker_partial_fill)?; + let _ = Self::do_fill_order(order_id, taker, maker_partial_fill)?; Ok(()) } @@ -321,16 +325,22 @@ mod pallet { Ok(()) } - /// Charge the external fees from `taker` and return the adjusted maker fill. + /// Charge the external fees in base asset and return the adjusted maker fill. + /// + /// `maker_fill` is the amount that the maker wants to have. + /// `taker_fill` is the amount that the taker wants to have. + /// It does not charge fees from the outcome asset. + /// + /// Returns the adjusted maker fill and the external fee. fn charge_external_fees( order_data: &OrderOf, base_asset: AssetOf, maker_fill: BalanceOf, taker: &AccountIdOf, taker_fill: BalanceOf, - ) -> Result, DispatchError> { - let maker_asset_is_base = order_data.maker_asset == base_asset; - let base_asset_fill = if maker_asset_is_base { + ) -> Result<(BalanceOf, ExternalFeeOf), DispatchError> { + let maker_asset_is_base_asset = order_data.maker_asset == base_asset; + let base_asset_fill = if maker_asset_is_base_asset { taker_fill } else { debug_assert!(order_data.taker_asset == base_asset); @@ -342,14 +352,14 @@ mod pallet { taker, base_asset_fill, ); - if maker_asset_is_base { - // maker_fill is the amount that the maker wants to have (outcome asset from taker) - // do not charge fees from outcome assets, but rather from the base asset - Ok(maker_fill) + if maker_asset_is_base_asset { + Ok((maker_fill, ExternalFeeOf:: { account: taker.clone(), amount: fee_amount })) } else { - // accounting fees from the taker, - // who is responsible to pay the base asset minus fees to the maker - Ok(maker_fill.checked_sub_res(&fee_amount)?) + Ok(( + // maker gets less base asset, so the maker paid the fees + maker_fill.checked_sub_res(&fee_amount)?, + ExternalFeeOf:: { account: order_data.maker.clone(), amount: fee_amount }, + )) } } @@ -357,7 +367,7 @@ mod pallet { order_id: OrderId, taker: AccountIdOf, maker_partial_fill: Option>, - ) -> DispatchResult { + ) -> Result, DispatchError> { let mut order_data = >::get(order_id).ok_or(Error::::OrderDoesNotExist)?; let market = T::MarketCommons::market(&order_data.market_id)?; debug_assert!( @@ -395,8 +405,7 @@ mod pallet { BalanceStatus::Free, )?; - // always charge fees from the base asset and not the outcome asset - let maybe_adjusted_maker_fill = Self::charge_external_fees( + let (maybe_adjusted_maker_fill, external_fee) = Self::charge_external_fees( &order_data, base_asset, maker_fill, @@ -431,9 +440,14 @@ mod pallet { filled_taker_amount: maker_fill, unfilled_maker_amount: order_data.maker_amount, unfilled_taker_amount: order_data.taker_amount, + external_fee: external_fee.clone(), }); - Ok(()) + Ok(OrderbookTrade { + filled_maker_amount: taker_fill, + filled_taker_amount: maker_fill, + external_fee, + }) } fn do_place_order( @@ -510,7 +524,7 @@ mod pallet { who: Self::AccountId, order_id: Self::OrderId, maker_partial_fill: Option, - ) -> DispatchResult { + ) -> Result, DispatchError> { Self::do_fill_order(order_id, who, maker_partial_fill) } diff --git a/zrml/orderbook/src/mock.rs b/zrml/orderbook/src/mock.rs index f46bc8d7c..46771b5b0 100644 --- a/zrml/orderbook/src/mock.rs +++ b/zrml/orderbook/src/mock.rs @@ -31,7 +31,7 @@ use sp_runtime::{ use zeitgeist_primitives::{ constants::mock::{ BlockHashCount, ExistentialDeposit, ExistentialDeposits, GetNativeCurrencyId, MaxLocks, - MaxReserves, MinimumPeriod, OrderbookPalletId, BASE, + MaxReserves, MinimumPeriod, OrderbookPalletId, BASE, CENT, }, traits::DistributeFees, types::{ @@ -47,12 +47,18 @@ pub const MARKET_CREATOR: AccountIdTest = 42; pub const INITIAL_BALANCE: Balance = 100 * BASE; +pub const EXTERNAL_FEES: Balance = CENT; + parameter_types! { pub const FeeAccount: AccountIdTest = MARKET_CREATOR; } +pub fn fee_percentage() -> Perbill { + Perbill::from_rational(EXTERNAL_FEES, BASE) +} + pub fn calculate_fee(amount: BalanceOf) -> BalanceOf { - Perbill::from_rational(1u64, 100u64).mul_floor(amount.saturated_into::>()) + fee_percentage::().mul_floor(amount.saturated_into::>()) } pub struct ExternalFees(PhantomData, PhantomData); @@ -78,6 +84,10 @@ where Err(_) => Zero::zero(), } } + + fn fee_percentage(_market_id: Self::MarketId) -> Perbill { + fee_percentage::() + } } construct_runtime!( diff --git a/zrml/orderbook/src/tests.rs b/zrml/orderbook/src/tests.rs index dc5003c1d..aed88b2ef 100644 --- a/zrml/orderbook/src/tests.rs +++ b/zrml/orderbook/src/tests.rs @@ -25,6 +25,7 @@ use sp_runtime::{Perbill, Perquintill}; use test_case::test_case; use zeitgeist_primitives::{ constants::BASE, + hybrid_router_api_types::ExternalFee, types::{Asset, MarketStatus, MarketType, ScalarPosition, ScoringRule}, }; use zrml_market_commons::{Error as MError, MarketCommonsPalletApi, Markets}; @@ -570,6 +571,8 @@ fn it_fills_order_fully_maker_outcome_asset() { let reserved_bob = AssetManager::reserved_balance(maker_asset, &BOB); assert_eq!(reserved_bob, 0); + let external_fee = ExternalFee { account: BOB, amount: taker_fees }; + System::assert_last_event( Event::::OrderFilled { order_id, @@ -579,6 +582,7 @@ fn it_fills_order_fully_maker_outcome_asset() { filled_taker_amount: taker_amount, unfilled_maker_amount: 0, unfilled_taker_amount: 0, + external_fee, } .into(), ); @@ -627,6 +631,9 @@ fn it_fills_order_fully_maker_base_asset() { let reserved_bob = AssetManager::reserved_balance(taker_asset, &BOB); assert_eq!(reserved_bob, 0); + let external_fee_amount = calculate_fee::(maker_amount); + let external_fee = ExternalFee { account: BOB, amount: external_fee_amount }; + System::assert_last_event( Event::::OrderFilled { order_id, @@ -636,6 +643,7 @@ fn it_fills_order_fully_maker_base_asset() { filled_taker_amount: taker_amount, unfilled_taker_amount: 0, unfilled_maker_amount: 0, + external_fee, } .into(), ); @@ -699,6 +707,7 @@ fn it_fills_order_partially_maker_base_asset() { let filled_maker_amount = Perquintill::from_rational(alice_portion, taker_amount).mul_floor(maker_amount); let unfilled_maker_amount = maker_amount - filled_maker_amount; + let external_fee_amount = calculate_fee::(filled_maker_amount); assert_eq!( order, @@ -712,6 +721,8 @@ fn it_fills_order_partially_maker_base_asset() { } ); + let external_fee = ExternalFee { account: BOB, amount: external_fee_amount }; + System::assert_last_event( Event::::OrderFilled { order_id, @@ -721,6 +732,7 @@ fn it_fills_order_partially_maker_base_asset() { filled_taker_amount: alice_portion, unfilled_maker_amount, unfilled_taker_amount, + external_fee, } .into(), ); @@ -787,9 +799,10 @@ fn it_fills_order_partially_maker_outcome_asset() { let market_creator_free_balance_after = AssetManager::free_balance(market.base_asset, &MARKET_CREATOR); + let external_fee_amount = calculate_fee::(70 * BASE); assert_eq!( market_creator_free_balance_after - market_creator_free_balance_before, - calculate_fee::(70 * BASE) + external_fee_amount ); let order = Orders::::get(order_id).unwrap(); @@ -810,6 +823,7 @@ fn it_fills_order_partially_maker_outcome_asset() { let reserved_bob = AssetManager::reserved_balance(maker_asset, &BOB); assert_eq!(reserved_bob, filled_maker_amount); + let external_fee = ExternalFee { account: BOB, amount: external_fee_amount }; System::assert_last_event( Event::::OrderFilled { @@ -821,6 +835,7 @@ fn it_fills_order_partially_maker_outcome_asset() { filled_taker_amount: alice_portion, unfilled_maker_amount: filled_maker_amount, unfilled_taker_amount: taker_amount - alice_portion, + external_fee, } .into(), ); diff --git a/zrml/parimutuel/src/mock.rs b/zrml/parimutuel/src/mock.rs index e4cfcfaac..fa78bc29c 100644 --- a/zrml/parimutuel/src/mock.rs +++ b/zrml/parimutuel/src/mock.rs @@ -33,7 +33,7 @@ use sp_runtime::{ use zeitgeist_primitives::{ constants::mock::{ BlockHashCount, ExistentialDeposits, GetNativeCurrencyId, MaxReserves, MinBetSize, - MinimumPeriod, ParimutuelPalletId, BASE, + MinimumPeriod, ParimutuelPalletId, BASE, CENT, }, traits::DistributeFees, types::{ @@ -50,10 +50,20 @@ pub const MARKET_CREATOR: AccountIdTest = 42; pub const INITIAL_BALANCE: u128 = 1_000 * BASE; +pub const EXTERNAL_FEES: u128 = CENT; + parameter_types! { pub const FeeAccount: AccountIdTest = MARKET_CREATOR; } +pub fn fee_percentage() -> Perbill { + Perbill::from_rational(EXTERNAL_FEES, BASE) +} + +pub fn calculate_fee(amount: BalanceOf) -> BalanceOf { + fee_percentage().mul_floor(amount.saturated_into::>()) +} + pub struct ExternalFees(PhantomData, PhantomData); impl DistributeFees for ExternalFees @@ -71,11 +81,14 @@ where account: &Self::AccountId, amount: Self::Balance, ) -> Self::Balance { - let fees = - Perbill::from_rational(1u64, 100u64).mul_floor(amount.saturated_into::>()); + let fees = calculate_fee::(amount.saturated_into::>()); let _ = T::AssetManager::transfer(asset, account, &F::get(), fees); fees } + + fn fee_percentage(_market_id: Self::MarketId) -> Perbill { + fee_percentage() + } } construct_runtime!(