From 52a306338786dda8dfe521373a31855dfdbd4d83 Mon Sep 17 00:00:00 2001 From: Chralt Date: Wed, 25 Oct 2023 16:07:27 +0200 Subject: [PATCH] Implement Parimutuel markets (#1138) * wip * remove market commons currency * update mocks, fix clippy * use unambiguous balance type * Update zrml/prediction-markets/src/lib.rs * Update zrml/prediction-markets/src/lib.rs * Update zrml/prediction-markets/src/lib.rs * fix benchmarks * fix copyrights * wip * restructure outcome asset type * wip * wip * Implement categorical and scalar claims * outsource into functions * add refund_pot extrinsic * prepare tests * fmt * fix after merge * fix mock of neo-swaps * apply review suggestions * delete parimutuel * add tests * impl bench, add comments * integrate Parimutuel into runtime * add parimutuel weights * remove scalar for parimutuels * revert outcome type changes in favour of dev speed * revert outcome type in parimutuels * add resolution mechanism * modify copyrights * taplo fmt * remove not existent Outcome type * avoid storage migration * decrease parimutuel existential deposits * impl resolution_mechanism * remove MaxCategories from parimutuel * Update zrml/parimutuel/Cargo.toml Co-authored-by: Malte Kliemann * rename benchmark module * remove MaxCategories from parimutuel config * remove unused block run methods * move market mock to utils file * use default market type categorical * remove unnecessary debug assert * implement is_redeemable * update config comments * add inconsistent state error * update comment * update collateral to be base asset * remove unused scalar market storage item * use do function style * order config trait * change copyright * update copyrights * impl market assets contains fn * improve comments * reduce indentation * correct error * fmt * delete review jerk comment * use bmul_floor and bdiv_floor * add not categorical error * remove trailing commas * use test_case for parimutuel * rename refund_pot to claim_refunds * Update zrml/parimutuel/README.md Co-authored-by: Malte Kliemann * use test cases for market status * add test cases for invalid scoring rule * improve test readibility * improve test cases * add docs * add invalid scoring rule tests * add redeem shares test * extend to non-trivial winner rewarding * add copyright * revert unsupported currency * Update zrml/prediction-markets/src/tests.rs Co-authored-by: Harald Heckmann * remove import * use log * fmt * Update zrml/prediction-markets/src/tests.rs Co-authored-by: Harald Heckmann * improve event readability * rename impl_distribute to do_distribute * Update zrml/parimutuel/src/lib.rs Co-authored-by: Harald Heckmann * use logs * use log for optimized builds * separate tests * outsource shared code * outsource get_winning_asset * add log * use withdraw instead of slash * use withdraw instead of slash * Update runtime/battery-station/src/parameters.rs Co-authored-by: Harald Heckmann * Update runtime/zeitgeist/src/parameters.rs Co-authored-by: Harald Heckmann * use creator parameter in mock --------- Co-authored-by: Malte Kliemann Co-authored-by: Harald Heckmann --- Cargo.lock | 23 + Cargo.toml | 3 + README.md | 2 + docs/changelog_for_devs.md | 21 + primitives/src/asset.rs | 3 +- primitives/src/constants.rs | 3 + primitives/src/constants/mock.rs | 6 + primitives/src/market.rs | 21 + primitives/src/math/fixed.rs | 24 + primitives/src/pool.rs | 1 + primitives/src/traits.rs | 2 + .../src/traits/distribute_fees.rs | 2 +- runtime/battery-station/Cargo.toml | 4 + runtime/battery-station/src/lib.rs | 5 +- runtime/battery-station/src/parameters.rs | 5 + runtime/common/src/fees.rs | 47 ++ runtime/common/src/lib.rs | 25 +- runtime/zeitgeist/Cargo.toml | 4 + runtime/zeitgeist/src/lib.rs | 5 +- runtime/zeitgeist/src/parameters.rs | 5 + zrml/neo-swaps/src/lib.rs | 12 +- zrml/neo-swaps/src/mock.rs | 8 +- zrml/neo-swaps/src/traits/mod.rs | 2 - .../neo-swaps/src/types/market_creator_fee.rs | 59 --- zrml/neo-swaps/src/types/mod.rs | 2 - .../fuzz/orderbook_v1_full_workflow.rs | 5 +- zrml/parimutuel/Cargo.toml | 47 ++ zrml/parimutuel/README.md | 36 ++ zrml/parimutuel/src/benchmarking.rs | 137 +++++ zrml/parimutuel/src/lib.rs | 489 ++++++++++++++++++ zrml/parimutuel/src/mock.rs | 213 ++++++++ zrml/parimutuel/src/tests/buy.rs | 220 ++++++++ zrml/parimutuel/src/tests/claim.rs | 375 ++++++++++++++ zrml/parimutuel/src/tests/mod.rs | 22 + zrml/parimutuel/src/tests/refund.rs | 218 ++++++++ zrml/parimutuel/src/utils.rs | 51 ++ zrml/parimutuel/src/weights.rs | 111 ++++ zrml/prediction-markets/src/lib.rs | 15 +- zrml/prediction-markets/src/tests.rs | 70 +++ zrml/swaps/fuzz/utils.rs | 6 +- zrml/swaps/src/lib.rs | 10 +- zrml/swaps/src/utils.rs | 6 +- 42 files changed, 2218 insertions(+), 107 deletions(-) rename {zrml/neo-swaps => primitives}/src/traits/distribute_fees.rs (97%) delete mode 100644 zrml/neo-swaps/src/types/market_creator_fee.rs create mode 100644 zrml/parimutuel/Cargo.toml create mode 100644 zrml/parimutuel/README.md create mode 100644 zrml/parimutuel/src/benchmarking.rs create mode 100644 zrml/parimutuel/src/lib.rs create mode 100644 zrml/parimutuel/src/mock.rs create mode 100644 zrml/parimutuel/src/tests/buy.rs create mode 100644 zrml/parimutuel/src/tests/claim.rs create mode 100644 zrml/parimutuel/src/tests/mod.rs create mode 100644 zrml/parimutuel/src/tests/refund.rs create mode 100644 zrml/parimutuel/src/utils.rs create mode 100644 zrml/parimutuel/src/weights.rs diff --git a/Cargo.lock b/Cargo.lock index 073307155..edeca217f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -625,6 +625,7 @@ dependencies = [ "zrml-market-commons", "zrml-neo-swaps", "zrml-orderbook-v1", + "zrml-parimutuel", "zrml-prediction-markets", "zrml-rikiddo", "zrml-simple-disputes", @@ -14483,6 +14484,7 @@ dependencies = [ "zrml-market-commons", "zrml-neo-swaps", "zrml-orderbook-v1", + "zrml-parimutuel", "zrml-prediction-markets", "zrml-rikiddo", "zrml-simple-disputes", @@ -14683,6 +14685,27 @@ dependencies = [ "zrml-orderbook-v1", ] +[[package]] +name = "zrml-parimutuel" +version = "0.4.1" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "orml-currencies", + "orml-tokens", + "orml-traits", + "pallet-balances", + "pallet-timestamp", + "parity-scale-codec", + "scale-info", + "sp-io", + "sp-runtime", + "test-case", + "zeitgeist-primitives", + "zrml-market-commons", +] + [[package]] name = "zrml-prediction-markets" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 93e07a943..9558220c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ default-members = [ "zrml/market-commons", "zrml/neo-swaps", "zrml/orderbook-v1", + "zrml/parimutuel", "zrml/prediction-markets", "zrml/prediction-markets/runtime-api", "zrml/rikiddo", @@ -35,6 +36,7 @@ members = [ "zrml/neo-swaps", "zrml/orderbook-v1", "zrml/orderbook-v1/fuzz", + "zrml/parimutuel", "zrml/prediction-markets", "zrml/prediction-markets/fuzz", "zrml/prediction-markets/runtime-api", @@ -233,6 +235,7 @@ zrml-liquidity-mining = { path = "zrml/liquidity-mining", default-features = fal zrml-market-commons = { path = "zrml/market-commons", default-features = false } zrml-neo-swaps = { path = "zrml/neo-swaps", default-features = false } zrml-orderbook-v1 = { path = "zrml/orderbook-v1", default-features = false } +zrml-parimutuel = { path = "zrml/parimutuel", default-features = false } zrml-prediction-markets = { path = "zrml/prediction-markets", default-features = false } zrml-prediction-markets-runtime-api = { path = "zrml/prediction-markets/runtime-api", default-features = false } zrml-rikiddo = { path = "zrml/rikiddo", default-features = false } diff --git a/README.md b/README.md index 62263e619..56c7fa352 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ _anything_. - [orderbook-v1](./zrml/orderbook-v1) - A naive orderbook implementation that's only part of Zeitgeist's PoC. Will be replaced by a v2 orderbook that uses 0x-style hybrid on-chain and off-chain trading. +- [parimutuel](./zrml/parimutuel) - A straightforward parimutuel market maker + for categorical markets. - [prediction-markets](./zrml/prediction-markets) - The core implementation of the prediction market logic for creating and resolving markets. - [simple-disputes](./zrml-simple-disputes) - Simple disputes selects the last diff --git a/docs/changelog_for_devs.md b/docs/changelog_for_devs.md index bc4cdc6d3..46d444b24 100644 --- a/docs/changelog_for_devs.md +++ b/docs/changelog_for_devs.md @@ -15,6 +15,27 @@ APIs/RPC interface. ## v0.4.2 [#1148]: https://github.com/zeitgeistpm/zeitgeist/pull/1148 +[#1138]: https://github.com/zeitgeistpm/zeitgeist/pull/1138 + +### Added + +- Implement parimutuel market ([#1138]) maker to allow markets without liquidity + provision. The new pallet has the following dispatchables: + + - `buy`: Buy outcome tokens. + - `claim_rewards`: Claim the winner outcome tokens. + - `claim_refunds`: Claim the refunds in case there was no winner. + + The new pallet has the following events: + + - `OutcomeBought { market_id, buyer, asset, amount_minus_fees, fees }`: + Informant bought a position. + - `RewardsClaimed { market_id, asset, balance, actual_payoff, sender }`: + Informant claimed rewards. + - `RefundsClaimed { market_id, asset, refunded_balance, sender }`: Informant + claimed refunds. + + For details, please refer to the `README.md` and the in-file documentation. ### Changed diff --git a/primitives/src/asset.rs b/primitives/src/asset.rs index 094f718c9..bc9c61637 100644 --- a/primitives/src/asset.rs +++ b/primitives/src/asset.rs @@ -1,4 +1,4 @@ -// Copyright 2022 Forecasting Technologies LTD. +// Copyright 2022-2023 Forecasting Technologies LTD. // Copyright 2021-2022 Zeitgeist PM LLC. // // This file is part of Zeitgeist. @@ -50,6 +50,7 @@ pub enum Asset { #[default] Ztg, ForeignAsset(u32), + ParimutuelShare(MI, CategoryIndex), } /// In a scalar market, users can either choose a `Long` position, diff --git a/primitives/src/constants.rs b/primitives/src/constants.rs index 3841666a1..6bd7a4c32 100644 --- a/primitives/src/constants.rs +++ b/primitives/src/constants.rs @@ -120,6 +120,9 @@ pub const SWAPS_PALLET_ID: PalletId = PalletId(*b"zge/swap"); // Orderbook pub const ORDERBOOK_PALLET_ID: PalletId = PalletId(*b"zge/ordb"); +// Parimutuel +pub const PARIMUTUEL_PALLET_ID: PalletId = PalletId(*b"zge/prmt"); + // Treasury /// Pallet identifier, used to derive treasury account pub const TREASURY_PALLET_ID: PalletId = PalletId(*b"zge/tsry"); diff --git a/primitives/src/constants/mock.rs b/primitives/src/constants/mock.rs index 487f252d0..17321da83 100644 --- a/primitives/src/constants/mock.rs +++ b/primitives/src/constants/mock.rs @@ -132,6 +132,12 @@ parameter_types! { pub const OrderbookPalletId: PalletId = PalletId(*b"zge/ordb"); } +// Parimutuel parameters +parameter_types! { + pub const ParimutuelPalletId: PalletId = PalletId(*b"zge/prmt"); + pub const MinBetSize: Balance = BASE; +} + // Shared within tests // Balance parameter_types! { diff --git a/primitives/src/market.rs b/primitives/src/market.rs index edd51c71e..8f5272f98 100644 --- a/primitives/src/market.rs +++ b/primitives/src/market.rs @@ -66,6 +66,22 @@ pub struct Market { pub bonds: MarketBonds, } +impl Market { + pub fn resolution_mechanism(&self) -> ResolutionMechanism { + match self.scoring_rule { + ScoringRule::CPMM + | ScoringRule::Lmsr + | ScoringRule::Orderbook + | ScoringRule::RikiddoSigmoidFeeMarketEma => ResolutionMechanism::RedeemTokens, + ScoringRule::Parimutuel => ResolutionMechanism::Noop, + } + } + + pub fn is_redeemable(&self) -> bool { + matches!(self.resolution_mechanism(), ResolutionMechanism::RedeemTokens) + } +} + /// Tracks the status of a bond. #[derive(Clone, Decode, Encode, MaxEncodedLen, PartialEq, Eq, RuntimeDebug, TypeInfo)] pub struct Bond { @@ -303,6 +319,11 @@ pub struct AuthorityReport { pub outcome: OutcomeReport, } +pub enum ResolutionMechanism { + RedeemTokens, + Noop, +} + /// Contains a market id and the market period. /// /// * `BN`: Block Number diff --git a/primitives/src/math/fixed.rs b/primitives/src/math/fixed.rs index 24a92794d..429bfc27d 100644 --- a/primitives/src/math/fixed.rs +++ b/primitives/src/math/fixed.rs @@ -58,12 +58,24 @@ pub fn bmul(a: u128, b: u128) -> Result { c1.check_div_rslt(&BASE) } +pub fn bmul_floor(a: u128, b: u128) -> Result { + // checked_mul already rounds down + let c0 = a.check_mul_rslt(&b)?; + c0.check_div_rslt(&BASE) +} + pub fn bdiv(a: u128, b: u128) -> Result { let c0 = a.check_mul_rslt(&BASE)?; let c1 = c0.check_add_rslt(&b.check_div_rslt(&2)?)?; c1.check_div_rslt(&b) } +pub fn bdiv_floor(a: u128, b: u128) -> Result { + let c0 = a.check_mul_rslt(&BASE)?; + // checked_div already rounds down + c0.check_div_rslt(&b) +} + pub fn bpowi(a: u128, n: u128) -> Result { let mut z = if n % 2 != 0 { a } else { BASE }; @@ -365,6 +377,18 @@ mod tests { }; } + #[test] + fn bmul_rounding_behaviours() { + assert_eq!(bmul(3u128, 33_333_333_333u128).unwrap(), 10u128); + assert_eq!(bmul_floor(3u128, 33_333_333_333u128).unwrap(), 9u128); + } + + #[test] + fn bdiv_rounding_behaviors() { + assert_eq!(bdiv(14u128, 3u128).unwrap(), 46_666_666_667u128); + assert_eq!(bdiv_floor(14u128, 3u128).unwrap(), 46_666_666_666u128); + } + #[test] fn bdiv_has_minimum_set_of_correct_values() { create_tests!( diff --git a/primitives/src/pool.rs b/primitives/src/pool.rs index c84d64d54..2b1c70529 100644 --- a/primitives/src/pool.rs +++ b/primitives/src/pool.rs @@ -86,4 +86,5 @@ pub enum ScoringRule { RikiddoSigmoidFeeMarketEma, Lmsr, Orderbook, + Parimutuel, } diff --git a/primitives/src/traits.rs b/primitives/src/traits.rs index c2742e3a9..cea15e4d0 100644 --- a/primitives/src/traits.rs +++ b/primitives/src/traits.rs @@ -19,6 +19,7 @@ mod complete_set_operations_api; mod deploy_pool_api; mod dispute_api; +mod distribute_fees; mod market_commons_pallet_api; mod market_id; mod swaps; @@ -27,6 +28,7 @@ mod zeitgeist_multi_reservable_currency; 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 market_commons_pallet_api::MarketCommonsPalletApi; pub use market_id::MarketId; pub use swaps::Swaps; diff --git a/zrml/neo-swaps/src/traits/distribute_fees.rs b/primitives/src/traits/distribute_fees.rs similarity index 97% rename from zrml/neo-swaps/src/traits/distribute_fees.rs rename to primitives/src/traits/distribute_fees.rs index a6b67ad50..7e443c55d 100644 --- a/zrml/neo-swaps/src/traits/distribute_fees.rs +++ b/primitives/src/traits/distribute_fees.rs @@ -37,7 +37,7 @@ pub trait DistributeFees { fn distribute( market_id: Self::MarketId, asset: Self::Asset, - account: Self::AccountId, + account: &Self::AccountId, amount: Self::Balance, ) -> Self::Balance; } diff --git a/runtime/battery-station/Cargo.toml b/runtime/battery-station/Cargo.toml index 0e8914dd4..11ce2d3f4 100644 --- a/runtime/battery-station/Cargo.toml +++ b/runtime/battery-station/Cargo.toml @@ -114,6 +114,7 @@ zrml-liquidity-mining = { workspace = true } zrml-market-commons = { workspace = true } zrml-neo-swaps = { workspace = true } zrml-orderbook-v1 = { workspace = true } +zrml-parimutuel = { workspace = true } zrml-prediction-markets = { workspace = true } zrml-rikiddo = { workspace = true } zrml-simple-disputes = { workspace = true } @@ -211,6 +212,7 @@ runtime-benchmarks = [ "zrml-court/runtime-benchmarks", "zrml-liquidity-mining/runtime-benchmarks", "zrml-neo-swaps/runtime-benchmarks", + "zrml-parimutuel/runtime-benchmarks", "zrml-prediction-markets/runtime-benchmarks", "zrml-simple-disputes/runtime-benchmarks", "zrml-global-disputes/runtime-benchmarks", @@ -325,6 +327,7 @@ std = [ "zrml-liquidity-mining/std", "zrml-market-commons/std", "zrml-neo-swaps/std", + "zrml-parimutuel/std", "zrml-prediction-markets/std", "zrml-rikiddo/std", "zrml-simple-disputes/std", @@ -380,6 +383,7 @@ try-runtime = [ "zrml-liquidity-mining/try-runtime", "zrml-market-commons/try-runtime", "zrml-neo-swaps/try-runtime", + "zrml-parimutuel/try-runtime", "zrml-prediction-markets/try-runtime", "zrml-rikiddo/try-runtime", "zrml-simple-disputes/try-runtime", diff --git a/runtime/battery-station/src/lib.rs b/runtime/battery-station/src/lib.rs index 6b3b96618..1475c43e7 100644 --- a/runtime/battery-station/src/lib.rs +++ b/runtime/battery-station/src/lib.rs @@ -48,10 +48,7 @@ use frame_support::{ use frame_system::{EnsureRoot, EnsureWithSuccess}; use orml_currencies::Call::transfer; use pallet_collective::{EnsureProportionAtLeast, PrimeDefaultVote}; -use sp_runtime::{ - traits::{AccountIdConversion, AccountIdLookup, BlakeTwo256}, - DispatchError, -}; +use sp_runtime::traits::{AccountIdConversion, AccountIdLookup, BlakeTwo256}; #[cfg(feature = "std")] use sp_version::NativeVersion; use substrate_fixed::{types::extra::U33, FixedI128, FixedU128}; diff --git a/runtime/battery-station/src/parameters.rs b/runtime/battery-station/src/parameters.rs index 2d54e052d..6966b14de 100644 --- a/runtime/battery-station/src/parameters.rs +++ b/runtime/battery-station/src/parameters.rs @@ -312,6 +312,10 @@ parameter_types! { // Orderbook parameters pub const OrderbookPalletId: PalletId = ORDERBOOK_PALLET_ID; + // Parimutuel parameters + pub const MinBetSize: Balance = 100 * ExistentialDeposit::get(); + pub const ParimutuelPalletId: PalletId = PARIMUTUEL_PALLET_ID; + // System pub const BlockHashCount: u64 = 250; pub const SS58Prefix: u8 = 73; @@ -466,6 +470,7 @@ parameter_type_with_key! { #[cfg(not(feature = "parachain"))] Asset::ForeignAsset(_) => ExistentialDeposit::get(), Asset::Ztg => ExistentialDeposit::get(), + Asset::ParimutuelShare(_,_) => ExistentialDeposit::get(), } }; } diff --git a/runtime/common/src/fees.rs b/runtime/common/src/fees.rs index 6e610adc6..85ddc7543 100644 --- a/runtime/common/src/fees.rs +++ b/runtime/common/src/fees.rs @@ -237,6 +237,53 @@ macro_rules! impl_foreign_fees { }; } +#[macro_export] +macro_rules! impl_market_creator_fees { + () => { + pub struct MarketCreatorFee; + + /// Uses the `creator_fee` field defined by the specified market to deduct a fee for the market's + /// creator. Calling `distribute` is noop if the market doesn't exist or the transfer fails for any + /// reason. + impl DistributeFees for MarketCreatorFee { + type Asset = Asset; + type AccountId = AccountId; + type Balance = Balance; + type MarketId = MarketId; + + fn distribute( + market_id: Self::MarketId, + asset: Self::Asset, + account: &Self::AccountId, + amount: Self::Balance, + ) -> Self::Balance { + Self::do_distribute(market_id, asset, account, amount) + .unwrap_or_else(|_| 0u8.saturated_into()) + } + } + + impl MarketCreatorFee { + fn do_distribute( + market_id: MarketId, + asset: Asset, + account: &AccountId, + amount: Balance, + ) -> Result { + let market = MarketCommons::market(&market_id)?; // Should never fail + let fee_amount = market.creator_fee.mul_floor(amount); + // Might fail if the transaction is too small + >::transfer( + asset, + account, + &market.creator, + fee_amount, + )?; + Ok(fee_amount) + } + } + }; +} + #[macro_export] macro_rules! fee_tests { () => { diff --git a/runtime/common/src/lib.rs b/runtime/common/src/lib.rs index 22bbbd696..69f19e159 100644 --- a/runtime/common/src/lib.rs +++ b/runtime/common/src/lib.rs @@ -46,14 +46,15 @@ pub mod weights; #[macro_export] macro_rules! decl_common_types { () => { + use core::marker::PhantomData; use frame_support::traits::{ Currency, Imbalance, NeverEnsureOrigin, OnRuntimeUpgrade, OnUnbalanced, }; #[cfg(feature = "try-runtime")] use frame_try_runtime::{TryStateSelect, UpgradeCheckSelect}; - use sp_runtime::{generic, DispatchResult}; - use zeitgeist_primitives::traits::DeployPoolApi; - use zrml_neo_swaps::types::MarketCreatorFee; + use orml_traits::MultiCurrency; + use sp_runtime::{generic, DispatchError, DispatchResult, SaturatedConversion}; + use zeitgeist_primitives::traits::{DeployPoolApi, DistributeFees, MarketCommonsPalletApi}; pub type Block = generic::Block; @@ -196,6 +197,7 @@ macro_rules! decl_common_types { GlobalDisputesPalletId::get(), LiquidityMiningPalletId::get(), OrderbookPalletId::get(), + ParimutuelPalletId::get(), PmPalletId::get(), SimpleDisputesPalletId::get(), SwapsPalletId::get(), @@ -315,6 +317,7 @@ macro_rules! create_runtime { GlobalDisputes: zrml_global_disputes::{Call, Event, Pallet, Storage} = 59, NeoSwaps: zrml_neo_swaps::{Call, Event, Pallet, Storage} = 60, Orderbook: zrml_orderbook_v1::{Call, Event, Pallet, Storage} = 61, + Parimutuel: zrml_parimutuel::{Call, Event, Pallet, Storage} = 62, $($additional_pallets)* } @@ -1244,9 +1247,11 @@ macro_rules! impl_config_traits { type WeightInfo = zrml_styx::weights::WeightInfo; } + common_runtime::impl_market_creator_fees!(); + impl zrml_neo_swaps::Config for Runtime { type CompleteSetOperations = PredictionMarkets; - type ExternalFees = MarketCreatorFee; + type ExternalFees = MarketCreatorFee; type MarketCommons = MarketCommons; type MultiCurrency = AssetManager; type RuntimeEvent = RuntimeEvent; @@ -1262,6 +1267,16 @@ macro_rules! impl_config_traits { type PalletId = OrderbookPalletId; type WeightInfo = zrml_orderbook_v1::weights::WeightInfo; } + + impl zrml_parimutuel::Config for Runtime { + type ExternalFees = MarketCreatorFee; + type RuntimeEvent = RuntimeEvent; + type MarketCommons = MarketCommons; + type AssetManager = AssetManager; + type MinBetSize = MinBetSize; + type PalletId = ParimutuelPalletId; + type WeightInfo = zrml_parimutuel::weights::WeightInfo; + } } } @@ -1371,6 +1386,7 @@ macro_rules! create_runtime_api { list_benchmark!(list, extra, zrml_simple_disputes, SimpleDisputes); list_benchmark!(list, extra, zrml_global_disputes, GlobalDisputes); list_benchmark!(list, extra, zrml_orderbook_v1, Orderbook); + list_benchmark!(list, extra, zrml_parimutuel, Parimutuel); #[cfg(not(feature = "parachain"))] list_benchmark!(list, extra, zrml_prediction_markets, PredictionMarkets); list_benchmark!(list, extra, zrml_liquidity_mining, LiquidityMining); @@ -1474,6 +1490,7 @@ macro_rules! create_runtime_api { add_benchmark!(params, batches, zrml_simple_disputes, SimpleDisputes); add_benchmark!(params, batches, zrml_global_disputes, GlobalDisputes); add_benchmark!(params, batches, zrml_orderbook_v1, Orderbook); + add_benchmark!(params, batches, zrml_parimutuel, Parimutuel); #[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 93752cb9f..b588c9735 100644 --- a/runtime/zeitgeist/Cargo.toml +++ b/runtime/zeitgeist/Cargo.toml @@ -113,6 +113,7 @@ zrml-liquidity-mining = { workspace = true } zrml-market-commons = { workspace = true } zrml-neo-swaps = { workspace = true } zrml-orderbook-v1 = { workspace = true } +zrml-parimutuel = { workspace = true } zrml-prediction-markets = { workspace = true } zrml-rikiddo = { workspace = true } zrml-simple-disputes = { workspace = true } @@ -209,6 +210,7 @@ runtime-benchmarks = [ "zrml-court/runtime-benchmarks", "zrml-liquidity-mining/runtime-benchmarks", "zrml-neo-swaps/runtime-benchmarks", + "zrml-parimutuel/runtime-benchmarks", "zrml-prediction-markets/runtime-benchmarks", "zrml-simple-disputes/runtime-benchmarks", "zrml-global-disputes/runtime-benchmarks", @@ -315,6 +317,7 @@ std = [ "zrml-liquidity-mining/std", "zrml-market-commons/std", "zrml-neo-swaps/std", + "zrml-parimutuel/std", "zrml-prediction-markets/std", "zrml-rikiddo/std", "zrml-simple-disputes/std", @@ -370,6 +373,7 @@ try-runtime = [ "zrml-liquidity-mining/try-runtime", "zrml-market-commons/try-runtime", "zrml-neo-swaps/try-runtime", + "zrml-parimutuel/try-runtime", "zrml-prediction-markets/try-runtime", "zrml-rikiddo/try-runtime", "zrml-simple-disputes/try-runtime", diff --git a/runtime/zeitgeist/src/lib.rs b/runtime/zeitgeist/src/lib.rs index 83dc1ba8e..1e8a0b2ea 100644 --- a/runtime/zeitgeist/src/lib.rs +++ b/runtime/zeitgeist/src/lib.rs @@ -46,10 +46,7 @@ use frame_support::{ }; use frame_system::{EnsureRoot, EnsureWithSuccess}; use pallet_collective::{EnsureProportionAtLeast, EnsureProportionMoreThan, PrimeDefaultVote}; -use sp_runtime::{ - traits::{AccountIdConversion, AccountIdLookup, BlakeTwo256}, - DispatchError, -}; +use sp_runtime::traits::{AccountIdConversion, AccountIdLookup, BlakeTwo256}; #[cfg(feature = "std")] use sp_version::NativeVersion; use substrate_fixed::{types::extra::U33, FixedI128, FixedU128}; diff --git a/runtime/zeitgeist/src/parameters.rs b/runtime/zeitgeist/src/parameters.rs index 4bdf563fa..358efd223 100644 --- a/runtime/zeitgeist/src/parameters.rs +++ b/runtime/zeitgeist/src/parameters.rs @@ -312,6 +312,10 @@ parameter_types! { // Orderbook parameters pub const OrderbookPalletId: PalletId = ORDERBOOK_PALLET_ID; + // Parimutuel parameters + pub const MinBetSize: Balance = 100 * ExistentialDeposit::get(); + pub const ParimutuelPalletId: PalletId = PARIMUTUEL_PALLET_ID; + // System pub const BlockHashCount: u64 = 250; pub const SS58Prefix: u8 = 73; @@ -466,6 +470,7 @@ parameter_type_with_key! { #[cfg(not(feature = "parachain"))] Asset::ForeignAsset(_) => ExistentialDeposit::get(), Asset::Ztg => ExistentialDeposit::get(), + Asset::ParimutuelShare(_, _) => ExistentialDeposit::get(), } }; } diff --git a/zrml/neo-swaps/src/lib.rs b/zrml/neo-swaps/src/lib.rs index a2bf01d6a..18075a7c5 100644 --- a/zrml/neo-swaps/src/lib.rs +++ b/zrml/neo-swaps/src/lib.rs @@ -36,7 +36,7 @@ mod pallet { use crate::{ consts::MAX_ASSETS, math::{Math, MathOps}, - traits::{pool_operations::PoolOperations, DistributeFees, LiquiditySharesManager}, + traits::{pool_operations::PoolOperations, LiquiditySharesManager}, types::{FeeDistribution, Pool, SoloLp}, weights::*, }; @@ -59,7 +59,7 @@ mod pallet { use zeitgeist_primitives::{ constants::{BASE, CENT}, math::fixed::{bdiv, bmul}, - traits::{CompleteSetOperationsApi, DeployPoolApi}, + traits::{CompleteSetOperationsApi, DeployPoolApi, DistributeFees}, types::{Asset, MarketStatus, MarketType, ScalarPosition, ScoringRule}, }; use zrml_market_commons::MarketCommonsPalletApi; @@ -819,12 +819,8 @@ mod pallet { let swap_fees_u128 = bmul(pool.swap_fee.saturated_into(), amount.saturated_into())?; let swap_fees = swap_fees_u128.saturated_into(); pool.liquidity_shares_manager.deposit_fees(swap_fees)?; // Should only error unexpectedly! - let external_fees = T::ExternalFees::distribute( - market_id, - pool.collateral, - pool.account_id.clone(), - amount, - ); + let external_fees = + T::ExternalFees::distribute(market_id, pool.collateral, &pool.account_id, amount); let total_fees = external_fees.saturating_add(swap_fees); let remaining = amount.checked_sub(&total_fees).ok_or(Error::::Unexpected)?; Ok(FeeDistribution { remaining, swap_fees, external_fees }) diff --git a/zrml/neo-swaps/src/mock.rs b/zrml/neo-swaps/src/mock.rs index f9710ef99..fd2d320ca 100644 --- a/zrml/neo-swaps/src/mock.rs +++ b/zrml/neo-swaps/src/mock.rs @@ -58,13 +58,13 @@ use zeitgeist_primitives::{ PmPalletId, RemoveKeysLimit, RequestInterval, SimpleDisputesPalletId, SwapsPalletId, TreasuryPalletId, VotePeriod, VotingOutcomeFee, BASE, CENT, }, - traits::DeployPoolApi, + traits::{DeployPoolApi, DistributeFees}, types::{ AccountIdTest, Amount, Balance, BasicCurrencyAdapter, BlockNumber, BlockTest, CurrencyId, Hash, Index, MarketId, Moment, PoolId, UncheckedExtrinsicTest, }, }; -use zrml_neo_swaps::{traits::DistributeFees, BalanceOf}; +use zrml_neo_swaps::BalanceOf; use zrml_rikiddo::types::{EmaMarketVolume, FeeSigmoid, RikiddoSigmoidMV}; pub const ALICE: AccountIdTest = 0; @@ -134,7 +134,7 @@ where fn distribute( _market_id: Self::MarketId, asset: Self::Asset, - account: Self::AccountId, + account: &Self::AccountId, amount: Self::Balance, ) -> Self::Balance { let fees = zeitgeist_primitives::math::fixed::bmul( @@ -143,7 +143,7 @@ where ) .unwrap() .saturated_into(); - let _ = T::MultiCurrency::transfer(asset, &account, &F::get(), fees); + let _ = T::MultiCurrency::transfer(asset, account, &F::get(), fees); fees } } diff --git a/zrml/neo-swaps/src/traits/mod.rs b/zrml/neo-swaps/src/traits/mod.rs index 061239e9f..b6c796c11 100644 --- a/zrml/neo-swaps/src/traits/mod.rs +++ b/zrml/neo-swaps/src/traits/mod.rs @@ -15,10 +15,8 @@ // You should have received a copy of the GNU General Public License // along with Zeitgeist. If not, see . -pub mod distribute_fees; pub(crate) mod liquidity_shares_manager; pub(crate) mod pool_operations; -pub use distribute_fees::DistributeFees; pub(crate) use liquidity_shares_manager::LiquiditySharesManager; pub(crate) use pool_operations::PoolOperations; diff --git a/zrml/neo-swaps/src/types/market_creator_fee.rs b/zrml/neo-swaps/src/types/market_creator_fee.rs deleted file mode 100644 index 087627267..000000000 --- a/zrml/neo-swaps/src/types/market_creator_fee.rs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2023 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::{traits::DistributeFees, AssetOf, BalanceOf, Config, MarketIdOf}; -use core::marker::PhantomData; -use orml_traits::MultiCurrency; -use sp_runtime::{DispatchError, SaturatedConversion}; -use zrml_market_commons::MarketCommonsPalletApi; - -pub struct MarketCreatorFee(PhantomData); - -/// Uses the `creator_fee` field defined by the specified market to deduct a fee for the market's -/// creator. Calling `distribute` is noop if the market doesn't exist or the transfer fails for any -/// reason. -impl DistributeFees for MarketCreatorFee { - 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 { - Self::impl_distribute(market_id, asset, account, amount) - .unwrap_or_else(|_| 0u8.saturated_into()) - } -} - -impl MarketCreatorFee { - fn impl_distribute( - market_id: MarketIdOf, - asset: AssetOf, - account: T::AccountId, - amount: BalanceOf, - ) -> Result, DispatchError> { - let market = T::MarketCommons::market(&market_id)?; // Should never fail - let fee_amount = market.creator_fee.mul_floor(amount); - // Might fail if the transaction is too small - T::MultiCurrency::transfer(asset, &account, &market.creator, fee_amount)?; - Ok(fee_amount) - } -} diff --git a/zrml/neo-swaps/src/types/mod.rs b/zrml/neo-swaps/src/types/mod.rs index dc0c4aaf9..3968b4718 100644 --- a/zrml/neo-swaps/src/types/mod.rs +++ b/zrml/neo-swaps/src/types/mod.rs @@ -16,11 +16,9 @@ // along with Zeitgeist. If not, see . mod fee_distribution; -mod market_creator_fee; mod pool; mod solo_lp; pub(crate) use fee_distribution::*; -pub use market_creator_fee::*; pub(crate) use pool::*; pub(crate) use solo_lp::*; diff --git a/zrml/orderbook-v1/fuzz/orderbook_v1_full_workflow.rs b/zrml/orderbook-v1/fuzz/orderbook_v1_full_workflow.rs index b26eb3225..1cf64d054 100644 --- a/zrml/orderbook-v1/fuzz/orderbook_v1_full_workflow.rs +++ b/zrml/orderbook-v1/fuzz/orderbook_v1_full_workflow.rs @@ -86,7 +86,7 @@ struct Data { fn asset(seed: (u128, u16)) -> Asset { let (seed0, seed1) = seed; - let module = seed0 % 5; + let module = seed0 % 3; match module { 0 => Asset::CategoricalOutcome(seed0, seed1), 1 => { @@ -94,8 +94,7 @@ fn asset(seed: (u128, u16)) -> Asset { if seed1 % 2 == 0 { ScalarPosition::Long } else { ScalarPosition::Short }; Asset::ScalarOutcome(seed0, scalar_position) } - 2 => Asset::CombinatorialOutcome, - 3 => Asset::PoolShare(SerdeWrapper(seed0)), + 2 => Asset::PoolShare(SerdeWrapper(seed0)), _ => Asset::Ztg, } } diff --git a/zrml/parimutuel/Cargo.toml b/zrml/parimutuel/Cargo.toml new file mode 100644 index 000000000..abcb8e765 --- /dev/null +++ b/zrml/parimutuel/Cargo.toml @@ -0,0 +1,47 @@ +[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 } + +[dev-dependencies] +orml-currencies = { workspace = true, features = ["default"] } +orml-tokens = { workspace = true, features = ["default"] } +pallet-balances = { workspace = true, features = ["default"] } +pallet-timestamp = { workspace = true, features = ["default"] } +sp-io = { workspace = true, features = ["default"] } +zeitgeist-primitives = { workspace = true, features = ["mock", "default"] } + +test-case = { workspace = true } + +[features] +default = ["std"] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", +] +std = [ + "frame-benchmarking?/std", + "frame-support/std", + "frame-system/std", + "orml-traits/std", + "parity-scale-codec/std", + "sp-runtime/std", + "zeitgeist-primitives/std", + "zrml-market-commons/std", +] +try-runtime = [ + "frame-support/try-runtime", +] + +[package] +authors = ["Zeitgeist PM "] +edition = "2021" +name = "zrml-parimutuel" +version = "0.4.1" diff --git a/zrml/parimutuel/README.md b/zrml/parimutuel/README.md new file mode 100644 index 000000000..5c744e82e --- /dev/null +++ b/zrml/parimutuel/README.md @@ -0,0 +1,36 @@ +# Parimutuel Module + +The Parimutuel module implements a straightforward parimutuel market maker for +categorical markets. + +## Overview + +These are "losers pay winners" market makers: Any informant can bet any amount +at any time. Their bet amount goes into the _pot_ and they receive tokens which +represent their share of the pot. After the market is resolved, the entire pot +is distributed amongst those who wagered on the outcome that materialized, +proportional to what their share of the pot is. + +Selling shares is not allowed in parimutuel markets (this may be subject to +change in the future). Parimutuel markets are only allowed to be used in +conjunction with categorical markets; scalar markets are not allowed. + +If there is no bet on the winning outcome, all bets are cancelled and informants +can retrieve their funds from the pool, minus potential external fees paid to +other parties. + +### Terminology + +- _Collateral_: The currency type that backs the outcomes in the pool. This is + also called the _base asset_ in other contexts. +- _External fees_: After taking swap fees, additional fees can be withdrawn from + an informant's collateral. They might go to the chain's treasury or the market + creator. +- _Pot_: The account that holds all the wagered funds. + +### Notes + +- There's a hard requirement that the existential deposit of + `Asset::ParimutuelShares` be at least the existential deposit of the + collateral used. This ensures that no whitelisting or other trickery is + necessary to prevent the pot from getting dusted. diff --git a/zrml/parimutuel/src/benchmarking.rs b/zrml/parimutuel/src/benchmarking.rs new file mode 100644 index 000000000..26e9625ed --- /dev/null +++ b/zrml/parimutuel/src/benchmarking.rs @@ -0,0 +1,137 @@ +// Copyright 2023 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")] + +use crate::{utils::*, Pallet as Parimutuel, *}; +use frame_benchmarking::v2::*; +use frame_support::traits::Get; +use frame_system::RawOrigin; +use orml_traits::MultiCurrency; +use zeitgeist_primitives::types::{Asset, MarketStatus, MarketType, OutcomeReport}; +use zrml_market_commons::MarketCommonsPalletApi; + +fn setup_market(market_type: MarketType) -> MarketIdOf { + let market_id = 0u32.into(); + let market_creator = whitelisted_caller(); + let mut market = market_mock::(market_creator); + market.market_type = market_type; + market.status = MarketStatus::Active; + T::MarketCommons::push_market(market.clone()).unwrap(); + market_id +} + +fn buy_asset( + market_id: MarketIdOf, + asset: AssetOf, + buyer: &T::AccountId, + amount: BalanceOf, +) { + let market = T::MarketCommons::market(&market_id).unwrap(); + T::AssetManager::deposit(market.base_asset, buyer, amount).unwrap(); + Parimutuel::::buy(RawOrigin::Signed(buyer.clone()).into(), asset, amount).unwrap(); +} + +#[benchmarks] +mod benchmarks { + use super::*; + + #[benchmark] + fn buy() { + let buyer = whitelisted_caller(); + + let market_id = setup_market::(MarketType::Categorical(64u16)); + + let amount = T::MinBetSize::get(); + let asset = Asset::ParimutuelShare(market_id, 0u16); + + let market = T::MarketCommons::market(&market_id).unwrap(); + T::AssetManager::deposit(market.base_asset, &buyer, amount).unwrap(); + + #[extrinsic_call] + buy(RawOrigin::Signed(buyer), asset, amount); + } + + #[benchmark] + fn claim_rewards() { + // max category index is worst case + let market_id = setup_market::(MarketType::Categorical(64u16)); + + let winner = whitelisted_caller(); + let winner_asset = Asset::ParimutuelShare(market_id, 0u16); + let winner_amount = T::MinBetSize::get() + T::MinBetSize::get(); + buy_asset::(market_id, winner_asset, &winner, winner_amount); + + let loser = whitelisted_caller(); + let loser_asset = Asset::ParimutuelShare(market_id, 1u16); + let loser_amount = T::MinBetSize::get(); + buy_asset::(market_id, loser_asset, &loser, loser_amount); + + T::MarketCommons::mutate_market(&market_id, |market| { + market.status = MarketStatus::Resolved; + market.resolved_outcome = Some(OutcomeReport::Categorical(0u16)); + Ok(()) + })?; + + #[extrinsic_call] + claim_rewards(RawOrigin::Signed(winner), market_id); + } + + #[benchmark] + fn claim_refunds() { + // max category index is worst case + let market_id = setup_market::(MarketType::Categorical(64u16)); + + let loser_0 = whitelisted_caller(); + let loser_0_index = 0u16; + let loser_0_asset = Asset::ParimutuelShare(market_id, loser_0_index); + let loser_0_amount = T::MinBetSize::get() + T::MinBetSize::get(); + buy_asset::(market_id, loser_0_asset, &loser_0, loser_0_amount); + + let loser_1 = whitelisted_caller(); + let loser_1_index = 1u16; + let loser_1_asset = Asset::ParimutuelShare(market_id, loser_1_index); + let loser_1_amount = T::MinBetSize::get(); + buy_asset::(market_id, loser_1_asset, &loser_1, loser_1_amount); + + T::MarketCommons::mutate_market(&market_id, |market| { + market.status = MarketStatus::Resolved; + let resolved_index = 9u16; + let resolved_outcome = OutcomeReport::Categorical(resolved_index); + assert_ne!(resolved_index, loser_0_index); + assert_ne!(resolved_index, loser_1_index); + let resolved_asset = Asset::ParimutuelShare(market_id, resolved_index); + let resolved_issuance_asset = T::AssetManager::total_issuance(resolved_asset); + assert!(resolved_issuance_asset.is_zero()); + market.resolved_outcome = Some(resolved_outcome); + Ok(()) + })?; + + #[extrinsic_call] + claim_refunds(RawOrigin::Signed(loser_0), loser_0_asset); + } + + impl_benchmark_test_suite!( + Parimutuel, + crate::mock::ExtBuilder::default().build(), + crate::mock::Runtime + ); +} diff --git a/zrml/parimutuel/src/lib.rs b/zrml/parimutuel/src/lib.rs new file mode 100644 index 000000000..5029336c6 --- /dev/null +++ b/zrml/parimutuel/src/lib.rs @@ -0,0 +1,489 @@ +// Copyright 2023 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)] + +mod benchmarking; +mod mock; +mod tests; +mod utils; +pub mod weights; + +pub use pallet::*; + +#[frame_support::pallet] +mod pallet { + use crate::weights::WeightInfoZeitgeist; + use core::marker::PhantomData; + use frame_support::{ + ensure, log, + pallet_prelude::{Decode, DispatchError, Encode, TypeInfo}, + traits::{Get, IsType, StorageVersion}, + PalletId, RuntimeDebug, + }; + use frame_system::{ + ensure_signed, + pallet_prelude::{BlockNumberFor, OriginFor}, + }; + use orml_traits::MultiCurrency; + use sp_runtime::{ + traits::{AccountIdConversion, CheckedSub, Zero}, + DispatchResult, SaturatedConversion, + }; + use zeitgeist_primitives::{ + constants::BASE, + math::fixed::*, + traits::DistributeFees, + types::{Asset, Market, MarketStatus, MarketType, OutcomeReport, ScoringRule}, + }; + use zrml_market_commons::MarketCommonsPalletApi; + + #[pallet::config] + pub trait Config: frame_system::Config { + /// The api to handle different asset classes. + type AssetManager: MultiCurrency>; + + /// The way how fees are taken from the market base asset. + type ExternalFees: DistributeFees< + Asset = Asset>, + AccountId = AccountIdOf, + Balance = BalanceOf, + MarketId = MarketIdOf, + >; + + type MarketCommons: MarketCommonsPalletApi< + AccountId = Self::AccountId, + BlockNumber = Self::BlockNumber, + Balance = BalanceOf, + >; + + /// The minimum amount each bet must be. Must be larger than or equal to the existential + /// deposit of parimutuel shares. + #[pallet::constant] + type MinBetSize: Get>; + + #[pallet::constant] + type PalletId: Get; + + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + + /// Weights generated by benchmarks. + type WeightInfo: WeightInfoZeitgeist; + } + + /// The current storage version. + const STORAGE_VERSION: StorageVersion = StorageVersion::new(0); + const LOG_TARGET: &str = "runtime::zrml-parimutuel"; + + pub(crate) type AssetOf = Asset>; + pub(crate) type AccountIdOf = ::AccountId; + pub(crate) type BalanceOf = + <::AssetManager as MultiCurrency>>::Balance; + 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>>; + + #[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, + { + /// An outcome was bought. + OutcomeBought { + market_id: MarketIdOf, + buyer: AccountIdOf, + asset: AssetOf, + amount_minus_fees: BalanceOf, + fees: BalanceOf, + }, + /// Rewards of the pot were claimed. + RewardsClaimed { + market_id: MarketIdOf, + asset: AssetOf, + withdrawn_asset_balance: BalanceOf, + base_asset_payoff: BalanceOf, + sender: AccountIdOf, + }, + /// A market base asset was refunded. + BalanceRefunded { + market_id: MarketIdOf, + asset: AssetOf, + refunded_balance: BalanceOf, + sender: AccountIdOf, + }, + } + + #[pallet::error] + pub enum Error { + /// There was no buyer for the winning outcome or all winners already claimed their rewards. + /// Use the `refund` extrinsic to get the initial bet back, + /// in case there was no buyer for the winning outcome. + NoRewardShareOutstanding, + /// The market is not active. + MarketIsNotActive, + /// The specified amount is below the minimum bet size. + AmountTooSmall, + /// The specified asset is not a parimutuel share. + NotParimutuelOutcome, + /// The specified asset was not found in the market assets. + InvalidOutcomeAsset, + /// The scoring rule is not parimutuel. + InvalidScoringRule, + /// The specified amount can not be transferred. + InsufficientBalance, + /// The market is not resolved yet. + MarketIsNotResolvedYet, + /// An unexpected error occured. This should never happen! + /// There was an internal coding mistake. + Unexpected, + /// There is no resolved outcome present for the market. + NoResolvedOutcome, + /// The refund is not allowed. + RefundNotAllowed, + /// There is no balance to refund. + RefundableBalanceIsZero, + /// There is no reward, because there are no winning shares. + NoWinningShares, + /// Only categorical markets are allowed for parimutuels. + NotCategorical, + /// There is no reward to distribute. + NoRewardToDistribute, + /// Action cannot be completed because an unexpected error has occurred. This should be + /// reported to protocol maintainers. + InconsistentState(InconsistentStateError), + } + + // NOTE: these errors should never happen. + #[derive(Encode, Decode, Eq, PartialEq, TypeInfo, frame_support::PalletError, RuntimeDebug)] + pub enum InconsistentStateError { + /// There are not enough funds in the pot to reward the calculated amount. + InsufficientFundsInPotAccount, + /// The outcome issuance is greater than the market base asset. + OutcomeIssuanceGreaterCollateral, + } + + #[pallet::call] + impl Pallet { + /// Buy parimutuel shares for the market's base asset. + /// + /// # Arguments + /// + /// - `asset`: The outcome asset to buy the shares of. + /// - `amount`: The amount of base asset to spend + /// and of parimutuel shares to receive. + /// Keep in mind that there are external fees taken from this amount. + /// + /// Complexity: `O(1)` + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::buy())] + #[frame_support::transactional] + pub fn buy( + origin: OriginFor, + asset: Asset>, + #[pallet::compact] amount: BalanceOf, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + Self::do_buy(who, asset, amount)?; + + Ok(()) + } + + /// Claim winnings from a resolved market. + /// + /// Complexity: `O(1)` + #[pallet::call_index(1)] + #[pallet::weight(T::WeightInfo::claim_rewards())] + #[frame_support::transactional] + pub fn claim_rewards(origin: OriginFor, market_id: MarketIdOf) -> DispatchResult { + let who = ensure_signed(origin)?; + + Self::do_claim_rewards(who, market_id)?; + + Ok(()) + } + + /// Refund the base asset of losing categorical outcome assets + /// in case that there was no account betting on the winner outcome. + /// + /// # Arguments + /// + /// - `refund_asset`: The outcome asset to refund. + /// + /// Complexity: `O(1)` + #[pallet::call_index(2)] + #[pallet::weight(T::WeightInfo::claim_refunds())] + #[frame_support::transactional] + pub fn claim_refunds(origin: OriginFor, refund_asset: AssetOf) -> DispatchResult { + let who = ensure_signed(origin)?; + + Self::do_claim_refunds(who, refund_asset)?; + + Ok(()) + } + } + + impl Pallet + where + T: Config, + { + #[inline] + pub(crate) fn pot_account(market_id: MarketIdOf) -> AccountIdOf { + T::PalletId::get().into_sub_account_truncating(market_id) + } + + /// Check the values for validity. + fn check_values( + winning_balance: BalanceOf, + pot_total: BalanceOf, + outcome_total: BalanceOf, + payoff_ratio_mul_base: BalanceOf, + payoff: BalanceOf, + ) -> DispatchResult { + ensure!( + pot_total >= winning_balance, + Error::::InconsistentState( + InconsistentStateError::InsufficientFundsInPotAccount + ) + ); + ensure!( + pot_total >= outcome_total, + Error::::InconsistentState( + InconsistentStateError::OutcomeIssuanceGreaterCollateral + ) + ); + if payoff_ratio_mul_base < BASE.saturated_into() { + log::debug!( + target: LOG_TARGET, + "The payoff ratio should be greater than or equal to BASE!" + ); + debug_assert!(false); + } + if payoff < winning_balance { + log::debug!( + target: LOG_TARGET, + "The payoff in base asset should be greater than or equal to the winning outcome \ + balance." + ); + debug_assert!(false); + } + if pot_total < payoff { + log::debug!( + target: LOG_TARGET, + "The payoff in base asset should not exceed the total amount of the base asset!" + ); + debug_assert!(false); + } + Ok(()) + } + + pub fn market_assets_contains(market: &MarketOf, asset: &AssetOf) -> DispatchResult { + if let Asset::ParimutuelShare(_, i) = asset { + match market.market_type { + MarketType::Categorical(categories) => { + ensure!(*i < categories, Error::::InvalidOutcomeAsset); + return Ok(()); + } + MarketType::Scalar(_) => return Err(Error::::NotCategorical.into()), + } + } + Err(Error::::NotParimutuelOutcome.into()) + } + + fn do_buy(who: T::AccountId, asset: AssetOf, amount: BalanceOf) -> DispatchResult { + ensure!(amount >= T::MinBetSize::get(), Error::::AmountTooSmall); + + let market_id = match asset { + Asset::ParimutuelShare(market_id, _) => market_id, + _ => return Err(Error::::NotParimutuelOutcome.into()), + }; + let market = T::MarketCommons::market(&market_id)?; + let base_asset = market.base_asset; + ensure!( + T::AssetManager::ensure_can_withdraw(base_asset, &who, amount).is_ok(), + Error::::InsufficientBalance + ); + ensure!(market.status == MarketStatus::Active, Error::::MarketIsNotActive); + ensure!(market.scoring_rule == ScoringRule::Parimutuel, Error::::InvalidScoringRule); + ensure!( + matches!(market.market_type, MarketType::Categorical(_)), + Error::::NotCategorical + ); + Self::market_assets_contains(&market, &asset)?; + + let external_fees = T::ExternalFees::distribute(market_id, base_asset, &who, amount); + let amount_minus_fees = + amount.checked_sub(&external_fees).ok_or(Error::::Unexpected)?; + let pot_account = Self::pot_account(market_id); + + T::AssetManager::transfer(market.base_asset, &who, &pot_account, amount_minus_fees)?; + T::AssetManager::deposit(asset, &who, amount_minus_fees)?; + + Self::deposit_event(Event::OutcomeBought { + market_id, + buyer: who, + asset, + amount_minus_fees, + fees: external_fees, + }); + + Ok(()) + } + + fn ensure_parimutuel_market_resolved(market: &MarketOf) -> DispatchResult { + ensure!(market.status == MarketStatus::Resolved, Error::::MarketIsNotResolvedYet); + ensure!(market.scoring_rule == ScoringRule::Parimutuel, Error::::InvalidScoringRule); + ensure!( + matches!(market.market_type, MarketType::Categorical(_)), + Error::::NotCategorical + ); + Ok(()) + } + + fn get_winning_asset( + market_id: MarketIdOf, + market: &MarketOf, + ) -> Result, DispatchError> { + let winning_outcome = + market.resolved_outcome.clone().ok_or(Error::::NoResolvedOutcome)?; + let winning_asset = match winning_outcome { + OutcomeReport::Categorical(category_index) => { + Asset::ParimutuelShare(market_id, category_index) + } + OutcomeReport::Scalar(_) => return Err(Error::::NotCategorical.into()), + }; + Ok(winning_asset) + } + + fn do_claim_rewards(who: T::AccountId, market_id: MarketIdOf) -> DispatchResult { + let market = T::MarketCommons::market(&market_id)?; + Self::ensure_parimutuel_market_resolved(&market)?; + let winning_asset = Self::get_winning_asset(market_id, &market)?; + // each Parimutuel outcome asset has the market id included + // this allows us to query all outstanding shares for each discrete asset + let outcome_total = T::AssetManager::total_issuance(winning_asset); + // if there are no outstanding reward shares, but the pot account is not empty + // then use the refund extrinsic to get the initial bet back + ensure!(outcome_total != BalanceOf::::zero(), Error::::NoRewardShareOutstanding); + let winning_balance = T::AssetManager::free_balance(winning_asset, &who); + ensure!(!winning_balance.is_zero(), Error::::NoWinningShares); + if outcome_total < winning_balance { + log::debug!( + target: LOG_TARGET, + "The outcome issuance should be at least as high as the individual balance of \ + this outcome!" + ); + debug_assert!(false); + } + + let pot_account = Self::pot_account(market_id); + let pot_total = T::AssetManager::free_balance(market.base_asset, &pot_account); + let payoff_ratio_mul_base: BalanceOf = + bdiv_floor(pot_total.saturated_into(), outcome_total.saturated_into())? + .saturated_into(); + let payoff: BalanceOf = bmul_floor( + payoff_ratio_mul_base.saturated_into(), + winning_balance.saturated_into(), + )? + .saturated_into(); + + Self::check_values( + winning_balance, + pot_total, + outcome_total, + payoff_ratio_mul_base, + payoff, + )?; + + let withdrawn_asset_balance = winning_balance; + + T::AssetManager::withdraw(winning_asset, &who, withdrawn_asset_balance)?; + + let remaining_bal = T::AssetManager::free_balance(market.base_asset, &pot_account); + let base_asset_payoff = payoff.min(remaining_bal); + + T::AssetManager::transfer(market.base_asset, &pot_account, &who, base_asset_payoff)?; + + Self::deposit_event(Event::RewardsClaimed { + market_id, + asset: winning_asset, + withdrawn_asset_balance, + base_asset_payoff, + sender: who.clone(), + }); + + Ok(()) + } + + fn do_claim_refunds(who: T::AccountId, refund_asset: AssetOf) -> DispatchResult { + let market_id = match refund_asset { + Asset::ParimutuelShare(market_id, _) => market_id, + _ => return Err(Error::::NotParimutuelOutcome.into()), + }; + let market = T::MarketCommons::market(&market_id)?; + Self::ensure_parimutuel_market_resolved(&market)?; + Self::market_assets_contains(&market, &refund_asset)?; + let winning_asset = Self::get_winning_asset(market_id, &market)?; + let outcome_total = T::AssetManager::total_issuance(winning_asset); + ensure!(outcome_total == >::zero(), Error::::RefundNotAllowed); + + let refund_balance = T::AssetManager::free_balance(refund_asset, &who); + ensure!(!refund_balance.is_zero(), Error::::RefundableBalanceIsZero); + if refund_asset == winning_asset { + log::debug!( + target: LOG_TARGET, + "Since we were checking the total issuance of the winning asset to be zero, if \ + the refund balance is non-zero, then the winning asset can't be the refund \ + asset!" + ); + debug_assert!(false); + } + + T::AssetManager::withdraw(refund_asset, &who, refund_balance)?; + + let pot_account = Self::pot_account(market_id); + let pot_total = T::AssetManager::free_balance(market.base_asset, &pot_account); + if pot_total < refund_balance { + log::debug!( + target: LOG_TARGET, + "The pot total is lower than the refund balance! This should never happen!" + ); + debug_assert!(false); + } + let refund_balance = refund_balance.min(pot_total); + + T::AssetManager::transfer(market.base_asset, &pot_account, &who, refund_balance)?; + + Self::deposit_event(Event::BalanceRefunded { + market_id, + asset: refund_asset, + refunded_balance: refund_balance, + sender: who.clone(), + }); + + Ok(()) + } + } +} diff --git a/zrml/parimutuel/src/mock.rs b/zrml/parimutuel/src/mock.rs new file mode 100644 index 000000000..c04387509 --- /dev/null +++ b/zrml/parimutuel/src/mock.rs @@ -0,0 +1,213 @@ +// Copyright 2023 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(test)] + +extern crate alloc; + +use crate as zrml_parimutuel; +use crate::{AssetOf, BalanceOf, MarketIdOf}; +use alloc::{vec, vec::Vec}; +use core::marker::PhantomData; +use frame_support::{construct_runtime, pallet_prelude::Get, parameter_types, traits::Everything}; +use orml_traits::MultiCurrency; +use sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, + Perbill, SaturatedConversion, +}; +use zeitgeist_primitives::{ + constants::mock::{ + BlockHashCount, ExistentialDeposits, GetNativeCurrencyId, MaxReserves, MinBetSize, + MinimumPeriod, ParimutuelPalletId, PmPalletId, BASE, + }, + traits::DistributeFees, + types::{ + AccountIdTest, Amount, Balance, BasicCurrencyAdapter, BlockNumber, BlockTest, CurrencyId, + Hash, Index, MarketId, Moment, UncheckedExtrinsicTest, + }, +}; + +pub const ALICE: AccountIdTest = 0; +pub const BOB: AccountIdTest = 1; +pub const CHARLIE: AccountIdTest = 2; + +pub const MARKET_CREATOR: AccountIdTest = 42; + +pub const INITIAL_BALANCE: u128 = 1_000 * BASE; + +parameter_types! { + pub const FeeAccount: AccountIdTest = MARKET_CREATOR; +} + +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 = + Perbill::from_rational(1u64, 100u64).mul_floor(amount.saturated_into::>()); + let _ = T::AssetManager::transfer(asset, account, &F::get(), fees); + fees + } +} + +construct_runtime!( + pub enum Runtime + where + Block = BlockTest, + NodeBlock = BlockTest, + UncheckedExtrinsic = UncheckedExtrinsicTest, + { + Parimutuel: zrml_parimutuel::{Event, Pallet, Storage}, + Balances: pallet_balances::{Call, Config, Event, Pallet, Storage}, + AssetManager: orml_currencies::{Call, Pallet, Storage}, + Tokens: orml_tokens::{Config, Event, Pallet, Storage}, + MarketCommons: zrml_market_commons::{Pallet, Storage}, + System: frame_system::{Call, Config, Event, Pallet, Storage}, + Timestamp: pallet_timestamp::{Pallet}, + } +); + +impl crate::Config for Runtime { + type ExternalFees = ExternalFees; + type RuntimeEvent = RuntimeEvent; + type MarketCommons = MarketCommons; + type AssetManager = AssetManager; + type MinBetSize = MinBetSize; + type PalletId = ParimutuelPalletId; + type WeightInfo = crate::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 RuntimeOrigin = RuntimeOrigin; + type PalletInfo = PalletInfo; + type SS58Prefix = (); + type SystemWeightInfo = (); + type Version = (); + type OnSetCode = (); +} + +impl pallet_balances::Config for Runtime { + type AccountStore = System; + type Balance = Balance; + type DustRemoval = (); + type RuntimeEvent = RuntimeEvent; + type ExistentialDeposit = (); + type MaxLocks = (); + type MaxReserves = MaxReserves; + type ReserveIdentifier = [u8; 8]; + type WeightInfo = (); +} + +impl zrml_market_commons::Config for Runtime { + type Balance = Balance; + type MarketId = MarketId; + type PredictionMarketsPalletId = PmPalletId; + type Timestamp = Timestamp; +} + +impl pallet_timestamp::Config for Runtime { + type MinimumPeriod = MinimumPeriod; + type Moment = Moment; + type OnTimestampSet = (); + type WeightInfo = (); +} + +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 = Everything; + type RuntimeEvent = RuntimeEvent; + type ExistentialDeposits = ExistentialDeposits; + type MaxLocks = (); + type MaxReserves = MaxReserves; + type CurrencyHooks = (); + type ReserveIdentifier = [u8; 8]; + type WeightInfo = (); +} + +pub struct ExtBuilder { + balances: Vec<(AccountIdTest, Balance)>, +} + +impl Default for ExtBuilder { + fn default() -> Self { + Self { + balances: vec![ + (ALICE, INITIAL_BALANCE), + (BOB, INITIAL_BALANCE), + (CHARLIE, INITIAL_BALANCE), + ], + } + } +} + +impl ExtBuilder { + pub fn build(self) -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::default().build_storage::().unwrap(); + + pallet_balances::GenesisConfig:: { balances: self.balances } + .assimilate_storage(&mut t) + .unwrap(); + + let mut t: sp_io::TestExternalities = t.into(); + + // to ensure we can have events emitted in the tests. events not present at genesis block + t.execute_with(|| System::set_block_number(1)); + + t + } +} diff --git a/zrml/parimutuel/src/tests/buy.rs b/zrml/parimutuel/src/tests/buy.rs new file mode 100644 index 000000000..c14345582 --- /dev/null +++ b/zrml/parimutuel/src/tests/buy.rs @@ -0,0 +1,220 @@ +// Copyright 2023 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(test)] + +use crate::{mock::*, utils::*, *}; +use core::ops::RangeInclusive; +use frame_support::{assert_noop, assert_ok}; +use orml_traits::MultiCurrency; +use test_case::test_case; +use zeitgeist_primitives::types::{Asset, MarketStatus, MarketType, ScoringRule}; +use zrml_market_commons::{Error as MError, Markets}; + +#[test] +fn buy_emits_event() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + let amount = ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(ALICE), asset, amount)); + + let amount_minus_fees = 9900000000; + let fees = 100000000; + assert_eq!(amount, amount_minus_fees + fees); + + System::assert_last_event( + Event::OutcomeBought { market_id, buyer: ALICE, asset, amount_minus_fees, fees }.into(), + ); + }); +} + +#[test] +fn buy_balances_change_correctly() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.status = MarketStatus::Active; + Markets::::insert(market_id, market.clone()); + + let base_asset = market.base_asset; + + let free_alice_before = AssetManager::free_balance(base_asset, &ALICE); + let free_creator_before = AssetManager::free_balance(base_asset, &market.creator); + let free_pot_before = + AssetManager::free_balance(base_asset, &Parimutuel::pot_account(market_id)); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + let amount = ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(ALICE), asset, amount)); + + let amount_minus_fees = 9900000000; + let fees = 100000000; + assert_eq!(amount, amount_minus_fees + fees); + + assert_eq!(AssetManager::free_balance(base_asset, &ALICE), free_alice_before - amount); + assert_eq!( + AssetManager::free_balance(base_asset, &Parimutuel::pot_account(market_id)) + - free_pot_before, + amount_minus_fees + ); + assert_eq!(AssetManager::free_balance(asset, &ALICE), amount_minus_fees); + assert_eq!( + AssetManager::free_balance(base_asset, &market.creator) - free_creator_before, + fees + ); + }); +} + +#[test] +fn buy_fails_if_asset_not_parimutuel_share() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.status = MarketStatus::Active; + Markets::::insert(market_id, market.clone()); + + let asset = Asset::CategoricalOutcome(market_id, 0u16); + let amount = ::MinBetSize::get(); + assert_noop!( + Parimutuel::buy(RuntimeOrigin::signed(ALICE), asset, amount), + Error::::NotParimutuelOutcome + ); + }); +} + +#[test_case(ScoringRule::CPMM; "cpmm")] +#[test_case(ScoringRule::Orderbook; "orderbook")] +#[test_case(ScoringRule::Lmsr; "lmsr")] +#[test_case(ScoringRule::RikiddoSigmoidFeeMarketEma; "rikiddo sigmoid fee market ema")] +fn buy_fails_if_invalid_scoring_rule(scoring_rule: ScoringRule) { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.status = MarketStatus::Active; + market.scoring_rule = scoring_rule; + + Markets::::insert(market_id, market.clone()); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + let amount = ::MinBetSize::get(); + assert_noop!( + Parimutuel::buy(RuntimeOrigin::signed(ALICE), asset, amount), + Error::::InvalidScoringRule + ); + }); +} + +#[test_case(MarketStatus::Proposed; "proposed")] +#[test_case(MarketStatus::Suspended; "suspended")] +#[test_case(MarketStatus::Closed; "closed")] +#[test_case(MarketStatus::CollectingSubsidy; "collecting subsidy")] +#[test_case(MarketStatus::InsufficientSubsidy; "insufficient subsidy")] +#[test_case(MarketStatus::Reported; "reported")] +#[test_case(MarketStatus::Disputed; "disputed")] +fn buy_fails_if_market_status_is_not_active(status: MarketStatus) { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.status = status; + market.scoring_rule = ScoringRule::Parimutuel; + + Markets::::insert(market_id, market.clone()); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + let amount = ::MinBetSize::get(); + assert_noop!( + Parimutuel::buy(RuntimeOrigin::signed(ALICE), asset, amount), + Error::::MarketIsNotActive + ); + }); +} + +#[test] +fn buy_fails_if_market_type_is_scalar() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + let range: RangeInclusive = 0..=100; + market.market_type = MarketType::Scalar(range); + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + let amount = + ::MinBetSize::get() + ::MinBetSize::get(); + assert_noop!( + Parimutuel::buy(RuntimeOrigin::signed(ALICE), asset, amount), + Error::::NotCategorical + ); + }); +} + +#[test] +fn buy_fails_if_insufficient_balance() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.status = MarketStatus::Active; + Markets::::insert(market_id, market.clone()); + + let free_alice = AssetManager::free_balance(market.base_asset, &ALICE); + AssetManager::slash(market.base_asset, &ALICE, free_alice); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + let amount = ::MinBetSize::get(); + assert_noop!( + Parimutuel::buy(RuntimeOrigin::signed(ALICE), asset, amount), + Error::::InsufficientBalance + ); + }); +} + +#[test] +fn buy_fails_if_below_minimum_bet_size() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.status = MarketStatus::Active; + Markets::::insert(market_id, market.clone()); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + let amount = ::MinBetSize::get() - 1; + assert_noop!( + Parimutuel::buy(RuntimeOrigin::signed(ALICE), asset, amount), + Error::::AmountTooSmall + ); + }); +} + +#[test] +fn buy_fails_if_market_does_not_exist() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + + let asset = Asset::ParimutuelShare(market_id, 0u16); + let amount = ::MinBetSize::get(); + assert_noop!( + Parimutuel::buy(RuntimeOrigin::signed(ALICE), asset, amount), + MError::::MarketDoesNotExist + ); + }); +} diff --git a/zrml/parimutuel/src/tests/claim.rs b/zrml/parimutuel/src/tests/claim.rs new file mode 100644 index 000000000..2fb8f9efd --- /dev/null +++ b/zrml/parimutuel/src/tests/claim.rs @@ -0,0 +1,375 @@ +// Copyright 2023 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(test)] + +use crate::{mock::*, utils::*, *}; +use core::ops::RangeInclusive; +use frame_support::{assert_noop, assert_ok}; +use orml_traits::MultiCurrency; +use sp_runtime::Percent; +use test_case::test_case; +use zeitgeist_primitives::types::{Asset, MarketStatus, MarketType, OutcomeReport, ScoringRule}; +use zrml_market_commons::{Error as MError, Markets}; + +#[test] +fn claim_rewards_emits_event() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let winner_asset = Asset::ParimutuelShare(market_id, 0u16); + let winner_amount = + ::MinBetSize::get() + ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(ALICE), winner_asset, winner_amount)); + + let loser_asset = Asset::ParimutuelShare(market_id, 1u16); + let loser_amount = ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(BOB), loser_asset, loser_amount)); + + let mut market = Markets::::get(market_id).unwrap(); + market.status = MarketStatus::Resolved; + market.resolved_outcome = Some(OutcomeReport::Categorical(0u16)); + Markets::::insert(market_id, market); + + assert_ok!(Parimutuel::claim_rewards(RuntimeOrigin::signed(ALICE), market_id)); + + let withdrawn_asset_balance = 19800000000; + let actual_payoff = 29700000000; + + System::assert_last_event( + Event::RewardsClaimed { + market_id, + asset: winner_asset, + withdrawn_asset_balance, + base_asset_payoff: actual_payoff, + sender: ALICE, + } + .into(), + ); + }); +} + +#[test] +fn claim_rewards_categorical_changes_balances_correctly() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let winner_asset = Asset::ParimutuelShare(market_id, 0u16); + let winner_amount_0 = + ::MinBetSize::get() + ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(ALICE), winner_asset, winner_amount_0)); + + let winner_amount_1 = ::MinBetSize::get() + + ::MinBetSize::get() + + ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(CHARLIE), winner_asset, winner_amount_1)); + + let loser_asset = Asset::ParimutuelShare(market_id, 1u16); + let loser_amount = ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(BOB), loser_asset, loser_amount)); + + let mut market = Markets::::get(market_id).unwrap(); + market.status = MarketStatus::Resolved; + market.resolved_outcome = Some(OutcomeReport::Categorical(0u16)); + Markets::::insert(market_id, market.clone()); + + let actual_payoff = 59400000000; + let winner_amount = winner_amount_0 + winner_amount_1; + let total_pot_amount = loser_amount + winner_amount; + let total_fees = Percent::from_percent(1) * total_pot_amount; + assert_eq!(actual_payoff, total_pot_amount - total_fees); + + // 2/5 from 59400000000 = 23760000000 + let actual_payoff_alice = 23760000000; + assert_eq!(Percent::from_percent(40) * actual_payoff, actual_payoff_alice); + // 3/5 from 59400000000 = 35640000000 + let actual_payoff_charlie = 35640000000; + assert_eq!(Percent::from_percent(60) * actual_payoff, actual_payoff_charlie); + assert_eq!(actual_payoff_alice + actual_payoff_charlie, actual_payoff); + + let free_winner_asset_alice_before = AssetManager::free_balance(winner_asset, &ALICE); + let winner_amount_0_minus_fees = + winner_amount_0 - Percent::from_percent(1) * winner_amount_0; + assert_eq!(free_winner_asset_alice_before, winner_amount_0_minus_fees); + let free_base_asset_alice_before = AssetManager::free_balance(market.base_asset, &ALICE); + let free_base_asset_pot_before = + AssetManager::free_balance(market.base_asset, &Parimutuel::pot_account(market_id)); + assert_eq!(free_base_asset_pot_before, total_pot_amount - total_fees); + + assert_ok!(Parimutuel::claim_rewards(RuntimeOrigin::signed(ALICE), market_id)); + + assert_eq!( + free_winner_asset_alice_before - AssetManager::free_balance(winner_asset, &ALICE), + winner_amount_0_minus_fees + ); + + assert_eq!( + AssetManager::free_balance(market.base_asset, &ALICE) - free_base_asset_alice_before, + actual_payoff_alice + ); + + assert_eq!( + AssetManager::free_balance(market.base_asset, &Parimutuel::pot_account(market_id)), + actual_payoff_charlie + ); + + let free_winner_asset_charlie_before = AssetManager::free_balance(winner_asset, &CHARLIE); + let winner_amount_1_minus_fees = + winner_amount_1 - Percent::from_percent(1) * winner_amount_1; + assert_eq!(free_winner_asset_charlie_before, winner_amount_1_minus_fees); + let free_base_asset_charlie_before = + AssetManager::free_balance(market.base_asset, &CHARLIE); + + assert_ok!(Parimutuel::claim_rewards(RuntimeOrigin::signed(CHARLIE), market_id)); + + assert_eq!( + free_winner_asset_charlie_before - AssetManager::free_balance(winner_asset, &CHARLIE), + winner_amount_1_minus_fees + ); + assert_eq!( + AssetManager::free_balance(market.base_asset, &CHARLIE) + - free_base_asset_charlie_before, + actual_payoff_charlie + ); + assert_eq!( + AssetManager::free_balance(market.base_asset, &Parimutuel::pot_account(market_id)), + 0 + ); + }); +} + +#[test] +fn claim_rewards_fails_if_market_type_is_scalar() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + let range: RangeInclusive = 0..=100; + market.market_type = MarketType::Scalar(range); + market.resolved_outcome = Some(OutcomeReport::Scalar(50)); + market.status = MarketStatus::Resolved; + Markets::::insert(market_id, market); + + assert_noop!( + Parimutuel::claim_rewards(RuntimeOrigin::signed(ALICE), market_id), + Error::::NotCategorical + ); + }); +} + +#[test_case(MarketStatus::Active; "active")] +#[test_case(MarketStatus::Proposed; "proposed")] +#[test_case(MarketStatus::Suspended; "suspended")] +#[test_case(MarketStatus::Closed; "closed")] +#[test_case(MarketStatus::CollectingSubsidy; "collecting subsidy")] +#[test_case(MarketStatus::InsufficientSubsidy; "insufficient subsidy")] +#[test_case(MarketStatus::Reported; "reported")] +#[test_case(MarketStatus::Disputed; "disputed")] +fn claim_rewards_fails_if_not_resolved(status: MarketStatus) { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.status = status; + Markets::::insert(market_id, market); + + assert_noop!( + Parimutuel::claim_rewards(RuntimeOrigin::signed(ALICE), market_id), + Error::::MarketIsNotResolvedYet + ); + }); +} + +#[test_case(ScoringRule::CPMM; "cpmm")] +#[test_case(ScoringRule::Orderbook; "orderbook")] +#[test_case(ScoringRule::Lmsr; "lmsr")] +#[test_case(ScoringRule::RikiddoSigmoidFeeMarketEma; "rikiddo sigmoid fee market ema")] +fn claim_rewards_fails_if_scoring_rule_not_parimutuel(scoring_rule: ScoringRule) { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.status = MarketStatus::Resolved; + market.resolved_outcome = Some(OutcomeReport::Categorical(0u16)); + market.scoring_rule = scoring_rule; + Markets::::insert(market_id, market); + + assert_noop!( + Parimutuel::claim_rewards(RuntimeOrigin::signed(ALICE), market_id), + Error::::InvalidScoringRule + ); + }); +} + +#[test] +fn claim_rewards_fails_if_no_resolved_outcome() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.status = MarketStatus::Resolved; + market.resolved_outcome = None; + Markets::::insert(market_id, market); + + assert_noop!( + Parimutuel::claim_rewards(RuntimeOrigin::signed(ALICE), market_id), + Error::::NoResolvedOutcome + ); + }); +} + +#[test] +fn claim_rewards_fails_if_market_does_not_exist() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + + assert_noop!( + Parimutuel::claim_rewards(RuntimeOrigin::signed(ALICE), market_id), + MError::::MarketDoesNotExist + ); + }); +} + +#[test] +fn claim_rewards_categorical_fails_if_no_winner() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let winner_asset = Asset::ParimutuelShare(market_id, 0u16); + let winner_amount = + ::MinBetSize::get() + ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(ALICE), winner_asset, winner_amount)); + + let loser_asset = Asset::ParimutuelShare(market_id, 1u16); + let loser_amount = ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(BOB), loser_asset, loser_amount)); + + let mut market = Markets::::get(market_id).unwrap(); + market.status = MarketStatus::Resolved; + let winner_outcome = OutcomeReport::Categorical(6u16); + market.resolved_outcome = Some(winner_outcome); + Markets::::insert(market_id, market); + + assert_noop!( + Parimutuel::claim_rewards(RuntimeOrigin::signed(ALICE), market_id), + Error::::NoRewardShareOutstanding + ); + }); +} + +#[test] +fn claim_rewards_categorical_fails_if_no_winning_shares() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.market_type = MarketType::Categorical(10u16); + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let winner_asset = Asset::ParimutuelShare(market_id, 0u16); + let winner_amount = + ::MinBetSize::get() + ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(ALICE), winner_asset, winner_amount)); + + let loser_asset = Asset::ParimutuelShare(market_id, 1u16); + let loser_amount = ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(BOB), loser_asset, loser_amount)); + + let mut market = Markets::::get(market_id).unwrap(); + market.status = MarketStatus::Resolved; + let winner_outcome = OutcomeReport::Categorical(0u16); + market.resolved_outcome = Some(winner_outcome); + Markets::::insert(market_id, market); + + assert_noop!( + Parimutuel::claim_rewards(RuntimeOrigin::signed(BOB), market_id), + Error::::NoWinningShares + ); + }); +} + +#[test] +fn claim_refunds_works() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.market_type = MarketType::Categorical(10u16); + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let alice_asset = Asset::ParimutuelShare(market_id, 0u16); + let alice_amount = + ::MinBetSize::get() + ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(ALICE), alice_asset, alice_amount)); + + let bob_asset = Asset::ParimutuelShare(market_id, 1u16); + let bob_amount = ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(BOB), bob_asset, bob_amount)); + + let mut market = Markets::::get(market_id).unwrap(); + market.status = MarketStatus::Resolved; + // no winner, because nobody bought shares of the winning outcome + let winner_outcome = OutcomeReport::Categorical(2u16); + market.resolved_outcome = Some(winner_outcome); + Markets::::insert(market_id, market.clone()); + + assert_noop!( + Parimutuel::claim_rewards(RuntimeOrigin::signed(ALICE), market_id), + Error::::NoRewardShareOutstanding + ); + + let alice_paid_fees = Percent::from_percent(1) * alice_amount; + let bob_paid_fees = Percent::from_percent(1) * bob_amount; + let alice_amount_minus_fees = alice_amount - alice_paid_fees; + let bob_amount_minus_fees = bob_amount - bob_paid_fees; + + let free_base_asset_alice_before = AssetManager::free_balance(market.base_asset, &ALICE); + let free_base_asset_bob_before = AssetManager::free_balance(market.base_asset, &BOB); + let free_base_asset_pot_before = + AssetManager::free_balance(market.base_asset, &Parimutuel::pot_account(market_id)); + + assert_ok!(Parimutuel::claim_refunds(RuntimeOrigin::signed(ALICE), alice_asset)); + + assert_eq!( + AssetManager::free_balance(market.base_asset, &ALICE) - free_base_asset_alice_before, + alice_amount_minus_fees + ); + assert_eq!( + AssetManager::free_balance(market.base_asset, &Parimutuel::pot_account(market_id)), + free_base_asset_pot_before - alice_amount_minus_fees + ); + assert_eq!( + AssetManager::free_balance(market.base_asset, &Parimutuel::pot_account(market_id)), + bob_amount_minus_fees + ); + + assert_ok!(Parimutuel::claim_refunds(RuntimeOrigin::signed(BOB), bob_asset)); + assert_eq!( + AssetManager::free_balance(market.base_asset, &BOB) - free_base_asset_bob_before, + bob_amount_minus_fees + ); + assert_eq!( + AssetManager::free_balance(market.base_asset, &Parimutuel::pot_account(market_id)), + 0 + ); + }); +} diff --git a/zrml/parimutuel/src/tests/mod.rs b/zrml/parimutuel/src/tests/mod.rs new file mode 100644 index 000000000..e11635e0d --- /dev/null +++ b/zrml/parimutuel/src/tests/mod.rs @@ -0,0 +1,22 @@ +// Copyright 2023 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(test)] + +mod buy; +mod claim; +mod refund; diff --git a/zrml/parimutuel/src/tests/refund.rs b/zrml/parimutuel/src/tests/refund.rs new file mode 100644 index 000000000..6f3c95015 --- /dev/null +++ b/zrml/parimutuel/src/tests/refund.rs @@ -0,0 +1,218 @@ +// Copyright 2023 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(test)] + +use crate::{mock::*, utils::*, *}; +use frame_support::{assert_noop, assert_ok}; +use sp_runtime::Percent; +use test_case::test_case; +use zeitgeist_primitives::types::{Asset, MarketStatus, MarketType, OutcomeReport, ScoringRule}; +use zrml_market_commons::Markets; + +#[test] +fn refund_fails_if_not_parimutuel_outcome() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.market_type = MarketType::Categorical(10u16); + market.resolved_outcome = Some(OutcomeReport::Categorical(0u16)); + market.status = MarketStatus::Resolved; + Markets::::insert(market_id, market); + + assert_noop!( + Parimutuel::claim_refunds( + RuntimeOrigin::signed(ALICE), + Asset::CategoricalOutcome(market_id, 0u16) + ), + Error::::NotParimutuelOutcome + ); + }); +} + +#[test_case(MarketStatus::Active; "active")] +#[test_case(MarketStatus::Proposed; "proposed")] +#[test_case(MarketStatus::Suspended; "suspended")] +#[test_case(MarketStatus::Closed; "closed")] +#[test_case(MarketStatus::CollectingSubsidy; "collecting subsidy")] +#[test_case(MarketStatus::InsufficientSubsidy; "insufficient subsidy")] +#[test_case(MarketStatus::Reported; "reported")] +#[test_case(MarketStatus::Disputed; "disputed")] +fn refund_fails_if_market_not_resolved(status: MarketStatus) { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.market_type = MarketType::Categorical(10u16); + market.status = status; + Markets::::insert(market_id, market); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + assert_noop!( + Parimutuel::claim_refunds(RuntimeOrigin::signed(ALICE), asset), + Error::::MarketIsNotResolvedYet + ); + }); +} + +#[test_case(ScoringRule::CPMM; "cpmm")] +#[test_case(ScoringRule::Orderbook; "orderbook")] +#[test_case(ScoringRule::Lmsr; "lmsr")] +#[test_case(ScoringRule::RikiddoSigmoidFeeMarketEma; "rikiddo sigmoid fee market ema")] +fn refund_fails_if_invalid_scoring_rule(scoring_rule: ScoringRule) { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.market_type = MarketType::Categorical(10u16); + market.resolved_outcome = Some(OutcomeReport::Categorical(0u16)); + market.status = MarketStatus::Resolved; + // invalid scoring rule + market.scoring_rule = scoring_rule; + Markets::::insert(market_id, market); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + assert_noop!( + Parimutuel::claim_refunds(RuntimeOrigin::signed(ALICE), asset), + Error::::InvalidScoringRule + ); + }); +} + +#[test] +fn refund_fails_if_invalid_outcome_asset() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.market_type = MarketType::Categorical(10u16); + market.resolved_outcome = Some(OutcomeReport::Categorical(0u16)); + market.status = MarketStatus::Resolved; + Markets::::insert(market_id, market); + + let asset = Asset::ParimutuelShare(market_id, 20u16); + assert_noop!( + Parimutuel::claim_refunds(RuntimeOrigin::signed(ALICE), asset), + Error::::InvalidOutcomeAsset + ); + }); +} + +#[test] +fn refund_fails_if_no_resolved_outcome() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.market_type = MarketType::Categorical(10u16); + market.status = MarketStatus::Resolved; + market.resolved_outcome = None; + Markets::::insert(market_id, market); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + assert_noop!( + Parimutuel::claim_refunds(RuntimeOrigin::signed(ALICE), asset), + Error::::NoResolvedOutcome + ); + }); +} + +#[test] +fn refund_fails_if_refund_not_allowed() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.market_type = MarketType::Categorical(10u16); + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + let amount = ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(ALICE), asset, amount)); + + let mut market = Markets::::get(market_id).unwrap(); + market.resolved_outcome = Some(OutcomeReport::Categorical(0u16)); + market.status = MarketStatus::Resolved; + Markets::::insert(market_id, market); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + assert_noop!( + Parimutuel::claim_refunds(RuntimeOrigin::signed(ALICE), asset), + Error::::RefundNotAllowed + ); + }); +} + +#[test] +fn refund_fails_if_refundable_balance_is_zero() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.market_type = MarketType::Categorical(10u16); + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + let amount = ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(ALICE), asset, amount)); + + let mut market = Markets::::get(market_id).unwrap(); + market.resolved_outcome = Some(OutcomeReport::Categorical(1u16)); + market.status = MarketStatus::Resolved; + Markets::::insert(market_id, market); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + assert_ok!(Parimutuel::claim_refunds(RuntimeOrigin::signed(ALICE), asset)); + + // already refunded above + assert_noop!( + Parimutuel::claim_refunds(RuntimeOrigin::signed(ALICE), asset), + Error::::RefundableBalanceIsZero + ); + }); +} + +#[test] +fn refund_emits_event() { + ExtBuilder::default().build().execute_with(|| { + let market_id = 0; + let mut market = market_mock::(MARKET_CREATOR); + market.market_type = MarketType::Categorical(10u16); + market.status = MarketStatus::Active; + Markets::::insert(market_id, market); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + let amount = ::MinBetSize::get(); + assert_ok!(Parimutuel::buy(RuntimeOrigin::signed(ALICE), asset, amount)); + + let mut market = Markets::::get(market_id).unwrap(); + market.resolved_outcome = Some(OutcomeReport::Categorical(1u16)); + market.status = MarketStatus::Resolved; + Markets::::insert(market_id, market); + + let asset = Asset::ParimutuelShare(market_id, 0u16); + assert_ok!(Parimutuel::claim_refunds(RuntimeOrigin::signed(ALICE), asset)); + + let amount_minus_fees = amount - (Percent::from_percent(1) * amount); + + System::assert_last_event( + Event::BalanceRefunded { + market_id, + asset, + refunded_balance: amount_minus_fees, + sender: ALICE, + } + .into(), + ); + }); +} diff --git a/zrml/parimutuel/src/utils.rs b/zrml/parimutuel/src/utils.rs new file mode 100644 index 000000000..e11696937 --- /dev/null +++ b/zrml/parimutuel/src/utils.rs @@ -0,0 +1,51 @@ +// Copyright 2023 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(any(feature = "runtime-benchmarks", test))] +pub(crate) fn market_mock(creator: T::AccountId) -> crate::MarketOf +where + T: crate::Config, +{ + use frame_support::traits::Get; + use sp_runtime::{traits::AccountIdConversion, Perbill}; + use zeitgeist_primitives::types::{ + Asset, Deadlines, MarketBonds, MarketCreation, MarketDisputeMechanism, MarketPeriod, + MarketStatus, MarketType, ScoringRule, + }; + + zeitgeist_primitives::types::Market { + base_asset: Asset::Ztg, + creation: MarketCreation::Permissionless, + creator_fee: Perbill::zero(), + creator, + market_type: MarketType::Categorical(10u16), + dispute_mechanism: Some(MarketDisputeMechanism::Authorized), + metadata: Default::default(), + oracle: T::PalletId::get().into_account_truncating(), + period: MarketPeriod::Block(Default::default()), + deadlines: Deadlines { + grace_period: 1_u32.into(), + oracle_duration: 1_u32.into(), + dispute_duration: 1_u32.into(), + }, + report: None, + resolved_outcome: None, + scoring_rule: ScoringRule::Parimutuel, + status: MarketStatus::Active, + bonds: MarketBonds::default(), + } +} diff --git a/zrml/parimutuel/src/weights.rs b/zrml/parimutuel/src/weights.rs new file mode 100644 index 000000000..bf646c150 --- /dev/null +++ b/zrml/parimutuel/src/weights.rs @@ -0,0 +1,111 @@ +// Copyright 2023 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 . + +//! Autogenerated weights for zrml_parimutuel +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev +//! DATE: `2023-10-13`, STEPS: `10`, REPEAT: `1000`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `Chralt-3.local`, CPU: `` +//! EXECUTION: `Some(Wasm)`, WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` + +// Executed Command: +// ./target/release/zeitgeist +// benchmark +// pallet +// --chain=dev +// --steps=10 +// --repeat=1000 +// --pallet=zrml_parimutuel +// --extrinsic=* +// --execution=wasm +// --wasm-execution=compiled +// --heap-pages=4096 +// --template=./misc/weight_template.hbs +// --output=./zrml/parimutuel/src/weights.rs + +#![allow(unused_parens)] +#![allow(unused_imports)] + +use core::marker::PhantomData; +use frame_support::{traits::Get, weights::Weight}; + +/// Trait containing the required functions for weight retrival within +/// zrml_parimutuel (automatically generated) +pub trait WeightInfoZeitgeist { + fn buy() -> Weight; + fn claim_rewards() -> Weight; + fn claim_refunds() -> Weight; +} + +/// Weight functions for zrml_parimutuel (automatically generated) +pub struct WeightInfo(PhantomData); +impl WeightInfoZeitgeist for WeightInfo { + /// Storage: MarketCommons Markets (r:1 w:0) + /// Proof: MarketCommons Markets (max_values: None, max_size: Some(543), added: 3018, mode: MaxEncodedLen) + /// Storage: System Account (r:1 w:1) + /// Proof: System Account (max_values: None, max_size: Some(132), added: 2607, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:1 w:1) + /// Proof: Tokens Accounts (max_values: None, max_size: Some(124), added: 2599, mode: MaxEncodedLen) + /// Storage: Tokens TotalIssuance (r:1 w:1) + /// Proof: Tokens TotalIssuance (max_values: None, max_size: Some(44), added: 2519, mode: MaxEncodedLen) + /// Storage: Parimutuel TotalRewards (r:1 w:1) + /// Proof: Parimutuel TotalRewards (max_values: None, max_size: Some(40), added: 2515, mode: MaxEncodedLen) + fn buy() -> Weight { + // Proof Size summary in bytes: + // Measured: `1815` + // Estimated: `13258` + // Minimum execution time: 79_000 nanoseconds. + Weight::from_parts(84_000_000, 13258) + .saturating_add(T::DbWeight::get().reads(5)) + .saturating_add(T::DbWeight::get().writes(4)) + } + /// Storage: MarketCommons Markets (r:1 w:0) + /// Proof: MarketCommons Markets (max_values: None, max_size: Some(543), added: 3018, mode: MaxEncodedLen) + /// Storage: Tokens TotalIssuance (r:1 w:1) + /// Proof: Tokens TotalIssuance (max_values: None, max_size: Some(44), added: 2519, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:1 w:1) + /// Proof: Tokens Accounts (max_values: None, max_size: Some(124), added: 2599, mode: MaxEncodedLen) + /// Storage: System Account (r:1 w:1) + /// Proof: System Account (max_values: None, max_size: Some(132), added: 2607, mode: MaxEncodedLen) + fn claim_rewards() -> Weight { + // Proof Size summary in bytes: + // Measured: `2311` + // Estimated: `10743` + // Minimum execution time: 75_000 nanoseconds. + Weight::from_parts(81_000_000, 10743) + .saturating_add(T::DbWeight::get().reads(4)) + .saturating_add(T::DbWeight::get().writes(3)) + } + /// Storage: MarketCommons Markets (r:1 w:0) + /// Proof: MarketCommons Markets (max_values: None, max_size: Some(543), added: 3018, mode: MaxEncodedLen) + /// Storage: Tokens TotalIssuance (r:2 w:1) + /// Proof: Tokens TotalIssuance (max_values: None, max_size: Some(44), added: 2519, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:1 w:1) + /// Proof: Tokens Accounts (max_values: None, max_size: Some(124), added: 2599, mode: MaxEncodedLen) + /// Storage: System Account (r:1 w:1) + /// Proof: System Account (max_values: None, max_size: Some(132), added: 2607, mode: MaxEncodedLen) + fn claim_refunds() -> Weight { + // Proof Size summary in bytes: + // Measured: `2311` + // Estimated: `13262` + // Minimum execution time: 75_000 nanoseconds. + Weight::from_parts(77_000_000, 13262) + .saturating_add(T::DbWeight::get().reads(5)) + .saturating_add(T::DbWeight::get().writes(3)) + } +} diff --git a/zrml/prediction-markets/src/lib.rs b/zrml/prediction-markets/src/lib.rs index a15010b3b..1e527a63d 100644 --- a/zrml/prediction-markets/src/lib.rs +++ b/zrml/prediction-markets/src/lib.rs @@ -424,7 +424,10 @@ mod pallet { ); match m.scoring_rule { - ScoringRule::CPMM | ScoringRule::Lmsr | ScoringRule::Orderbook => { + ScoringRule::CPMM + | ScoringRule::Lmsr + | ScoringRule::Parimutuel + | ScoringRule::Orderbook => { m.status = MarketStatus::Active; } ScoringRule::RikiddoSigmoidFeeMarketEma => { @@ -973,6 +976,7 @@ mod pallet { let market_account = >::market_account(market_id); ensure!(market.status == MarketStatus::Resolved, Error::::MarketIsNotResolved); + ensure!(market.is_redeemable(), Error::::InvalidResolutionMechanism); // Check to see if the sender has any winning shares. let resolved_outcome = @@ -1654,6 +1658,8 @@ mod pallet { NonZeroDisputePeriodOnTrustedMarket, /// The fee is too high. FeeTooHigh, + /// The resolution mechanism resulting from the scoring rule is not supported. + InvalidResolutionMechanism, } #[pallet::event] @@ -3034,9 +3040,10 @@ mod pallet { } let status: MarketStatus = match creation { MarketCreation::Permissionless => match scoring_rule { - ScoringRule::CPMM | ScoringRule::Lmsr | ScoringRule::Orderbook => { - MarketStatus::Active - } + ScoringRule::CPMM + | ScoringRule::Lmsr + | ScoringRule::Parimutuel + | ScoringRule::Orderbook => MarketStatus::Active, ScoringRule::RikiddoSigmoidFeeMarketEma => MarketStatus::CollectingSubsidy, }, MarketCreation::Advised => MarketStatus::Proposed, diff --git a/zrml/prediction-markets/src/tests.rs b/zrml/prediction-markets/src/tests.rs index 4320b5f37..8cd67e8bf 100644 --- a/zrml/prediction-markets/src/tests.rs +++ b/zrml/prediction-markets/src/tests.rs @@ -169,6 +169,23 @@ fn buy_complete_set_fails_if_market_is_not_active(status: MarketStatus) { }); } +#[test_case(ScoringRule::Parimutuel)] +fn buy_complete_set_fails_if_market_has_wrong_scoring_rule(scoring_rule: ScoringRule) { + ExtBuilder::default().build().execute_with(|| { + simple_create_categorical_market( + Asset::Ztg, + MarketCreation::Permissionless, + 0..2, + scoring_rule, + ); + let market_id = 0; + assert_noop!( + PredictionMarkets::buy_complete_set(RuntimeOrigin::signed(FRED), market_id, 1), + Error::::InvalidScoringRule, + ); + }); +} + #[test] fn admin_move_market_to_closed_successfully_closes_market_and_sets_end_blocknumber() { ExtBuilder::default().build().execute_with(|| { @@ -1857,6 +1874,29 @@ fn it_does_not_allow_to_sell_complete_sets_with_insufficient_balance() { }); } +#[test_case(ScoringRule::Parimutuel; "parimutuel")] +fn sell_complete_set_fails_if_market_has_wrong_scoring_rule(scoring_rule: ScoringRule) { + let test = |base_asset: Asset| { + simple_create_categorical_market( + base_asset, + MarketCreation::Permissionless, + 0..1, + scoring_rule, + ); + assert_noop!( + PredictionMarkets::sell_complete_set(RuntimeOrigin::signed(BOB), 0, 2 * CENT), + Error::::InvalidScoringRule + ); + }; + ExtBuilder::default().build().execute_with(|| { + test(Asset::Ztg); + }); + #[cfg(feature = "parachain")] + ExtBuilder::default().build().execute_with(|| { + test(Asset::ForeignAsset(100)); + }); +} + #[test] fn it_allows_to_report_the_outcome_of_a_market() { ExtBuilder::default().build().execute_with(|| { @@ -3069,6 +3109,36 @@ fn it_allows_to_redeem_shares() { }); } +#[test_case(ScoringRule::Parimutuel; "parimutuel")] +fn redeem_shares_fails_if_invalid_resolution_mechanism(scoring_rule: ScoringRule) { + let test = |base_asset: Asset| { + let end = 2; + simple_create_categorical_market( + base_asset, + MarketCreation::Permissionless, + 0..end, + scoring_rule, + ); + + assert_ok!(MarketCommons::mutate_market(&0, |market_inner| { + market_inner.status = MarketStatus::Resolved; + Ok(()) + })); + + assert_noop!( + PredictionMarkets::redeem_shares(RuntimeOrigin::signed(CHARLIE), 0), + Error::::InvalidResolutionMechanism + ); + }; + ExtBuilder::default().build().execute_with(|| { + test(Asset::Ztg); + }); + #[cfg(feature = "parachain")] + ExtBuilder::default().build().execute_with(|| { + test(Asset::ForeignAsset(100)); + }); +} + #[test] fn create_market_and_deploy_assets_results_in_expected_balances_and_pool_params() { let test = |base_asset: Asset| { diff --git a/zrml/swaps/fuzz/utils.rs b/zrml/swaps/fuzz/utils.rs index 6c8dfff73..6c4272ecf 100644 --- a/zrml/swaps/fuzz/utils.rs +++ b/zrml/swaps/fuzz/utils.rs @@ -35,15 +35,15 @@ use zrml_swaps::mock::{Swaps, DEFAULT_MARKET_ID}; pub fn construct_asset(seed: (u8, u128, u16)) -> Asset { let (module, seed0, seed1) = seed; - match module % 5 { + + match module % 4 { 0 => Asset::CategoricalOutcome(seed0, seed1), 1 => { let scalar_position = if seed1 % 2 == 0 { ScalarPosition::Long } else { ScalarPosition::Short }; Asset::ScalarOutcome(seed0, scalar_position) } - 2 => Asset::CombinatorialOutcome, - 3 => Asset::PoolShare(SerdeWrapper(seed0)), + 2 => Asset::PoolShare(SerdeWrapper(seed0)), _ => Asset::Ztg, } } diff --git a/zrml/swaps/src/lib.rs b/zrml/swaps/src/lib.rs index 0e3b999d8..e0d8c1a67 100644 --- a/zrml/swaps/src/lib.rs +++ b/zrml/swaps/src/lib.rs @@ -1964,7 +1964,7 @@ mod pallet { let pool_amount = BalanceOf::::zero(); (pool_status, total_subsidy, total_weight, weights, pool_amount) } - ScoringRule::Lmsr | ScoringRule::Orderbook => { + ScoringRule::Lmsr | ScoringRule::Parimutuel | ScoringRule::Orderbook => { return Err(Error::::InvalidScoringRule.into()); } }; @@ -2526,7 +2526,7 @@ mod pallet { T::RikiddoSigmoidFeeMarketEma::cost(pool_id, &outstanding_after)?; cost_before.checked_sub(&cost_after).ok_or(ArithmeticError::Overflow)? } - ScoringRule::Lmsr | ScoringRule::Orderbook => { + ScoringRule::Lmsr | ScoringRule::Parimutuel | ScoringRule::Orderbook => { return Err(Error::::InvalidScoringRule.into()); } }; @@ -2579,7 +2579,7 @@ mod pallet { ScoringRule::RikiddoSigmoidFeeMarketEma => Ok( T::WeightInfo::swap_exact_amount_in_rikiddo(pool.assets.len().saturated_into()), ), - ScoringRule::Lmsr | ScoringRule::Orderbook => { + ScoringRule::Lmsr | ScoringRule::Parimutuel | ScoringRule::Orderbook => { Err(Error::::InvalidScoringRule.into()) } } @@ -2689,7 +2689,7 @@ mod pallet { T::RikiddoSigmoidFeeMarketEma::cost(pool_id, &outstanding_after)?; cost_after.checked_sub(&cost_before).ok_or(ArithmeticError::Overflow)? } - ScoringRule::Lmsr | ScoringRule::Orderbook => { + ScoringRule::Lmsr | ScoringRule::Parimutuel | ScoringRule::Orderbook => { return Err(Error::::InvalidScoringRule.into()); } }; @@ -2755,7 +2755,7 @@ mod pallet { pool.assets.len().saturated_into(), )) } - ScoringRule::Lmsr | ScoringRule::Orderbook => { + ScoringRule::Lmsr | ScoringRule::Parimutuel | ScoringRule::Orderbook => { Err(Error::::InvalidScoringRule.into()) } } diff --git a/zrml/swaps/src/utils.rs b/zrml/swaps/src/utils.rs index 9006b5ee6..afe06ca3c 100644 --- a/zrml/swaps/src/utils.rs +++ b/zrml/swaps/src/utils.rs @@ -216,7 +216,7 @@ where return Err(Error::::UnsupportedTrade.into()); } } - ScoringRule::Lmsr | ScoringRule::Orderbook => { + ScoringRule::Lmsr | ScoringRule::Parimutuel | ScoringRule::Orderbook => { return Err(Error::::InvalidScoringRule.into()); } } @@ -233,7 +233,7 @@ where spot_price_before.saturating_sub(spot_price_after) < 20u8.into(), Error::::MathApproximation ), - ScoringRule::Lmsr | ScoringRule::Orderbook => { + ScoringRule::Lmsr | ScoringRule::Parimutuel | ScoringRule::Orderbook => { return Err(Error::::InvalidScoringRule.into()); } } @@ -256,7 +256,7 @@ where let volume = if p.asset_in == base_asset { asset_amount_in } else { asset_amount_out }; T::RikiddoSigmoidFeeMarketEma::update_volume(p.pool_id, volume)?; } - ScoringRule::Lmsr | ScoringRule::Orderbook => { + ScoringRule::Lmsr | ScoringRule::Parimutuel | ScoringRule::Orderbook => { return Err(Error::::InvalidScoringRule.into()); } }