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