diff --git a/zrml/combinatorial-tokens/src/mock/consts.rs b/zrml/combinatorial-tokens/src/mock/consts.rs
index 7d579595e..d614e0775 100644
--- a/zrml/combinatorial-tokens/src/mock/consts.rs
+++ b/zrml/combinatorial-tokens/src/mock/consts.rs
@@ -1,4 +1,19 @@
// 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 = "parachain")]
use zeitgeist_primitives::types::{Asset, MarketId};
diff --git a/zrml/neo-swaps/src/lib.rs b/zrml/neo-swaps/src/lib.rs
index 7784ec8e0..1e04c849f 100644
--- a/zrml/neo-swaps/src/lib.rs
+++ b/zrml/neo-swaps/src/lib.rs
@@ -94,6 +94,10 @@ mod pallet {
pub(crate) const MAX_SPOT_PRICE: u128 = BASE - CENT / 2;
/// The minimum allowed spot price when creating a pool.
pub(crate) const MIN_SPOT_PRICE: u128 = CENT / 2;
+ /// The maximum value the spot price is allowed to take in a combinatorial market.
+ pub(crate) const COMBO_MAX_SPOT_PRICE: u128 = BASE - CENT / 10;
+ /// The minimum value the spot price is allowed to take in a combinatorial market.
+ pub(crate) const COMBO_MIN_SPOT_PRICE: u128 = CENT / 10;
/// The minimum vallowed value of a pool's liquidity parameter.
pub(crate) const MIN_LIQUIDITY: u128 = BASE;
/// The minimum percentage each new LP position must increase the liquidity by, represented as
@@ -311,12 +315,14 @@ mod pallet {
pub enum NumericalLimitsError {
/// Selling is not allowed at prices this low.
SpotPriceTooLow,
- /// Sells which move the price below this threshold are not allowed.
+ /// Interactions which move the price below a particular threshold are not allowed.
SpotPriceSlippedTooLow,
/// The maximum buy or sell amount was exceeded.
MaxAmountExceeded,
/// The minimum buy or sell amount was exceeded.
MinAmountNotMet,
+ /// Interactions which move the price above a particular threshold are not allowed.
+ SpotPriceSlippedTooHigh,
}
#[pallet::call]
@@ -1041,10 +1047,10 @@ mod pallet {
ensure!(!sell.is_empty(), Error::::InvalidPartition);
for asset in buy.iter() {
ensure!(!sell.contains(asset), Error::::InvalidPartition);
- ensure!(market.outcome_assets().contains(asset), Error::::InvalidPartition);
+ ensure!(pool.assets().contains(asset), Error::::InvalidPartition);
}
for asset in sell.iter() {
- ensure!(market.outcome_assets().contains(asset), Error::::InvalidPartition);
+ ensure!(pool.assets().contains(asset), Error::::InvalidPartition);
}
let buy_set = buy.iter().collect::>();
let sell_set = sell.iter().collect::>();
@@ -1084,6 +1090,19 @@ mod pallet {
pool.increase_reserve(&asset, &amount_in_minus_fees)?;
}
+ // Ensure that numerical limits of all prices are respected.
+ for &asset in pool.assets().iter() {
+ let spot_price = pool.calculate_spot_price(asset)?;
+ ensure!(
+ spot_price >= COMBO_MIN_SPOT_PRICE.saturated_into(),
+ Error::::NumericalLimits(NumericalLimitsError::SpotPriceSlippedTooLow)
+ );
+ ensure!(
+ spot_price <= COMBO_MAX_SPOT_PRICE.saturated_into(),
+ Error::::NumericalLimits(NumericalLimitsError::SpotPriceSlippedTooHigh)
+ );
+ }
+
Self::deposit_event(Event::::ComboBuyExecuted {
who: who.clone(),
market_id,
@@ -1123,14 +1142,14 @@ mod pallet {
for asset in buy.iter() {
ensure!(!keep.contains(asset), Error::::InvalidPartition);
ensure!(!sell.contains(asset), Error::::InvalidPartition);
- ensure!(market.outcome_assets().contains(asset), Error::::InvalidPartition);
+ ensure!(pool.assets().contains(asset), Error::::InvalidPartition);
}
for asset in sell.iter() {
ensure!(!keep.contains(asset), Error::::InvalidPartition);
- ensure!(market.outcome_assets().contains(asset), Error::::InvalidPartition);
+ ensure!(pool.assets().contains(asset), Error::::InvalidPartition);
}
for asset in keep.iter() {
- ensure!(market.outcome_assets().contains(asset), Error::::InvalidPartition);
+ ensure!(pool.assets().contains(asset), Error::::InvalidPartition);
}
let buy_set = buy.iter().collect::>();
let keep_set = keep.iter().collect::>();
@@ -1193,6 +1212,32 @@ mod pallet {
amount_out_minus_fees,
)?;
+ // Ensure that numerical limits of all prices are respected.
+ for &asset in pool.assets().iter() {
+ let spot_price = pool.calculate_spot_price(asset)?;
+ ensure!(
+ spot_price >= COMBO_MIN_SPOT_PRICE.saturated_into(),
+ Error::::NumericalLimits(NumericalLimitsError::SpotPriceSlippedTooLow)
+ );
+ ensure!(
+ spot_price <= COMBO_MAX_SPOT_PRICE.saturated_into(),
+ Error::::NumericalLimits(NumericalLimitsError::SpotPriceSlippedTooHigh)
+ );
+ }
+
+ // Ensure that numerical limits of all prices are respected.
+ for &asset in pool.assets().iter() {
+ let spot_price = pool.calculate_spot_price(asset)?;
+ ensure!(
+ spot_price >= COMBO_MIN_SPOT_PRICE.saturated_into(),
+ Error::::NumericalLimits(NumericalLimitsError::SpotPriceSlippedTooLow)
+ );
+ ensure!(
+ spot_price <= COMBO_MAX_SPOT_PRICE.saturated_into(),
+ Error::::NumericalLimits(NumericalLimitsError::SpotPriceSlippedTooHigh)
+ );
+ }
+
Self::deposit_event(Event::::ComboSellExecuted {
who: who.clone(),
market_id,
diff --git a/zrml/neo-swaps/src/tests/combo_buy.rs b/zrml/neo-swaps/src/tests/combo_buy.rs
index ae9492789..7fb5ae5c8 100644
--- a/zrml/neo-swaps/src/tests/combo_buy.rs
+++ b/zrml/neo-swaps/src/tests/combo_buy.rs
@@ -299,3 +299,78 @@ fn combo_buy_fails_on_invalid_partition(indices_buy: Vec, indices_sell: Vec
);
});
}
+
+#[test]
+fn combo_buy_fails_on_spot_price_slipping_too_low() {
+ ExtBuilder::default().build().execute_with(|| {
+ let market_id = create_market_and_deploy_pool(
+ ALICE,
+ BASE_ASSET,
+ MarketType::Categorical(5),
+ _10,
+ vec![_1_5, _1_5, _1_5, _1_5, _1_5],
+ CENT,
+ );
+ let amount_in = _100;
+
+ assert_ok!(AssetManager::deposit(BASE_ASSET, &BOB, amount_in));
+
+ let buy = [0, 1, 2, 3].into_iter().map(|i| CategoricalOutcome(market_id, i)).collect();
+ let sell = [4].into_iter().map(|i| CategoricalOutcome(market_id, i)).collect();
+
+ assert_noop!(
+ NeoSwaps::combo_buy(RuntimeOrigin::signed(BOB), market_id, 5, buy, sell, amount_in, 0),
+ Error::::NumericalLimits(NumericalLimitsError::SpotPriceSlippedTooLow),
+ );
+ });
+}
+
+#[test]
+fn combo_buy_fails_on_spot_price_slipping_too_high() {
+ ExtBuilder::default().build().execute_with(|| {
+ let market_id = create_market_and_deploy_pool(
+ ALICE,
+ BASE_ASSET,
+ MarketType::Categorical(5),
+ _10,
+ vec![_1_5, _1_5, _1_5, _1_5, _1_5],
+ CENT,
+ );
+ let amount_in = _100;
+
+ assert_ok!(AssetManager::deposit(BASE_ASSET, &BOB, amount_in));
+
+ let buy = [0].into_iter().map(|i| CategoricalOutcome(market_id, i)).collect();
+ let sell = [1, 2, 3, 4].into_iter().map(|i| CategoricalOutcome(market_id, i)).collect();
+
+ assert_noop!(
+ NeoSwaps::combo_buy(RuntimeOrigin::signed(BOB), market_id, 5, buy, sell, amount_in, 0),
+ Error::::NumericalLimits(NumericalLimitsError::SpotPriceSlippedTooHigh),
+ );
+ });
+}
+
+#[test]
+fn combo_buy_fails_on_large_buy() {
+ ExtBuilder::default().build().execute_with(|| {
+ let market_id = create_market_and_deploy_pool(
+ ALICE,
+ BASE_ASSET,
+ MarketType::Categorical(5),
+ _10,
+ vec![_1_5, _1_5, _1_5, _1_5, _1_5],
+ CENT,
+ );
+ let amount_in = 100 * _100;
+
+ assert_ok!(AssetManager::deposit(BASE_ASSET, &BOB, amount_in));
+
+ let buy = [0].into_iter().map(|i| CategoricalOutcome(market_id, i)).collect();
+ let sell = [1, 2].into_iter().map(|i| CategoricalOutcome(market_id, i)).collect();
+
+ assert_noop!(
+ NeoSwaps::combo_buy(RuntimeOrigin::signed(BOB), market_id, 5, buy, sell, amount_in, 0),
+ Error::::MathError,
+ );
+ });
+}
diff --git a/zrml/neo-swaps/src/tests/combo_sell.rs b/zrml/neo-swaps/src/tests/combo_sell.rs
index 57ed1ee3b..f8f117201 100644
--- a/zrml/neo-swaps/src/tests/combo_sell.rs
+++ b/zrml/neo-swaps/src/tests/combo_sell.rs
@@ -290,7 +290,7 @@ fn combo_sell_fails_on_amount_out_below_min() {
#[test_case(vec![0, 6, 1, 6], vec![2, 4], vec![5, 3]; "duplicate_buy")]
#[test_case(vec![0, 1], vec![2, 2, 4], vec![5, 3]; "duplicate_keep")]
#[test_case(vec![0, 1], vec![2, 4], vec![5, 3, 6, 6, 6]; "duplicate_sell")]
-fn combo_buy_fails_on_invalid_partition(
+fn combo_sell_fails_on_invalid_partition(
indices_buy: Vec,
indices_keep: Vec,
indices_sell: Vec,
@@ -327,3 +327,126 @@ fn combo_buy_fails_on_invalid_partition(
);
});
}
+
+#[test]
+fn combo_sell_fails_on_spot_price_slipping_too_low() {
+ ExtBuilder::default().build().execute_with(|| {
+ let market_id = create_market_and_deploy_pool(
+ ALICE,
+ BASE_ASSET,
+ MarketType::Categorical(5),
+ _10,
+ vec![_1_5, _1_5, _1_5, _1_5, _1_5],
+ CENT,
+ );
+ let amount_buy = _100;
+
+ for i in 0..4 {
+ assert_ok!(AssetManager::deposit(
+ Asset::CategoricalOutcome(market_id, i),
+ &BOB,
+ amount_buy
+ ));
+ }
+
+ let buy = [0, 1, 2, 3].into_iter().map(|i| CategoricalOutcome(market_id, i)).collect();
+ let sell = [4].into_iter().map(|i| CategoricalOutcome(market_id, i)).collect();
+
+ assert_noop!(
+ NeoSwaps::combo_sell(
+ RuntimeOrigin::signed(BOB),
+ market_id,
+ 5,
+ buy,
+ vec![],
+ sell,
+ amount_buy,
+ 0,
+ 0
+ ),
+ Error::::NumericalLimits(NumericalLimitsError::SpotPriceSlippedTooLow),
+ );
+ });
+}
+
+#[test]
+fn combo_sell_fails_on_spot_price_slipping_too_high() {
+ ExtBuilder::default().build().execute_with(|| {
+ let market_id = create_market_and_deploy_pool(
+ ALICE,
+ BASE_ASSET,
+ MarketType::Categorical(5),
+ _10,
+ vec![_1_5, _1_5, _1_5, _1_5, _1_5],
+ CENT,
+ );
+ let amount_buy = _100;
+
+ for i in 0..4 {
+ assert_ok!(AssetManager::deposit(
+ Asset::CategoricalOutcome(market_id, i),
+ &BOB,
+ amount_buy
+ ));
+ }
+
+ let buy = [0].into_iter().map(|i| CategoricalOutcome(market_id, i)).collect();
+ let sell = [1, 2, 3, 4].into_iter().map(|i| CategoricalOutcome(market_id, i)).collect();
+
+ assert_noop!(
+ NeoSwaps::combo_sell(
+ RuntimeOrigin::signed(BOB),
+ market_id,
+ 5,
+ buy,
+ vec![],
+ sell,
+ amount_buy,
+ 0,
+ 0
+ ),
+ Error::::NumericalLimits(NumericalLimitsError::SpotPriceSlippedTooLow),
+ );
+ });
+}
+
+#[test]
+fn combo_sell_fails_on_large_amount() {
+ ExtBuilder::default().build().execute_with(|| {
+ let market_id = create_market_and_deploy_pool(
+ ALICE,
+ BASE_ASSET,
+ MarketType::Categorical(5),
+ _10,
+ vec![_1_5, _1_5, _1_5, _1_5, _1_5],
+ CENT,
+ );
+ let amount_buy = 100 * _100;
+
+ for i in 0..4 {
+ assert_ok!(AssetManager::deposit(
+ Asset::CategoricalOutcome(market_id, i),
+ &BOB,
+ amount_buy
+ ));
+ }
+
+ let buy = [0].into_iter().map(|i| CategoricalOutcome(market_id, i)).collect();
+ let sell = [1, 2, 3, 4].into_iter().map(|i| CategoricalOutcome(market_id, i)).collect();
+
+ assert_noop!(
+ NeoSwaps::combo_sell(
+ RuntimeOrigin::signed(BOB),
+ market_id,
+ 5,
+ buy,
+ vec![],
+ sell,
+ amount_buy,
+ 0,
+ 0
+ ),
+ Error::::MathError,
+ );
+ });
+}