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, + ); + }); +}