diff --git a/CODEOWNERS b/CODEOWNERS index 74c117914..00e47700b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -11,6 +11,7 @@ /zrml/authorized/ @Chralt98 /zrml/court/ @Chralt98 /zrml/global-disputes/ @Chralt98 +/zrml/neo-swaps/ @maltekliemann /zrml/prediction-markets/ @maltekliemann /zrml/rikiddo/ @sea212 /zrml/simple-disputes/ @Chralt98 @@ -19,4 +20,4 @@ # Skip weight and Cargo.toml files **/weights.rs -**/Cargo.toml \ No newline at end of file +**/Cargo.toml diff --git a/Cargo.lock b/Cargo.lock index 0fdc7e3d1..d97dc2c9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -473,6 +473,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "az" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" + [[package]] name = "backtrace" version = "0.3.68" @@ -617,6 +623,7 @@ dependencies = [ "zrml-global-disputes", "zrml-liquidity-mining", "zrml-market-commons", + "zrml-neo-swaps", "zrml-orderbook-v1", "zrml-prediction-markets", "zrml-rikiddo", @@ -903,6 +910,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" +[[package]] +name = "bytemuck" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17febce684fd15d89027105661fec94afb475cb995fbc59d2865198446ba2eea" + [[package]] name = "byteorder" version = "1.4.3" @@ -1223,6 +1236,7 @@ dependencies = [ "pallet-treasury", "pallet-utility", "pallet-vesting", + "zeitgeist-primitives", ] [[package]] @@ -2745,6 +2759,19 @@ dependencies = [ "scale-info", ] +[[package]] +name = "fixed" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36a65312835c1097a0c926ff3702df965285fadc33d948b87397ff8961bad881" +dependencies = [ + "az", + "bytemuck", + "half", + "num-traits", + "typenum 1.16.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "fixed-hash" version = "0.8.0" @@ -3377,6 +3404,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7" + [[package]] name = "handlebars" version = "4.3.7" @@ -3569,6 +3602,20 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "hydra-dx-math" +version = "7.4.3" +source = "git+https://github.com/galacticcouncil/HydraDX-node?tag=v18.0.0#6173a8b0661582247eed774330aa8fa6d99d524d" +dependencies = [ + "fixed", + "num-traits", + "parity-scale-codec", + "primitive-types", + "scale-info", + "sp-arithmetic", + "sp-std", +] + [[package]] name = "hyper" version = "0.14.27" @@ -14325,8 +14372,10 @@ name = "zeitgeist-primitives" version = "0.4.0" dependencies = [ "arbitrary", + "fixed", "frame-support", "frame-system", + "more-asserts", "orml-currencies", "orml-tokens", "orml-traits", @@ -14337,6 +14386,7 @@ dependencies = [ "sp-core", "sp-runtime", "test-case", + "typenum 1.16.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -14431,6 +14481,7 @@ dependencies = [ "zrml-global-disputes", "zrml-liquidity-mining", "zrml-market-commons", + "zrml-neo-swaps", "zrml-orderbook-v1", "zrml-prediction-markets", "zrml-rikiddo", @@ -14555,6 +14606,50 @@ dependencies = [ "zeitgeist-primitives", ] +[[package]] +name = "zrml-neo-swaps" +version = "0.4.0" +dependencies = [ + "fixed", + "frame-benchmarking", + "frame-support", + "frame-system", + "hydra-dx-math", + "more-asserts", + "orml-asset-registry", + "orml-currencies", + "orml-tokens", + "orml-traits", + "pallet-balances", + "pallet-randomness-collective-flip", + "pallet-timestamp", + "pallet-treasury", + "pallet-xcm", + "parity-scale-codec", + "scale-info", + "serde", + "sp-api", + "sp-io", + "sp-runtime", + "substrate-fixed", + "test-case", + "typenum 1.16.0 (registry+https://github.com/rust-lang/crates.io-index)", + "xcm", + "xcm-builder", + "zeitgeist-primitives", + "zrml-authorized", + "zrml-court", + "zrml-global-disputes", + "zrml-liquidity-mining", + "zrml-market-commons", + "zrml-neo-swaps", + "zrml-prediction-markets", + "zrml-prediction-markets-runtime-api", + "zrml-rikiddo", + "zrml-simple-disputes", + "zrml-swaps", +] + [[package]] name = "zrml-orderbook-v1" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 899a30cb3..93e07a943 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ default-members = [ "zrml/global-disputes", "zrml/liquidity-mining", "zrml/market-commons", + "zrml/neo-swaps", "zrml/orderbook-v1", "zrml/prediction-markets", "zrml/prediction-markets/runtime-api", @@ -31,6 +32,7 @@ members = [ "zrml/global-disputes", "zrml/liquidity-mining", "zrml/market-commons", + "zrml/neo-swaps", "zrml/orderbook-v1", "zrml/orderbook-v1/fuzz", "zrml/prediction-markets", @@ -229,6 +231,7 @@ zrml-court = { path = "zrml/court", default-features = false } zrml-global-disputes = { path = "zrml/global-disputes", default-features = false } zrml-liquidity-mining = { path = "zrml/liquidity-mining", default-features = false } zrml-market-commons = { path = "zrml/market-commons", default-features = false } +zrml-neo-swaps = { path = "zrml/neo-swaps", default-features = false } zrml-orderbook-v1 = { path = "zrml/orderbook-v1", 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 } @@ -250,6 +253,9 @@ url = "2.2.2" arbitrary = { version = "1.3.0", default-features = false } arrayvec = { version = "0.7.4", default-features = false } cfg-if = { version = "1.0.0" } +fixed = { version = "=1.15.0", default-features = false, features = ["num-traits"] } +# Using math code directly from the HydraDX node repository as https://github.com/galacticcouncil/hydradx-math is outdated and has been archived in May 2023. +hydra-dx-math = { git = "https://github.com/galacticcouncil/HydraDX-node", package = "hydra-dx-math", tag = "v18.0.0", default-features = false } # Hashbrown works in no_std by default and default features are used in Rikiddo hashbrown = { version = "0.12.3", default-features = true } hex-literal = { version = "0.3.4", default-features = false } @@ -258,6 +264,7 @@ num-traits = { version = "0.2.15", default-features = false } rand = { version = "0.8.5", default-features = false } rand_chacha = { version = "0.3.1", default-features = false } serde = { version = "1.0.152", default-features = false } +typenum = { version = "1.15.0", default-features = false } [profile.dev.package] blake2 = { opt-level = 3 } diff --git a/README.md b/README.md index b31e630fb..62263e619 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,18 @@ # Zeitgeist: An Evolving Blockchain for Prediction Markets and Futarchy -![Rust](https://github.com/zeitgeistpm/zeitgeist/workflows/Rust/badge.svg) [![Codecov](https://codecov.io/gh/zeitgeistpm/zeitgeist/branch/main/graph/badge.svg)](https://codecov.io/gh/zeitgeistpm/zeitgeist) [![Discord](https://img.shields.io/badge/discord-https%3A%2F%2Fdiscord.gg%2FMD3TbH3ctv-purple)](https://discord.gg/MD3TbH3ctv) [![Telegram](https://img.shields.io/badge/telegram-https%3A%2F%2Ft.me%2Fzeitgeist__official-blue)](https://t.me/zeitgeist_official) +![Rust](https://github.com/zeitgeistpm/zeitgeist/workflows/Rust/badge.svg) +[![Codecov](https://codecov.io/gh/zeitgeistpm/zeitgeist/branch/main/graph/badge.svg)](https://codecov.io/gh/zeitgeistpm/zeitgeist) +[![Discord](https://img.shields.io/badge/discord-https%3A%2F%2Fdiscord.gg%2FMD3TbH3ctv-purple)](https://discord.gg/MD3TbH3ctv) +[![Telegram](https://img.shields.io/badge/telegram-https%3A%2F%2Ft.me%2Fzeitgeist__official-blue)](https://t.me/zeitgeist_official) Zeitgeist is a decentralized network for creating, betting on, and resolving prediction markets. The platform's native currency, the ZTG, is used to sway the direction of the network, and as a means of last-call dispute resolution. Additionally, Zeitgeist is a protocol for efficient trading of prediction market shares and will one day become the backbone of the decentralized finance -ecosystem by allowing traders to create complex financial contracts on -virtually _anything_. +ecosystem by allowing traders to create complex financial contracts on virtually +_anything_. ## Modules @@ -27,6 +30,9 @@ virtually _anything_. liquidity to swap pools. - [market-commons](./zrml/market-commons) - Contains common operations on markets that are used by multiple pallets. +- [neo-swaps](./zrml/neo-swaps) - An implementation of the Logarithmic Market + Scoring Rule as constant function market maker, tailor-made for decentralized + combinatorial markets and Futarchy. - [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. diff --git a/docs/changelog_for_devs.md b/docs/changelog_for_devs.md index fbdf342e4..29f993350 100644 --- a/docs/changelog_for_devs.md +++ b/docs/changelog_for_devs.md @@ -12,6 +12,38 @@ As of 0.3.9, the changelog's format is based on components which query the chain's storage, the extrinsics or the runtime APIs/RPC interface. +## v0.4.1 + +### Added + +- Implement AMM-2.0-light in the form of zrml-neo-swaps. The new pallet has the + following dispatchables: + + - `buy`: Buy outcome tokens from the specified market. + - `sell`: Sell outcome tokens to the specified market. + - `join`: Join the liquidity pool for the specified market. + - `exit`: Exit the liquidity pool for the specified market. + - `withdraw_fees`: Withdraw swap fees from the specified market. + - `deploy_pool`: Deploy a pool for the specified market and provide liquidity. + + The new pallet has the following events: + + - `BuyExecuted { who, market_id, asset_out, amount_in, amount_out, swap_fee_amount, external_fee_amount }`: + Informant bought a position. + - `SellExecuted { who, market_id, asset_in, amount_in, amount_out, swap_fee_amount, external_fee_amount }`: + Informants sold a position. + - `FeesWithdrawn { who }`: Liquidity provider withdrew fees. + - `JoinExecuted { who, market_id, pool_shares_amount, amounts_in, new_liquidity_parameter }`: + Liquidity provider joined the pool. + - `ExitExecuted { who, market_id, pool_shares_amount, amounts_out, new_liquidity_parameter }`: + Liquidity provider left the pool. + - `PoolDeployed { who, market_id, pool_shares_amount, amounts_in, liquidity_parameter }`: + Pool was created. + - `PoolDestroyed { who, market_id, pool_shares_amount, amounts_out }`: Pool + was destroyed. + + For details, please refer to the `README.md` and the in-file documentation. + ## v0.4.0 [#976]: https://github.com/zeitgeistpm/zeitgeist/pull/976 diff --git a/primitives/Cargo.toml b/primitives/Cargo.toml index 4c2360e79..82e613543 100644 --- a/primitives/Cargo.toml +++ b/primitives/Cargo.toml @@ -1,5 +1,6 @@ [dependencies] arbitrary = { workspace = true, optional = true } +fixed = { workspace = true } frame-support = { workspace = true } frame-system = { workspace = true } orml-currencies = { workspace = true } @@ -13,7 +14,9 @@ sp-core = { workspace = true } sp-runtime = { workspace = true } [dev-dependencies] +more-asserts = { workspace = true } test-case = { workspace = true } +typenum = { workspace = true } [features] default = ["std"] diff --git a/primitives/src/constants.rs b/primitives/src/constants.rs index d5e32ef71..3841666a1 100644 --- a/primitives/src/constants.rs +++ b/primitives/src/constants.rs @@ -37,7 +37,8 @@ pub const BLOCKS_PER_MINUTE: BlockNumber = 60_000 / (MILLISECS_PER_BLOCK as Bloc pub const BLOCKS_PER_HOUR: BlockNumber = BLOCKS_PER_MINUTE * 60; // 300 // Definitions for currency -pub const BASE: u128 = 10_000_000_000; +pub const DECIMALS: u8 = 10; +pub const BASE: u128 = 10u128.pow(DECIMALS as u32); pub const CENT: Balance = BASE / 100; // 100_000_000 pub const MILLI: Balance = CENT / 10; // 10_000_000 pub const MICRO: Balance = MILLI / 1000; // 10_000 @@ -82,6 +83,9 @@ pub const GLOBAL_DISPUTES_LOCK_ID: [u8; 8] = *b"zge/gdlk"; /// Pallet identifier, mainly used for named balance reserves. pub const LM_PALLET_ID: PalletId = PalletId(*b"zge/lymg"); +// NeoSwaps +pub const NS_PALLET_ID: PalletId = PalletId(*b"zge/neos"); + // Prediction Markets /// The maximum allowed market life time, measured in blocks. pub const MAX_MARKET_LIFETIME: BlockNumber = 4 * BLOCKS_PER_YEAR; diff --git a/primitives/src/constants/mock.rs b/primitives/src/constants/mock.rs index 5f1ec243c..487f252d0 100644 --- a/primitives/src/constants/mock.rs +++ b/primitives/src/constants/mock.rs @@ -70,6 +70,13 @@ parameter_types! { pub const LiquidityMiningPalletId: PalletId = PalletId(*b"zge/lymg"); } +// NeoSwaps +parameter_types! { + pub storage NeoExitFee: Balance = CENT; + pub const NeoMaxSwapFee: Balance = 10 * CENT; + pub const NeoSwapsPalletId: PalletId = PalletId(*b"zge/neos"); +} + // Prediction Market parameters parameter_types! { pub const AdvisoryBond: Balance = 25 * CENT; @@ -146,11 +153,10 @@ parameter_types! { } parameter_type_with_key! { - // Well, not every asset is a currency ¯\_(ツ)_/¯ pub ExistentialDeposits: |currency_id: CurrencyId| -> Balance { match currency_id { Asset::Ztg => ExistentialDeposit::get(), - _ => 0 + _ => 10 } }; } diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index 0bb25dee5..5a3d424d8 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -1,3 +1,4 @@ +// Copyright 2023 Forecasting Technologies LTD. // Copyright 2021-2022 Zeitgeist PM LLC. // // This file is part of Zeitgeist. @@ -22,6 +23,7 @@ extern crate alloc; mod asset; pub mod constants; mod market; +pub mod math; mod max_runtime_usize; mod outcome_report; mod pool; diff --git a/primitives/src/math/check_arithm_rslt.rs b/primitives/src/math/check_arithm_rslt.rs new file mode 100644 index 000000000..1bd010f06 --- /dev/null +++ b/primitives/src/math/check_arithm_rslt.rs @@ -0,0 +1,81 @@ +// Copyright 2023 Forecasting Technologies LTD. +// Copyright 2021-2022 Zeitgeist PM LLC. +// +// This file is part of Zeitgeist. +// +// Zeitgeist is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at +// your option) any later version. +// +// Zeitgeist is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . +// +// This file incorporates work covered by the license above but +// published without copyright notice by Balancer Labs +// (, contact@balancer.finance) in the +// balancer-core repository +// . + +use crate::math::consts::ARITHM_OF; +use frame_support::dispatch::DispatchError; +use sp_runtime::traits::{CheckedAdd, CheckedDiv, CheckedMul, CheckedSub}; + +/// Check Arithmetic - Result +/// +/// Checked arithmetic operations returning `Result<_, DispatchError>`. +pub trait CheckArithmRslt: CheckedAdd + CheckedDiv + CheckedMul + CheckedSub { + /// Check Addition - Result + /// + /// Same as `sp_runtime::traits::CheckedAdd::checked_add` but returns a + /// `Result` instead of `Option`. + fn check_add_rslt(&self, n: &Self) -> Result; + + /// Check Division - Result + /// + /// Same as `sp_runtime::traits::CheckedDiv::checked_div` but returns a + /// `Result` instead of `Option`. + fn check_div_rslt(&self, n: &Self) -> Result; + + /// Check Multiplication - Result + /// + /// Same as `sp_runtime::traits::CheckedMul::checked_mul` but returns a + /// `Result` instead of `Option`. + fn check_mul_rslt(&self, n: &Self) -> Result; + + /// Check Subtraction - Result + /// + /// Same as `sp_runtime::traits::CheckedSub::checked_sub` but returns a + /// `Result` instead of `Option`. + fn check_sub_rslt(&self, n: &Self) -> Result; +} + +impl CheckArithmRslt for T +where + T: CheckedAdd + CheckedDiv + CheckedMul + CheckedSub, +{ + #[inline] + fn check_add_rslt(&self, n: &Self) -> Result { + self.checked_add(n).ok_or(ARITHM_OF) + } + + #[inline] + fn check_div_rslt(&self, n: &Self) -> Result { + self.checked_div(n).ok_or(ARITHM_OF) + } + + #[inline] + fn check_mul_rslt(&self, n: &Self) -> Result { + self.checked_mul(n).ok_or(ARITHM_OF) + } + + #[inline] + fn check_sub_rslt(&self, n: &Self) -> Result { + self.checked_sub(n).ok_or(ARITHM_OF) + } +} diff --git a/primitives/src/math/consts.rs b/primitives/src/math/consts.rs new file mode 100644 index 000000000..08e8f2aad --- /dev/null +++ b/primitives/src/math/consts.rs @@ -0,0 +1,37 @@ +// Copyright 2023 Forecasting Technologies LTD. +// Copyright 2021-2022 Zeitgeist PM LLC. +// +// This file is part of Zeitgeist. +// +// Zeitgeist is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at +// your option) any later version. +// +// Zeitgeist is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . +// +// This file incorporates work covered by the license above but +// published without copyright notice by Balancer Labs +// (, contact@balancer.finance) in the +// balancer-core repository +// . + +use crate::constants::BASE; +use frame_support::dispatch::DispatchError; + +pub const ARITHM_OF: DispatchError = DispatchError::Other("Arithmetic overflow"); + +/// The amount of precision to use in exponentiation. +pub const BPOW_PRECISION: u128 = 10; +/// The minimum value of the base parameter in bpow_approx. +pub const BPOW_APPROX_BASE_MIN: u128 = BASE / 4; +/// The maximum value of the base parameter in bpow_approx. +pub const BPOW_APPROX_BASE_MAX: u128 = 7 * BASE / 4; +/// The maximum number of terms from the binomial series used to calculate bpow_approx. +pub const BPOW_APPROX_MAX_ITERATIONS: u128 = 100; diff --git a/primitives/src/math/fixed.rs b/primitives/src/math/fixed.rs new file mode 100644 index 000000000..f977563a2 --- /dev/null +++ b/primitives/src/math/fixed.rs @@ -0,0 +1,824 @@ +// Copyright 2023 Forecasting Technologies LTD. +// Copyright 2021-2022 Zeitgeist PM LLC. +// +// This file is part of Zeitgeist. +// +// Zeitgeist is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at +// your option) any later version. +// +// Zeitgeist is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . +// +// This file incorporates work covered by the license above but +// published without copyright notice by Balancer Labs +// (, contact@balancer.finance) in the +// balancer-core repository +// . + +use crate::{ + constants::BASE, + math::{ + check_arithm_rslt::CheckArithmRslt, + consts::{ + BPOW_APPROX_BASE_MAX, BPOW_APPROX_BASE_MIN, BPOW_APPROX_MAX_ITERATIONS, BPOW_PRECISION, + }, + }, +}; +use alloc::{borrow::ToOwned, format, string::ToString, vec::Vec}; +use core::convert::TryFrom; +use fixed::{traits::Fixed, ParseFixedError}; +use frame_support::dispatch::DispatchError; + +pub fn btoi(a: u128) -> Result { + a.check_div_rslt(&BASE) +} + +pub fn bfloor(a: u128) -> Result { + btoi(a)?.check_mul_rslt(&BASE) +} + +pub fn bsub_sign(a: u128, b: u128) -> Result<(u128, bool), DispatchError> { + Ok(if a >= b { (a.check_sub_rslt(&b)?, false) } else { (b.check_sub_rslt(&a)?, true) }) +} + +pub fn bmul(a: u128, b: u128) -> Result { + let c0 = a.check_mul_rslt(&b)?; + let c1 = c0.check_add_rslt(&BASE.check_div_rslt(&2)?)?; + c1.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 bpowi(a: u128, n: u128) -> Result { + let mut z = if n % 2 != 0 { a } else { BASE }; + + let mut b = a; + let mut m = n.check_div_rslt(&2)?; + + while m != 0 { + b = bmul(b, b)?; + + if m % 2 != 0 { + z = bmul(z, b)?; + } + + m = m.check_div_rslt(&2)?; + } + + Ok(z) +} + +/// Compute the power `base ** exp`. +/// +/// # Arguments +/// +/// * `base`: The base, a number between `BASE / 4` and `7 * BASE / 4` +/// * `exp`: The exponent +/// +/// # Errors +/// +/// If this function encounters an arithmetic over/underflow, or if the numerical limits +/// for `base` (specified above) are violated, a `DispatchError::Other` is returned. +pub fn bpow(base: u128, exp: u128) -> Result { + let whole = bfloor(exp)?; + let remain = exp.check_sub_rslt(&whole)?; + + let whole_pow = bpowi(base, btoi(whole)?)?; + + if remain == 0 { + return Ok(whole_pow); + } + + let partial_result = bpow_approx(base, remain)?; + bmul(whole_pow, partial_result) +} + +/// Compute an estimate of the power `base ** exp`. +/// +/// # Arguments +/// +/// * `base`: The base, an element of `[BASE / 4, 7 * BASE / 4]` +/// * `exp`: The exponent, an element of `[0, BASE]` +/// +/// # Errors +/// +/// If this function encounters an arithmetic over/underflow, or if the numerical limits +/// for `base` or `exp` (specified above) are violated, a `DispatchError::Other` is +/// returned. +pub fn bpow_approx(base: u128, exp: u128) -> Result { + // We use the binomial power series for this calculation. We stop adding terms to + // the result as soon as one term is smaller than `BPOW_PRECISION`. (Thanks to the + // limits on `base` and `exp`, this means that the total error should not exceed + // 4*BPOW_PRECISION`.) + if exp > BASE { + return Err(DispatchError::Other("[bpow_approx]: expected exp <= BASE")); + } + if base < BPOW_APPROX_BASE_MIN { + return Err(DispatchError::Other("[bpow_approx]: expected base >= BASE / 4")); + } + if base > BPOW_APPROX_BASE_MAX { + return Err(DispatchError::Other("[bpow_approx]: expected base <= 7 * BASE / 4")); + } + + let a = exp; + let (x, xneg) = bsub_sign(base, BASE)?; + let mut term = BASE; + let mut sum = term; + let mut negative = false; + + // term(k) = numer / denom + // = (product(a - i - 1, i=1-->k) * x^k) / (k!) + // each iteration, multiply previous term by (a-(k-1)) * x / k + // continue until term is less than precision + for i in 1..=BPOW_APPROX_MAX_ITERATIONS { + if term < BPOW_PRECISION { + break; + } + + let big_k = i.check_mul_rslt(&BASE)?; + let (c, cneg) = bsub_sign(a, big_k.check_sub_rslt(&BASE)?)?; + term = bmul(term, bmul(c, x)?)?; + term = bdiv(term, big_k)?; + if term == 0 { + break; + } + + if xneg { + negative = !negative; + } + if cneg { + negative = !negative; + } + if negative { + // Never underflows. In fact, the absolute value of the terms is strictly + // decreasing thanks to the numerical limits. + sum = sum.check_sub_rslt(&term)?; + } else { + sum = sum.check_add_rslt(&term)?; + } + } + + // If term is still large, then MAX_ITERATIONS was violated (can't happen with the current + // limits). + if term >= BPOW_PRECISION { + return Err(DispatchError::Other("[bpow_approx] Maximum number of iterations exceeded")); + } + + Ok(sum) +} + +/// Converts a fixed point decimal number into another type. +pub trait FromFixedDecimal> +where + Self: Sized, +{ + /// Craft a fixed point decimal number from `N`. + fn from_fixed_decimal(decimal: N, places: u8) -> Result; +} + +/// Converts a fixed point decimal number into another type. +pub trait IntoFixedFromDecimal { + /// Converts a fixed point decimal number into another type. + fn to_fixed_from_fixed_decimal(self, places: u8) -> Result; +} + +/// Converts a type into a fixed point decimal number. +pub trait FromFixedToDecimal +where + Self: Sized + TryFrom, +{ + /// Craft a fixed point decimal number from another type. + fn from_fixed_to_fixed_decimal(fixed: F, places: u8) -> Result; +} + +/// Converts a type into a fixed point decimal number. +pub trait IntoFixedDecimal> { + /// Converts a type into a fixed point decimal number. + fn to_fixed_decimal(self, places: u8) -> Result; +} + +impl> FromFixedDecimal for F { + /// Craft a `Fixed` type from a fixed point decimal number of type `N` + fn from_fixed_decimal(decimal: N, places: u8) -> Result { + let decimal_u128 = decimal.into(); + let mut decimal_string = decimal_u128.to_string(); + + if decimal_string.len() <= places as usize { + // This can never underflow (places >= len). Saturating subtraction to satisfy clippy. + decimal_string = "0.".to_owned() + + &"0".repeat((places as usize).saturating_sub(decimal_string.len())) + + &decimal_string; + } else { + // This can never underflow (len > places). Saturating subtraction to satisfy clippy. + decimal_string.insert(decimal_string.len().saturating_sub(places as usize), '.'); + } + + F::from_str(&decimal_string) + } +} + +impl IntoFixedFromDecimal for N +where + F: Fixed + FromFixedDecimal, + N: Into, +{ + /// Converts a fixed point decimal number into `Fixed` type (e.g. `Balance` -> `Fixed`). + fn to_fixed_from_fixed_decimal(self, places: u8) -> Result { + F::from_fixed_decimal(self, places) + } +} + +impl> FromFixedToDecimal for N { + fn from_fixed_to_fixed_decimal(fixed: F, decimals: u8) -> Result { + let decimals_usize = decimals as usize; + let s = fixed.to_string(); + let mut parts: Vec<&str> = s.split('.').collect(); + // If there's no fractional part, then `fixed` was an integer. + if parts.len() != 2 { + parts.push("0"); + } + + let (int_part, frac_part) = (parts[0], parts[1]); + let mut increment = false; + + let new_frac_part = if frac_part.len() < decimals_usize { + format!("{}{}", frac_part, "0".repeat(decimals_usize.saturating_sub(frac_part.len()))) + } else { + // Adding rounding behavior + let round_digit = frac_part.chars().nth(decimals_usize); + match round_digit { + Some(d) if d >= '5' => increment = true, + _ => {} + } + + frac_part.chars().take(decimals_usize).collect() + }; + + let mut fixed_decimal: u128 = format!("{}{}", int_part, new_frac_part) + .parse::() + .map_err(|_| "Failed to parse the fixed decimal representation into u128")?; + + if increment { + fixed_decimal = fixed_decimal.saturating_add(1); + } + + let result: N = fixed_decimal.try_into().map_err(|_| { + "The parsed fixed decimal representation does not fit into the target type" + })?; + Ok(result) + } +} + +impl IntoFixedDecimal for F +where + F: Fixed, + N: FromFixedToDecimal, +{ + /// Converts a `Fixed` type into a fixed point decimal number. + fn to_fixed_decimal(self, places: u8) -> Result { + N::from_fixed_to_fixed_decimal(self, places) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + assert_approx, + constants::BASE, + math::{ + consts::{ARITHM_OF, BPOW_PRECISION}, + fixed::{bdiv, bmul, bpow, bpow_approx}, + }, + }; + use fixed::{traits::ToFixed, FixedU128}; + use frame_support::{assert_err, dispatch::DispatchError}; + use more_asserts::assert_le; + use test_case::test_case; + use typenum::U80; + + pub const ERR: Result = Err(ARITHM_OF); + + macro_rules! create_tests { + ( + $op:ident; + + 0 => $_0_0:expr, $_0_1:expr, $_0_2:expr, $_0_3:expr; + 1 => $_1_0:expr, $_1_1:expr, $_1_2:expr, $_1_3:expr; + 2 => $_2_0:expr, $_2_1:expr, $_2_2:expr, $_2_3:expr; + 3 => $_3_0:expr, $_3_1:expr, $_3_2:expr, $_3_3:expr; + max_n => $max_n_0:expr, $max_n_1:expr, $max_n_2:expr, $max_n_3:expr; + n_max => $n_max_0:expr, $n_max_1:expr, $n_max_2:expr, $n_max_3:expr; + ) => { + assert_eq!($op(0, 0 * BASE), $_0_0); + assert_eq!($op(0, 1 * BASE), $_0_1); + assert_eq!($op(0, 2 * BASE), $_0_2); + assert_eq!($op(0, 3 * BASE), $_0_3); + + assert_eq!($op(1 * BASE, 0 * BASE), $_1_0); + assert_eq!($op(1 * BASE, 1 * BASE), $_1_1); + assert_eq!($op(1 * BASE, 2 * BASE), $_1_2); + assert_eq!($op(1 * BASE, 3 * BASE), $_1_3); + + assert_eq!($op(2 * BASE, 0 * BASE), $_2_0); + assert_eq!($op(2 * BASE, 1 * BASE), $_2_1); + assert_eq!($op(2 * BASE, 2 * BASE), $_2_2); + assert_eq!($op(2 * BASE, 3 * BASE), $_2_3); + + assert_eq!($op(3 * BASE, 0 * BASE), $_3_0); + assert_eq!($op(3 * BASE, 1 * BASE), $_3_1); + assert_eq!($op(3 * BASE, 2 * BASE), $_3_2); + assert_eq!($op(3 * BASE, 3 * BASE), $_3_3); + + assert_eq!($op(u128::MAX, 0 * BASE), $max_n_0); + assert_eq!($op(u128::MAX, 1 * BASE), $max_n_1); + assert_eq!($op(u128::MAX, 2 * BASE), $max_n_2); + assert_eq!($op(u128::MAX, 3 * BASE), $max_n_3); + + assert_eq!($op(0, u128::MAX), $n_max_0); + assert_eq!($op(1, u128::MAX), $n_max_1); + assert_eq!($op(2, u128::MAX), $n_max_2); + assert_eq!($op(3, u128::MAX), $n_max_3); + }; + } + + #[test] + fn bdiv_has_minimum_set_of_correct_values() { + create_tests!( + bdiv; + 0 => ERR, Ok(0), Ok(0), Ok(0); + 1 => ERR, Ok(BASE), Ok(BASE / 2), Ok(BASE / 3); + 2 => ERR, Ok(2 * BASE), Ok(BASE), Ok(6666666667); + 3 => ERR, Ok(3 * BASE), Ok(3 * BASE / 2), Ok(BASE); + max_n => ERR, ERR, ERR, ERR; + n_max => Ok(0), Ok(1 / BASE), Ok(2 / BASE), Ok(3 / BASE); + ); + } + + #[test] + fn bmul_has_minimum_set_of_correct_values() { + create_tests!( + bmul; + 0 => Ok(0), Ok(0), Ok(0), Ok(0); + 1 => Ok(0), Ok(BASE), Ok(2 * BASE), Ok(3 * BASE); + 2 => Ok(0), Ok(2 * BASE), Ok(4 * BASE), Ok(6 * BASE); + 3 => Ok(0), Ok(3 * BASE), Ok(6 * BASE), Ok(9 * BASE); + max_n => Ok(0), ERR, ERR, ERR; + n_max => Ok(0), ERR, ERR, ERR; + ); + } + + #[test] + fn bpow_has_minimum_set_of_correct_values() { + let test_vector: Vec<(u128, u128, u128)> = vec![ + (2500000000, 0, 10000000000), + (2500000000, 10000000000, 2500000000), + (2500000000, 33333333333, 98431332), + (2500000000, 200000000, 9726549474), + (2500000000, 500000000000, 0), + (5000000000, 0, 10000000000), + (5000000000, 10000000000, 5000000000), + (5000000000, 33333333333, 992125657), + (5000000000, 200000000, 9862327044), + (5000000000, 500000000000, 0), + (7500000000, 0, 10000000000), + (7500000000, 10000000000, 7500000000), + (7500000000, 33333333333, 3832988750), + (7500000000, 200000000, 9942628790), + (7500000000, 500000000000, 5663), + (10000000000, 0, 10000000000), + (10000000000, 10000000000, 10000000000), + (10000000000, 33333333333, 10000000000), + (10000000000, 200000000, 10000000000), + (10000000000, 500000000000, 10000000000), + (12500000000, 0, 10000000000), + (12500000000, 10000000000, 12500000000), + (12500000000, 33333333333, 21039401269), + (12500000000, 200000000, 10044728444), + (12500000000, 500000000000, 700649232162408), + (15000000000, 0, 10000000000), + (15000000000, 10000000000, 15000000000), + (15000000000, 33333333333, 38634105686), + (15000000000, 200000000, 10081422716), + (15000000000, 500000000000, 6376215002140495869), + (17500000000, 0, 10000000000), + (17500000000, 10000000000, 17500000000), + (17500000000, 33333333333, 64584280985), + (17500000000, 200000000, 10112551840), + (17500000000, 500000000000, 14187387615511831479362), + ]; + for (base, exp, expected) in test_vector.iter() { + let result = bpow(*base, *exp).unwrap(); + let precision = *expected / BASE + 4 * BPOW_PRECISION; // relative + absolute error + let diff = if result > *expected { result - *expected } else { *expected - result }; + assert_le!(diff, precision); + } + } + + #[test] + fn bpow_returns_error_when_parameters_are_outside_of_specified_limits() { + let test_vector: Vec<(u128, u128)> = + vec![(BASE / 10, 3 * BASE / 2), (2 * BASE - BASE / 10, 3 * BASE / 2)]; + for (base, exp) in test_vector.iter() { + assert!(bpow(*base, *exp).is_err()); + } + } + + #[test] + fn bpow_approx_has_minimum_set_of_correct_values() { + let precision = 4 * BPOW_PRECISION; + let test_vector: Vec<(u128, u128, u128)> = vec![ + (2500000000, 0, 10000000000), + (2500000000, 1000000000, 8705505632), + (2500000000, 2000000000, 7578582832), + (2500000000, 3000000000, 6597539553), + (2500000000, 4000000000, 5743491774), + (2500000000, 5000000000, 5000000000), + (2500000000, 6000000000, 4352752816), + (2500000000, 7000000000, 3789291416), + (2500000000, 8000000000, 3298769776), + (2500000000, 9000000000, 2871745887), + (2500000000, 10000000000, 2500000000), + (5000000000, 0, 10000000000), + (5000000000, 1000000000, 9330329915), + (5000000000, 2000000000, 8705505632), + (5000000000, 3000000000, 8122523963), + (5000000000, 4000000000, 7578582832), + (5000000000, 5000000000, 7071067811), + (5000000000, 6000000000, 6597539553), + (5000000000, 7000000000, 6155722066), + (5000000000, 8000000000, 5743491774), + (5000000000, 9000000000, 5358867312), + (5000000000, 10000000000, 5000000000), + (7500000000, 0, 10000000000), + (7500000000, 1000000000, 9716416578), + (7500000000, 2000000000, 9440875112), + (7500000000, 3000000000, 9173147546), + (7500000000, 4000000000, 8913012289), + (7500000000, 5000000000, 8660254037), + (7500000000, 6000000000, 8414663590), + (7500000000, 7000000000, 8176037681), + (7500000000, 8000000000, 7944178807), + (7500000000, 9000000000, 7718895067), + (7500000000, 10000000000, 7500000000), + (10000000000, 0, 10000000000), + (10000000000, 1000000000, 10000000000), + (10000000000, 2000000000, 10000000000), + (10000000000, 3000000000, 10000000000), + (10000000000, 4000000000, 10000000000), + (10000000000, 5000000000, 10000000000), + (10000000000, 6000000000, 10000000000), + (10000000000, 7000000000, 10000000000), + (10000000000, 8000000000, 10000000000), + (10000000000, 9000000000, 10000000000), + (10000000000, 10000000000, 10000000000), + (12500000000, 0, 10000000000), + (12500000000, 1000000000, 10225651825), + (12500000000, 2000000000, 10456395525), + (12500000000, 3000000000, 10692345999), + (12500000000, 4000000000, 10933620739), + (12500000000, 5000000000, 11180339887), + (12500000000, 6000000000, 11432626298), + (12500000000, 7000000000, 11690605597), + (12500000000, 8000000000, 11954406247), + (12500000000, 9000000000, 12224159606), + (12500000000, 10000000000, 12500000000), + (15000000000, 0, 10000000000), + (15000000000, 1000000000, 10413797439), + (15000000000, 2000000000, 10844717711), + (15000000000, 3000000000, 11293469354), + (15000000000, 4000000000, 11760790225), + (15000000000, 5000000000, 12247448713), + (15000000000, 6000000000, 12754245006), + (15000000000, 7000000000, 13282012399), + (15000000000, 8000000000, 13831618672), + (15000000000, 9000000000, 14403967511), + (15000000000, 10000000000, 15000000000), + (17500000000, 0, 10000000000), + (17500000000, 1000000000, 10575570503), + (17500000000, 2000000000, 11184269147), + (17500000000, 3000000000, 11828002689), + (17500000000, 4000000000, 12508787635), + (17500000000, 5000000000, 13228756555), + (17500000000, 6000000000, 13990164762), + (17500000000, 7000000000, 14795397379), + (17500000000, 8000000000, 15646976811), + (17500000000, 9000000000, 16547570643), + (17500000000, 10000000000, 17500000000), + ]; + for (base, exp, expected) in test_vector.iter() { + let result = bpow_approx(*base, *exp).unwrap(); + let diff = if result > *expected { result - *expected } else { *expected - result }; + assert_le!(diff, precision); + } + } + + #[test] + fn bpow_approx_returns_error_when_parameters_are_outside_of_specified_limits() { + let test_vector: Vec<(u128, u128, DispatchError)> = vec![ + (BASE, BASE + 1, DispatchError::Other("[bpow_approx]: expected exp <= BASE")), + (BASE / 10, BASE / 2, DispatchError::Other("[bpow_approx]: expected base >= BASE / 4")), + ( + 2 * BASE - BASE / 10, + BASE / 2, + DispatchError::Other("[bpow_approx]: expected base <= 7 * BASE / 4"), + ), + ]; + for (base, exp, err) in test_vector.iter() { + assert_err!(bpow_approx(*base, *exp), *err); + } + } + + #[test_case(0, 10, 0.0)] + #[test_case(1, 10, 0.0000000001)] + #[test_case(9, 10, 0.0000000009)] + #[test_case(123_456_789, 10, 0.123456789)] + #[test_case(999_999_999, 10, 0.999999999)] + #[test_case(10_000_000_000, 10, 1.0)] + #[test_case(10_000_000_001, 10, 1.00000000001)] + #[test_case(20751874964, 10, 2.075_187_496_394_219)] + #[test_case(123456789876543210, 10, 12_345_678.987_654_32)] + #[test_case(99999999999999999999999, 10, 9999999999999.9999999999)] + // Tests taken from Rikiddo pallet + #[test_case(1, 10, 0.000_000_000_1)] + #[test_case(123_456_789, 10, 0.012_345_678_9)] + #[test_case(9_999, 2, 99.99)] + #[test_case(736_101, 2, 7_361.01)] + #[test_case(133_733_333_333, 8, 1_337.333_333_33)] + #[test_case(1, 1, 0.1)] + #[test_case(55, 11, 0.000_000_000_6)] + #[test_case(34, 11, 0.000_000_000_3)] + fn to_fixed_from_fixed_decimal(value: u128, decimals: u8, expected_float: f64) { + let result: FixedU128 = value.to_fixed_from_fixed_decimal(decimals).unwrap(); + assert_approx!(result, >::from_num(expected_float), 1); + } + + #[test_case(0.0, 10, 0)] + #[test_case(0.00000000004, 10, 0)] + #[test_case(0.00000000005, 10, 1)] + #[test_case(0.0000000001, 10, 1)] + #[test_case(0.00000000099, 10, 10)] + #[test_case(0.0123456789, 10, 123_456_789)] + #[test_case(0.09999999999, 10, 1_000_000_000)] + #[test_case(0.19999999999, 10, 2_000_000_000)] + #[test_case(0.99999999999, 10, 10_000_000_000)] + #[test_case(1.0, 10, 10_000_000_000)] + #[test_case(1.00000000001, 10, 10_000_000_000)] + #[test_case(1.67899999995, 10, 16_790_000_000)] + #[test_case(1.89999999995, 10, 19_000_000_000)] + #[test_case(1.99999999995, 10, 20_000_000_000)] + #[test_case(2.075_187_496_394_219, 10, 20751874964)] + #[test_case(12_345_678.987_654_32, 10, 123456789876543210)] + #[test_case(99.999999999999, 10, 1_000_000_000_000)] + #[test_case(9999999999999.9999999999, 10, 99999999999999999999999)] + // Tests taken from Rikiddo pallet + #[test_case(32.5, 0, 33)] + #[test_case(32.25, 0, 32)] + #[test_case(200.0, 8, 20_000_000_000)] + #[test_case(200.1234, 8, 20_012_340_000)] + #[test_case(200.1234, 2, 20_012)] + #[test_case(200.1254, 2, 20_013)] + #[test_case(123.456, 3, 123_456)] + #[test_case(123.0, 0, 123)] + // Random values + #[test_case(0.1161, 3, 116)] + #[test_case(0.2449, 3, 245)] + #[test_case(0.29, 3, 290)] + #[test_case(0.297, 3, 297)] + #[test_case(0.3423, 3, 342)] + #[test_case(0.4259, 3, 426)] + #[test_case(0.4283, 3, 428)] + #[test_case(0.4317, 3, 432)] + #[test_case(0.4649, 3, 465)] + #[test_case(0.4924, 3, 492)] + #[test_case(0.5656, 3, 566)] + #[test_case(0.7197, 3, 720)] + #[test_case(0.9803, 3, 980)] + #[test_case(1.0285, 3, 1029)] + #[test_case(1.0661, 3, 1066)] + #[test_case(1.0701, 3, 1070)] + #[test_case(1.1505, 3, 1151)] + #[test_case(1.1814, 3, 1181)] + #[test_case(1.2284, 3, 1228)] + #[test_case(1.3549, 3, 1355)] + #[test_case(1.3781, 3, 1378)] + #[test_case(1.3987, 3, 1399)] + #[test_case(1.5239, 3, 1524)] + #[test_case(1.5279, 3, 1528)] + #[test_case(1.5636, 3, 1564)] + #[test_case(1.5688, 3, 1569)] + #[test_case(1.6275, 3, 1628)] + #[test_case(1.6567, 3, 1657)] + #[test_case(1.7245, 3, 1725)] + #[test_case(1.7264, 3, 1726)] + #[test_case(1.7884, 3, 1788)] + #[test_case(1.8532, 3, 1853)] + #[test_case(2.0569, 3, 2057)] + #[test_case(2.0801, 3, 2080)] + #[test_case(2.1192, 3, 2119)] + #[test_case(2.1724, 3, 2172)] + #[test_case(2.2966, 3, 2297)] + #[test_case(2.3375, 3, 2338)] + #[test_case(2.3673, 3, 2367)] + #[test_case(2.4284, 3, 2428)] + #[test_case(2.431, 3, 2431)] + #[test_case(2.4724, 3, 2472)] + #[test_case(2.5036, 3, 2504)] + #[test_case(2.5329, 3, 2533)] + #[test_case(2.5976, 3, 2598)] + #[test_case(2.625, 3, 2625)] + #[test_case(2.7198, 3, 2720)] + #[test_case(2.7713, 3, 2771)] + #[test_case(2.8375, 3, 2838)] + #[test_case(2.9222, 3, 2922)] + #[test_case(2.9501, 3, 2950)] + #[test_case(2.9657, 3, 2966)] + #[test_case(3.0959, 3, 3096)] + #[test_case(3.182, 3, 3182)] + #[test_case(3.216, 3, 3216)] + #[test_case(3.2507, 3, 3251)] + #[test_case(3.3119, 3, 3312)] + #[test_case(3.338, 3, 3338)] + #[test_case(3.473, 3, 3473)] + #[test_case(3.5163, 3, 3516)] + #[test_case(3.5483, 3, 3548)] + #[test_case(3.6441, 3, 3644)] + #[test_case(3.7228, 3, 3723)] + #[test_case(3.7712, 3, 3771)] + #[test_case(3.7746, 3, 3775)] + #[test_case(3.8729, 3, 3873)] + #[test_case(3.8854, 3, 3885)] + #[test_case(3.935, 3, 3935)] + #[test_case(3.9437, 3, 3944)] + #[test_case(3.9872, 3, 3987)] + #[test_case(4.0136, 3, 4014)] + #[test_case(4.069, 3, 4069)] + #[test_case(4.0889, 3, 4089)] + #[test_case(4.2128, 3, 4213)] + #[test_case(4.2915, 3, 4292)] + #[test_case(4.3033, 3, 4303)] + #[test_case(4.3513, 3, 4351)] + #[test_case(4.3665, 3, 4367)] + #[test_case(4.3703, 3, 4370)] + #[test_case(4.4216, 3, 4422)] + #[test_case(4.4768, 3, 4477)] + #[test_case(4.5022, 3, 4502)] + #[test_case(4.5236, 3, 4524)] + #[test_case(4.5336, 3, 4534)] + #[test_case(4.5371, 3, 4537)] + #[test_case(4.5871, 3, 4587)] + #[test_case(4.696, 3, 4696)] + #[test_case(4.6967, 3, 4697)] + #[test_case(4.775, 3, 4775)] + #[test_case(4.7977, 3, 4798)] + #[test_case(4.825, 3, 4825)] + #[test_case(4.8334, 3, 4833)] + #[test_case(4.8335, 3, 4834)] + #[test_case(4.8602, 3, 4860)] + #[test_case(4.9123, 3, 4912)] + #[test_case(5.0153, 3, 5015)] + #[test_case(5.143, 3, 5143)] + #[test_case(5.1701, 3, 5170)] + #[test_case(5.1721, 3, 5172)] + #[test_case(5.1834, 3, 5183)] + #[test_case(5.2639, 3, 5264)] + #[test_case(5.2667, 3, 5267)] + #[test_case(5.2775, 3, 5278)] + #[test_case(5.3815, 3, 5382)] + #[test_case(5.4786, 3, 5479)] + #[test_case(5.4879, 3, 5488)] + #[test_case(5.4883, 3, 5488)] + #[test_case(5.494, 3, 5494)] + #[test_case(5.5098, 3, 5510)] + #[test_case(5.5364, 3, 5536)] + #[test_case(5.5635, 3, 5564)] + #[test_case(5.5847, 3, 5585)] + #[test_case(5.6063, 3, 5606)] + #[test_case(5.6352, 3, 5635)] + #[test_case(5.6438, 3, 5644)] + #[test_case(5.7062, 3, 5706)] + #[test_case(5.7268, 3, 5727)] + #[test_case(5.7535, 3, 5754)] + #[test_case(5.8718, 3, 5872)] + #[test_case(5.8901, 3, 5890)] + #[test_case(5.956, 3, 5956)] + #[test_case(5.9962, 3, 5996)] + #[test_case(6.1368, 3, 6137)] + #[test_case(6.1665, 3, 6167)] + #[test_case(6.2001, 3, 6200)] + #[test_case(6.286, 3, 6286)] + #[test_case(6.2987, 3, 6299)] + #[test_case(6.3282, 3, 6328)] + #[test_case(6.3284, 3, 6328)] + #[test_case(6.3707, 3, 6371)] + #[test_case(6.3897, 3, 6390)] + #[test_case(6.5623, 3, 6562)] + #[test_case(6.5701, 3, 6570)] + #[test_case(6.6014, 3, 6601)] + #[test_case(6.6157, 3, 6616)] + #[test_case(6.6995, 3, 6700)] + #[test_case(6.7213, 3, 6721)] + #[test_case(6.8694, 3, 6869)] + #[test_case(6.932, 3, 6932)] + #[test_case(6.9411, 3, 6941)] + #[test_case(7.0225, 3, 7023)] + #[test_case(7.032, 3, 7032)] + #[test_case(7.1557, 3, 7156)] + #[test_case(7.1647, 3, 7165)] + #[test_case(7.183, 3, 7183)] + #[test_case(7.1869, 3, 7187)] + #[test_case(7.2222, 3, 7222)] + #[test_case(7.2293, 3, 7229)] + #[test_case(7.4952, 3, 7495)] + #[test_case(7.563, 3, 7563)] + #[test_case(7.5905, 3, 7591)] + #[test_case(7.7602, 3, 7760)] + #[test_case(7.7763, 3, 7776)] + #[test_case(7.8228, 3, 7823)] + #[test_case(7.8872, 3, 7887)] + #[test_case(7.9229, 3, 7923)] + #[test_case(7.9928, 3, 7993)] + #[test_case(8.0465, 3, 8047)] + #[test_case(8.0572, 3, 8057)] + #[test_case(8.0623, 3, 8062)] + #[test_case(8.0938, 3, 8094)] + #[test_case(8.145, 3, 8145)] + #[test_case(8.1547, 3, 8155)] + #[test_case(8.162, 3, 8162)] + #[test_case(8.1711, 3, 8171)] + #[test_case(8.2104, 3, 8210)] + #[test_case(8.2124, 3, 8212)] + #[test_case(8.2336, 3, 8234)] + #[test_case(8.2414, 3, 8241)] + #[test_case(8.3364, 3, 8336)] + #[test_case(8.5011, 3, 8501)] + #[test_case(8.5729, 3, 8573)] + #[test_case(8.7035, 3, 8704)] + #[test_case(8.882, 3, 8882)] + #[test_case(8.8834, 3, 8883)] + #[test_case(8.8921, 3, 8892)] + #[test_case(8.9127, 3, 8913)] + #[test_case(8.9691, 3, 8969)] + #[test_case(8.9782, 3, 8978)] + #[test_case(9.0893, 3, 9089)] + #[test_case(9.1449, 3, 9145)] + #[test_case(9.1954, 3, 9195)] + #[test_case(9.241, 3, 9241)] + #[test_case(9.3169, 3, 9317)] + #[test_case(9.3172, 3, 9317)] + #[test_case(9.406, 3, 9406)] + #[test_case(9.4351, 3, 9435)] + #[test_case(9.5563, 3, 9556)] + #[test_case(9.5958, 3, 9596)] + #[test_case(9.6461, 3, 9646)] + #[test_case(9.6985, 3, 9699)] + #[test_case(9.7331, 3, 9733)] + #[test_case(9.7433, 3, 9743)] + #[test_case(9.7725, 3, 9773)] + #[test_case(9.8178, 3, 9818)] + #[test_case(9.8311, 3, 9831)] + #[test_case(9.8323, 3, 9832)] + #[test_case(9.8414, 3, 9841)] + #[test_case(9.88, 3, 9880)] + #[test_case(9.9107, 3, 9911)] + fn to_fixed_decimal_works(value_float: f64, decimals: u8, expected: u128) { + let value_fixed: FixedU128 = value_float.to_fixed(); + let result: u128 = value_fixed.to_fixed_decimal(decimals).unwrap(); + // We allow for a small error because some floats like 9.9665 are actually 9.9664999... and + // round down instead of up. + assert_approx!(result, expected, 1); + } +} + +#[macro_export] +macro_rules! assert_approx { + ($left:expr, $right:expr, $precision:expr $(,)?) => { + match (&$left, &$right, &$precision) { + (left_val, right_val, precision_val) => { + let diff = if *left_val > *right_val { + *left_val - *right_val + } else { + *right_val - *left_val + }; + if diff > *precision_val { + panic!( + "assertion `left approx== right` failed\n left: {}\n right: {}\n \ + precision: {}\ndifference: {}", + *left_val, *right_val, *precision_val, diff + ); + } + } + } + }; +} diff --git a/primitives/src/math/mod.rs b/primitives/src/math/mod.rs new file mode 100644 index 000000000..42a27f245 --- /dev/null +++ b/primitives/src/math/mod.rs @@ -0,0 +1,20 @@ +// 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 . + +pub mod check_arithm_rslt; +mod consts; +pub mod fixed; diff --git a/primitives/src/pool.rs b/primitives/src/pool.rs index 62ca56d80..c84d64d54 100644 --- a/primitives/src/pool.rs +++ b/primitives/src/pool.rs @@ -84,5 +84,6 @@ where pub enum ScoringRule { CPMM, RikiddoSigmoidFeeMarketEma, + Lmsr, Orderbook, } diff --git a/primitives/src/traits.rs b/primitives/src/traits.rs index a1be3cd57..c2742e3a9 100644 --- a/primitives/src/traits.rs +++ b/primitives/src/traits.rs @@ -16,12 +16,16 @@ // You should have received a copy of the GNU General Public License // along with Zeitgeist. If not, see . +mod complete_set_operations_api; +mod deploy_pool_api; mod dispute_api; mod market_commons_pallet_api; mod market_id; mod swaps; 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 market_commons_pallet_api::MarketCommonsPalletApi; pub use market_id::MarketId; diff --git a/primitives/src/traits/complete_set_operations_api.rs b/primitives/src/traits/complete_set_operations_api.rs new file mode 100644 index 000000000..3685da5b4 --- /dev/null +++ b/primitives/src/traits/complete_set_operations_api.rs @@ -0,0 +1,35 @@ +// 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 sp_runtime::DispatchResult; + +pub trait CompleteSetOperationsApi { + type AccountId; + type Balance; + type MarketId; + + fn buy_complete_set( + who: Self::AccountId, + market_id: Self::MarketId, + amount: Self::Balance, + ) -> DispatchResult; + fn sell_complete_set( + who: Self::AccountId, + market_id: Self::MarketId, + amount: Self::Balance, + ) -> DispatchResult; +} diff --git a/primitives/src/traits/deploy_pool_api.rs b/primitives/src/traits/deploy_pool_api.rs new file mode 100644 index 000000000..92f0bd1df --- /dev/null +++ b/primitives/src/traits/deploy_pool_api.rs @@ -0,0 +1,33 @@ +// 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 alloc::vec::Vec; +use sp_runtime::DispatchResult; + +pub trait DeployPoolApi { + type AccountId; + type Balance; + type MarketId; + + fn deploy_pool( + who: Self::AccountId, + market_id: Self::MarketId, + amount: Self::Balance, + swap_prices: Vec, + swap_fee: Self::Balance, + ) -> DispatchResult; +} diff --git a/runtime/battery-station/Cargo.toml b/runtime/battery-station/Cargo.toml index 4d21ad84e..7df3e156b 100644 --- a/runtime/battery-station/Cargo.toml +++ b/runtime/battery-station/Cargo.toml @@ -1,7 +1,6 @@ [build-dependencies] substrate-wasm-builder = { workspace = true } - [dependencies] frame-executive = { workspace = true } frame-support = { workspace = true } @@ -113,6 +112,7 @@ zrml-court = { workspace = true } zrml-global-disputes = { workspace = true, optional = true } zrml-liquidity-mining = { workspace = true } zrml-market-commons = { workspace = true } +zrml-neo-swaps = { workspace = true } zrml-orderbook-v1 = { workspace = true } zrml-prediction-markets = { workspace = true } zrml-rikiddo = { workspace = true } @@ -178,8 +178,8 @@ runtime-benchmarks = [ "frame-system/runtime-benchmarks", "hex-literal", "orml-asset-registry?/runtime-benchmarks", - "orml-tokens/runtime-benchmarks", "orml-benchmarking", + "orml-tokens/runtime-benchmarks", "orml-xtokens?/runtime-benchmarks", "pallet-author-inherent?/runtime-benchmarks", "pallet-author-mapping?/runtime-benchmarks", @@ -210,6 +210,7 @@ runtime-benchmarks = [ "zrml-authorized/runtime-benchmarks", "zrml-court/runtime-benchmarks", "zrml-liquidity-mining/runtime-benchmarks", + "zrml-neo-swaps/runtime-benchmarks", "zrml-prediction-markets/runtime-benchmarks", "zrml-simple-disputes/runtime-benchmarks", "zrml-global-disputes/runtime-benchmarks", @@ -323,6 +324,7 @@ std = [ "zrml-court/std", "zrml-liquidity-mining/std", "zrml-market-commons/std", + "zrml-neo-swaps/std", "zrml-prediction-markets/std", "zrml-rikiddo/std", "zrml-simple-disputes/std", @@ -377,6 +379,7 @@ try-runtime = [ "zrml-court/try-runtime", "zrml-liquidity-mining/try-runtime", "zrml-market-commons/try-runtime", + "zrml-neo-swaps/try-runtime", "zrml-prediction-markets/try-runtime", "zrml-rikiddo/try-runtime", "zrml-simple-disputes/try-runtime", diff --git a/runtime/battery-station/src/parameters.rs b/runtime/battery-station/src/parameters.rs index 2eb042b1e..2d54e052d 100644 --- a/runtime/battery-station/src/parameters.rs +++ b/runtime/battery-station/src/parameters.rs @@ -190,6 +190,10 @@ parameter_types! { // Additional storage item size of 32 bytes. pub const DepositFactor: Balance = deposit(0, 32); + // NeoSwaps + pub const NeoSwapsMaxSwapFee: Balance = 10 * CENT; + pub const NeoSwapsPalletId: PalletId = NS_PALLET_ID; + // ORML pub const GetNativeCurrencyId: CurrencyId = Asset::Ztg; diff --git a/runtime/common/Cargo.toml b/runtime/common/Cargo.toml index 9935b06e0..ed404d80b 100644 --- a/runtime/common/Cargo.toml +++ b/runtime/common/Cargo.toml @@ -29,6 +29,7 @@ pallet-transaction-payment-rpc-runtime-api = { workspace = true } pallet-treasury = { workspace = true } pallet-utility = { workspace = true } pallet-vesting = { workspace = true } +zeitgeist-primitives = { workspace = true } # Utility cfg-if = { workspace = true } @@ -72,6 +73,7 @@ std = [ "pallet-utility/std", "pallet-vesting/std", "pallet-parachain-staking?/std", + "zeitgeist-primitives/std", ] [package] diff --git a/runtime/common/src/lib.rs b/runtime/common/src/lib.rs index f311977a1..c7a299446 100644 --- a/runtime/common/src/lib.rs +++ b/runtime/common/src/lib.rs @@ -51,7 +51,9 @@ macro_rules! decl_common_types { }; #[cfg(feature = "try-runtime")] use frame_try_runtime::{TryStateSelect, UpgradeCheckSelect}; - use sp_runtime::generic; + use sp_runtime::{generic, DispatchResult}; + use zeitgeist_primitives::traits::DeployPoolApi; + use zrml_neo_swaps::types::MarketCreatorFee; pub type Block = generic::Block; @@ -219,10 +221,10 @@ macro_rules! decl_common_types { common_runtime::impl_fee_types!(); pub mod opaque { - //! Opaque types. These are used by the CLI to instantiate machinery that don't need to know - //! the specifics of the runtime. They can then be made to be agnostic over specific formats - //! of data like extrinsics, allowing for them to continue syncing the network through upgrades - //! to even the core data structures. + //! Opaque types. These are used by the CLI to instantiate machinery that don't need to + //! know the specifics of the runtime. They can then be made to be agnostic over + //! specific formats of data like extrinsics, allowing for them to continue syncing the + //! network through upgrades to even the core data structures. use super::Header; use alloc::vec::Vec; @@ -311,7 +313,8 @@ macro_rules! create_runtime { PredictionMarkets: zrml_prediction_markets::{Call, Event, Pallet, Storage} = 57, Styx: zrml_styx::{Call, Event, Pallet, Storage} = 58, GlobalDisputes: zrml_global_disputes::{Call, Event, Pallet, Storage} = 59, - Orderbook: zrml_orderbook_v1::{Call, Event, Pallet, Storage} = 60, + NeoSwaps: zrml_neo_swaps::{Call, Event, Pallet, Storage} = 60, + Orderbook: zrml_orderbook_v1::{Call, Event, Pallet, Storage} = 61, $($additional_pallets)* } @@ -1123,6 +1126,7 @@ macro_rules! impl_config_traits { type Court = Court; type CloseOrigin = EnsureRoot; type DestroyOrigin = EnsureRootOrAllAdvisoryCommittee; + type DeployPool = NeoSwaps; type DisputeBond = DisputeBond; type RuntimeEvent = RuntimeEvent; type GlobalDisputes = GlobalDisputes; @@ -1237,6 +1241,17 @@ macro_rules! impl_config_traits { type WeightInfo = zrml_styx::weights::WeightInfo; } + impl zrml_neo_swaps::Config for Runtime { + type CompleteSetOperations = PredictionMarkets; + type ExternalFees = MarketCreatorFee; + type MarketCommons = MarketCommons; + type MultiCurrency = AssetManager; + type RuntimeEvent = RuntimeEvent; + type WeightInfo = zrml_neo_swaps::weights::WeightInfo; + type MaxSwapFee = NeoSwapsMaxSwapFee; + type PalletId = NeoSwapsPalletId; + } + impl zrml_orderbook_v1::Config for Runtime { type AssetManager = AssetManager; type RuntimeEvent = RuntimeEvent; @@ -1357,6 +1372,7 @@ macro_rules! create_runtime_api { list_benchmark!(list, extra, zrml_prediction_markets, PredictionMarkets); list_benchmark!(list, extra, zrml_liquidity_mining, LiquidityMining); list_benchmark!(list, extra, zrml_styx, Styx); + list_benchmark!(list, extra, zrml_neo_swaps, NeoSwaps); cfg_if::cfg_if! { if #[cfg(feature = "parachain")] { @@ -1459,6 +1475,7 @@ macro_rules! create_runtime_api { add_benchmark!(params, batches, zrml_prediction_markets, PredictionMarkets); add_benchmark!(params, batches, zrml_liquidity_mining, LiquidityMining); add_benchmark!(params, batches, zrml_styx, Styx); + add_benchmark!(params, batches, zrml_neo_swaps, NeoSwaps); cfg_if::cfg_if! { diff --git a/runtime/zeitgeist/Cargo.toml b/runtime/zeitgeist/Cargo.toml index 83514b05d..93b1906bd 100644 --- a/runtime/zeitgeist/Cargo.toml +++ b/runtime/zeitgeist/Cargo.toml @@ -111,6 +111,7 @@ zrml-court = { workspace = true } zrml-global-disputes = { workspace = true, optional = true } zrml-liquidity-mining = { workspace = true } zrml-market-commons = { workspace = true } +zrml-neo-swaps = { workspace = true } zrml-orderbook-v1 = { workspace = true } zrml-prediction-markets = { workspace = true } zrml-rikiddo = { workspace = true } @@ -177,9 +178,9 @@ runtime-benchmarks = [ "hex-literal", "polkadot-runtime?/runtime-benchmarks", "orml-asset-registry?/runtime-benchmarks", + "orml-benchmarking", "orml-tokens/runtime-benchmarks", "orml-xtokens?/runtime-benchmarks", - "orml-benchmarking", "pallet-author-inherent?/runtime-benchmarks", "pallet-author-mapping?/runtime-benchmarks", "pallet-author-slot-filter?/runtime-benchmarks", @@ -207,6 +208,7 @@ runtime-benchmarks = [ "zrml-authorized/runtime-benchmarks", "zrml-court/runtime-benchmarks", "zrml-liquidity-mining/runtime-benchmarks", + "zrml-neo-swaps/runtime-benchmarks", "zrml-prediction-markets/runtime-benchmarks", "zrml-simple-disputes/runtime-benchmarks", "zrml-global-disputes/runtime-benchmarks", @@ -312,6 +314,7 @@ std = [ "zrml-court/std", "zrml-liquidity-mining/std", "zrml-market-commons/std", + "zrml-neo-swaps/std", "zrml-prediction-markets/std", "zrml-rikiddo/std", "zrml-simple-disputes/std", @@ -366,6 +369,7 @@ try-runtime = [ "zrml-court/try-runtime", "zrml-liquidity-mining/try-runtime", "zrml-market-commons/try-runtime", + "zrml-neo-swaps/try-runtime", "zrml-prediction-markets/try-runtime", "zrml-rikiddo/try-runtime", "zrml-simple-disputes/try-runtime", diff --git a/runtime/zeitgeist/src/parameters.rs b/runtime/zeitgeist/src/parameters.rs index b4a907ec3..4bdf563fa 100644 --- a/runtime/zeitgeist/src/parameters.rs +++ b/runtime/zeitgeist/src/parameters.rs @@ -190,6 +190,10 @@ parameter_types! { // Additional storage item size of 32 bytes. pub const DepositFactor: Balance = deposit(0, 32); + // NeoSwaps + pub const NeoSwapsMaxSwapFee: Balance = 10 * CENT; + pub const NeoSwapsPalletId: PalletId = NS_PALLET_ID; + // ORML pub const GetNativeCurrencyId: CurrencyId = Asset::Ztg; diff --git a/scripts/benchmarks/configuration.sh b/scripts/benchmarks/configuration.sh index c4f71fe2c..f5ce38ec1 100644 --- a/scripts/benchmarks/configuration.sh +++ b/scripts/benchmarks/configuration.sh @@ -27,8 +27,8 @@ export ORML_PALLETS_STEPS="${ORML_PALLETS_STEPS:-50}" export ORML_WEIGHT_TEMPLATE="./misc/orml_weight_template.hbs" export ZEITGEIST_PALLETS=( - zrml_authorized zrml_court zrml_global_disputes zrml_liquidity_mining zrml_prediction_markets \ - zrml_swaps zrml_styx \ + zrml_authorized zrml_court zrml_global_disputes zrml_liquidity_mining zrml_neo_swaps \ + zrml_prediction_markets zrml_swaps zrml_styx \ ) export ZEITGEIST_PALLETS_RUNS="${ZEITGEIST_PALLETS_RUNS:-1000}" export ZEITGEIST_PALLETS_STEPS="${ZEITGEIST_PALLETS_STEPS:-10}" diff --git a/zrml/neo-swaps/Cargo.toml b/zrml/neo-swaps/Cargo.toml new file mode 100644 index 000000000..62f5f1a3b --- /dev/null +++ b/zrml/neo-swaps/Cargo.toml @@ -0,0 +1,109 @@ +[dependencies] +fixed = { workspace = true } +frame-benchmarking = { workspace = true, optional = true } +frame-support = { workspace = true } +frame-system = { workspace = true } +hydra-dx-math = { 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 } +typenum = { workspace = true } +zeitgeist-primitives = { workspace = true } +zrml-market-commons = { workspace = true } + +# Mock + +orml-asset-registry = { workspace = true, optional = true } +orml-currencies = { workspace = true, optional = true } +orml-tokens = { workspace = true, optional = true } +pallet-balances = { workspace = true, optional = true } +pallet-randomness-collective-flip = { workspace = true, optional = true } +pallet-timestamp = { workspace = true, optional = true } +pallet-treasury = { workspace = true, optional = true } +pallet-xcm = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +sp-api = { workspace = true, optional = true } +sp-io = { workspace = true, optional = true } +substrate-fixed = { workspace = true, optional = true } +xcm = { workspace = true, optional = true } +xcm-builder = { workspace = true, optional = true } +zrml-authorized = { workspace = true, optional = true } +zrml-court = { workspace = true, optional = true } +zrml-global-disputes = { workspace = true, optional = true } +zrml-liquidity-mining = { workspace = true, optional = true } +zrml-prediction-markets = { workspace = true, optional = true } +zrml-prediction-markets-runtime-api = { workspace = true, optional = true } +zrml-rikiddo = { workspace = true, optional = true } +zrml-simple-disputes = { workspace = true, optional = true } +zrml-swaps = { workspace = true, optional = true } + + +[dev-dependencies] +more-asserts = { workspace = true } +test-case = { workspace = true } +zrml-neo-swaps = { workspace = true, features = ["mock"] } + +[features] +default = ["std"] +mock = [ + "orml-currencies/default", + "orml-tokens/default", + "pallet-balances", + "pallet-randomness-collective-flip/default", + "pallet-timestamp/default", + "pallet-treasury/default", + "sp-api/default", + "sp-io/default", + "substrate-fixed", + "zeitgeist-primitives/mock", + "zrml-prediction-markets-runtime-api/default", + "zrml-rikiddo/default", + "zrml-swaps/default", + "xcm/default", + "orml-asset-registry/default", + "orml-currencies/default", + "orml-tokens/default", + "pallet-balances/default", + "pallet-timestamp/default", + "sp-api/default", + "sp-io/default", + "zrml-court/std", + "zrml-authorized/std", + "zrml-liquidity-mining/std", + "zrml-simple-disputes/std", + "zrml-global-disputes/std", + "zrml-prediction-markets/std", + "zrml-prediction-markets/mock", + "zrml-prediction-markets/default", + "serde/default", +] +parachain = ["zrml-prediction-markets/parachain"] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "xcm-builder/runtime-benchmarks", + "pallet-xcm/runtime-benchmarks", +] +std = [ + "frame-benchmarking?/std", + "frame-support/std", + "frame-system/std", + "orml-traits/std", + "parity-scale-codec/std", + "sp-runtime/std", + "xcm-builder/std", + "pallet-xcm/std", + "zeitgeist-primitives/std", + "zrml-market-commons/std", +] +try-runtime = [ + "frame-support/try-runtime", +] + +[package] +authors = ["Zeitgeist PM "] +edition = "2021" +name = "zrml-neo-swaps" +version = "0.4.0" diff --git a/zrml/neo-swaps/README.md b/zrml/neo-swaps/README.md new file mode 100644 index 000000000..e2b057c39 --- /dev/null +++ b/zrml/neo-swaps/README.md @@ -0,0 +1,47 @@ +# Neo Swaps Module + +The Neo Swaps module implements liquidity pools which use the Logarithmic Market +Scoring Rule (LMSR) to determine spot prices and swap amounts, and allow users +to dynamically provide liquidity. + +## Overview + +For a detailed description of the underlying mathematics see [here][docslink]. + +### Terminology + +- _Collateral_: The currency type that backs the outcomes in the pool. This is + also called the _base asset_ in other contexts. +- _Exit_: Refers to removing (part of) the liquidity from a liquidity pool in + exchange for burning pool shares. +- _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. +- _Join_: Refers to adding more liquidity to a pool and receiving pool shares + in return. +- _Liquidity provider_: A user who owns pool shares indicating their stake in + the liquidity pool. +- _Pool Shares_: A token indicating the owner's per rate share of the + liquidity pool. +- _Reserve_: The balances in the liquidity pool used for trading. +- _Swap fees_: Part of the collateral paid or received by informants that is + moved to a separate account owned by the liquidity providers. They need to + be withdrawn using the `withdraw_fees` extrinsic. + +### Notes + +- The `Pool` struct tracks the reserve held in the pool account. The reserve + changes when trades are executed or the liquidity changes, but the reserve + does not take into account funds that are sent to the pool account + unsolicitedly. This fixes a griefing vector which allows an attacker to + change prices by sending funds to the pool account. +- Pool shares are not recorded using the `ZeitgeistAssetManager` trait. + Instead, they are part of the `Pool` object and can be tracked using events. +- When the native currency is used as collateral, the pallet deposits the + existential deposit to the pool account (which holds the swap fees). This is + done to ensure that small amounts of fees don't cause the entire transaction + to error with `ExistentialDeposit`. This "buffer" is removed when the pool + is destroyed. The pool account is expected to be whitelisted from dusting + for all other assets. + +[docslink]: ./docs/docs.pdf diff --git a/zrml/neo-swaps/docs/docs.pdf b/zrml/neo-swaps/docs/docs.pdf new file mode 100644 index 000000000..f98e4c2bf Binary files /dev/null and b/zrml/neo-swaps/docs/docs.pdf differ diff --git a/zrml/neo-swaps/docs/docs.tex b/zrml/neo-swaps/docs/docs.tex new file mode 100644 index 000000000..a3712d394 --- /dev/null +++ b/zrml/neo-swaps/docs/docs.tex @@ -0,0 +1,260 @@ +\documentclass[12pt]{article} + +% Packages +\usepackage{amsmath} +\usepackage{amsfonts} +\usepackage{amssymb} +\usepackage{graphicx} +\usepackage{hyperref} +\usepackage{geometry} +\usepackage{listings} +\usepackage{xcolor} +\usepackage{parskip} +\geometry{a4paper, margin=1in} +\newtheorem{theorem}{Theorem} +\lstset{ + literate={←}{$\leftarrow$}{1} + {→}{$\rightarrow$}{1}, + basicstyle=\ttfamily\small, + keywordstyle=\color{blue}, + commentstyle=\color{olive}, + numberstyle=\tiny\color{gray}, + numbers=left, + frame=single, + backgroundcolor=\color{yellow!10}, + breaklines=true, + captionpos=b, + tabsize=4 +} + +\title{zrml-neo-swaps Documentation} +\date{0.4.1 (\today)} + +\begin{document} + +\maketitle + +\section{Introduction} + +This document provides the mathematical and technical details for zrml-neo-swaps. The automatic market maker (AMM) implemented by zrml-neo-swaps is a variant of the Logarithmic Market Scoring Rule (LMSR; \cite{hanson_2003}) which was first developed by Gnosis (see \url{https://docs.gnosis.io/conditionaltokens/docs/introduction3/}). We often refer to it as AMM 2.0. + +Unlike the typical implementation using a cost function (see \cite{chen_vaughan_2010}), this implementation of LMSR is a \emph{constant-function market maker} (CFMM), similar to the classical constant product market maker, which allows us to implement \emph{dynamic liquidity}. In other words, liquidity providers (LPs) can come and go as they please, allowing the market to self-regulate how much price resistance the AMM should provide. + +As of v0.4.1, the AMM is only available for markets with two outcomes. This will be mitigated in a future publication. + +\section{The Trading Function} + +We consider a prediction market with $n$ outcomes, denoted by $1, \ldots, n$ for simplicity. Every complete set of outcome tokens is backed a unit of collateral, denotes by \$. The AMM operates on a \emph{liquidity pool} (or just \emph{pool}), which consists of a \emph{reserve} $(r_1, \ldots, r_n)$ of outcome tokens and a \emph{liquidity parameter} $b$. The trading function is defined as +\[ + \varphi(b, r) = \sum_i e^{-r_i/b}. +\] +In fact, $\varphi(b, r)$ must always equal $1$. This means that a trader may change the reserve from $r$ to $r'$ and receive the delta provided that $\varphi(b, r') = 1$. We denote such a trade by $r \rightarrow r'$. We call these outcome-to-outcome (O2O) swaps. + +However, we do not allow users to execute these types of trades. Instead, we only allow \emph{buys} (exchange collateral for outcome tokens) and \emph{sells} (exchange outcome tokens for collateral). + +\section{Buying and Selling} + +\subsection{Buying} + +Buying and selling is implemented by combining complete set operations (exchange $x$ dollars for $x$ units of every outcome) and O2O swaps. + +Alice wants to swap $x$ dollars for units of outcome $i$. This is done by exchanging $x$ dollars for $x$ complete sets and then selling all outcomes $k \neq i$ for more $i$ using an O2O swap $r \rightarrow r'$, which yields $y(x)$ additional units of $i$. \emph{Ignoring swap fees}, this modifies the reserve to $r'$, where $r_k' = r_k + x$ for $k \neq i$ and $r_i' = r_i - y(x)$. As trades don't change the invariant, we have $1 = \sum_k e^{-r_k'/b}$. Thus, using $1 = \varphi(b, r) = \sum_k e^{-r_k/b}$, +\begin{align*} + 1 &= \sum_k e^{-r_k'/b} \\ + &= \sum_{k \neq i} e^{-(r_k + x)/b} + e^{-(r_i-y(x))/b} \\ + &= e^{-x/b} \sum_{k \neq i} e^{-r_k/b} + e^{y(x)/b} e^{-r_i/b} \\ + &= e^{-x/b} (1 - e^{-r_i/b}) + e^{y(x)/b} e^{-r_i/b}. +\end{align*} +Rearranging these terms gives +\[ + e^{y(x)/b} = e^{r_i/b} (1 - e^{-x/b}(1 - e^{-r_i/b})), +\] +and, thus, +\begin{align*} + y(x) &= b \ln(e^{r_i/b} (1 - e^{-x/b}(1 - e^{-r_i/b}))) \\ + &= b \ln (1 - e^{-x/b}(1 - e^{-r_i/b})) + r_i \\ + &= b \ln (e^{x/b} - 1 + e^{-r_i/b}) + r_i - x. +\end{align*} + +Note that the total amount of outcome $i$ that Alice receives is $y(x)$ from the O2O trade and $x$ from the complete set operation. We denote this by $z(x) = y(x) + x$. + +This allows us to calculate the \emph{spot price} of outcome $i$ +\[ + p_i(b, r) = \lim_{x \rightarrow 0} \frac{x}{z(x)} = \frac{1}{z'(0)} = \frac{1}{y'(0) + 1}. +\] +Calculating the derivative of $y$ yields +\[ + y'(x) = \frac{e^{x/b}}{e^{x/b} - 1 + e^{-r_i/b}} - 1 +\] +and thus $y'(0) = e^{r_i/b} - 1$, which yields $p_i(b, r) = e^{-r_i/b}$. + +Note that this means +\[ + 1 = \varphi(b, r) = \sum_i p_i(b, r). +\] +In particular, $(p_1, \ldots, p_n)$ always maps to a probability distribution. + +Trading fees are specified as fractional (a fee of $f = .01$ means that $1\%$ are charged) and deducted from the amount of collateral before the complete set operations are executed. In other words, the liquidity providers receive $fx$ dollars (fees are distributed pro rata amongst the liquidity providers) and Alice goes through the entire process described above with $\tilde x = (1-f)x$ in place of $x$. The spot price taking the fees into account is (as expected) +\[ + \psi(b, r, f) = (1 - f)^{-1}e^{-r_i/b}. +\] + +\subsection{Selling} + +Alice wants to swap $x$ units of $i$ for dollars. This is done by selling $x' < x$ units of $i$ for $v(x) = x - x'$ units of all outcomes $k \neq i$ and then selling $v(x)$ units of complete sets, which yields $v(x)$ dollars. \emph{Ignoring swap fees}, this modifies the reserve from $r$ to $r'$, where $r_k' = r_k - v(x)$ and $r_i = r_i + x'$. Using $1 = \varphi(b, r')$ and $x' = x - v(x)$, we get +\begin{align*} + 1 &= \sum_k e^{-r_k'/b} \\ + &= \sum_{k \neq i} e^{-(r_k - v(x))/b} + e^{-(r_i + x')/b} \\ + &= e^{v(x)/b} \sum_{k \neq i} e^{-r_i/b} + e^{-x'/b} e^{-r_i/b} \\ + &= e^{v(x)/b} (1 - e^{-r_i/b}) + e^{-x/b} e^{v(x)/b} e^{-r_i/b} \\ + &= e^{v(x)/b} ( 1 - e^{-r_i/b} + e^{-(r_i + x)/b} ). +\end{align*} +Thus, we get +$$ + e^{-v(x)/b} = 1 - e^{-r_i/b} + e^{-(r_i + x)/b}, +$$ +which in turn yields \begin{align*} + v(x) &= - b \ln (1 - e^{-r_i/b} + e^{-(x+r_i)/b} \\ + &= -b \ln (e^{r_i/b} - 1 + e^{-x/b}) + r_i \\ &= -b \ln (e^{(x + r_i)/b} - e^{x/b} + 1) + r_i + x. +\end{align*} + +Trading fees are deducted from the amount of collateral received from the complete set operation. In other words, the liquidity providers receive $fv(x)$ dollars and Alice receives $(1-f)v(x)$. The selling price (the amount of collateral received for each unit of $x$), is then (as expected) +\[ + \lim_{x \rightarrow 0} \frac{(1-f)v(x)}{x} = (1-f) v'(0) = (1-f) e^{-r_i/b} = (1-f)p_i(b, r). +\] +This leads to a typical bid-ask spread between buy and sell price. + +\section{Dynamic Liquidity} + +Liquidity may be added or removed dynamically to regulate the market's price resistance. Each LP's share of the pool is tracked using pool share tokens, which record their \emph{pro rata} share of the pool. + +We consider a pool with liquidity parameter $b$, reserve $r$ and a total issuance of $q$ pool shares. + +\subsection{Adding Liquidity} + +Alice wants to add liquidity to the pool. She's willing to pay $x$ dollars. To implement this, she first spends $x$ dollars to buy $x$ complete sets. + +Now let $i$ be so that $r_i = \max_k r_k$. Let $\lambda = x / r_i$ and $\mu = 1 + \lambda$. For each $k$, Alice moves $\lambda r_k$ units of $k$ into the pool and receives $\lambda q$ pool shares. The liquidity parameter changes from $b$ to $b' = \mu b$. Alice's transfers change the reserve from $r$ to $r' = \mu r$. + +The new total issuance of pool shares is $\mu q$ and Alice's share of the pool now is $\lambda / \mu$. Note that Alice retains the balance $(x)^n - \lambda r$ of "left-over tokens". + +\subsection{Withdrawing Liquidity} + +Alice wants to withdraw liquidity from the pool. She's willing to burn $p$ pool shares. + +Let $\lambda = p / q$ and $\mu = 1 - \lambda$. For each $k$, Alice receives $\lambda r_k$ units of $k$ from the pool. The liquidity parameter changes from $b$ to $b' = \mu b$. Alice's transfers change the reserve from $r$ to $r' = \mu r$. + +Alice could now convert $x = \min_i r_i$ complete sets of her newly received funds into $x$ dollars. The remainder of the funds will remain in her wallet until the market resolves or she opts to sell them. + +\subsection{Fee Distribution} + +Fees are distributed pro rata amongst the liquidity providers. These funds are completely separate from the reserve used for trading. Transferring the fees into the pool (like the constant product market maker does) wouldn't make any sense here as collateral is not directly traded on the pool. + +\section{Creating Pools} + +Creating a pool is straightforward. The initial odds are defined by adding different amounts of each outcome to the pool. If Alice wants to deposit liquidity worth $x$ units of collateral with initial probability $p$, then she starts off by buying $x$ complete sets. The following algorithm is used to calculate how many units of each outcome go into the pool. Alice retains the other tokens as "left-overs". + +Let $b = 1$, and let $r_i = - b \ln p_i$ for all $i$. Now let $y = x / \max_i r_i$. Then $y r_i \leq x$ for all $i$ and there exists $i_0$ so that $y r_{i_0} = x$. Set $\tilde r_i = y r_i$ and $\tilde b = yb$. Then +\[ + p_i(\tilde r) = e^{-\tilde r_i/\tilde b} = e^{-r_i/b} = p_i +\] +and $\max_i \tilde r_i = x$ (so Alice uses up at least one of her outcome balances). + +In pseudocode: + +\begin{lstlisting}[language=Pascal, caption=Procedure to Calculate Balances] +Procedure CalculateBalances(p[1...n], x) + b ← 1 // Initialize b, larger values may be picked for numerical stability + For i from 1 to n do + r[i] ← -b * log(p[i]) + End For + y ← x / max(r[1...n]) + For i from 1 to n do + r[i] ← y * r[i] + End For + b ← y * b + Return r, b +End Procedure +\end{lstlisting} + +\section{Additional Formulas} + +\subsection{Estimated Price After Execution} + +After executing a buy for $x$ units of collateral for outcome $i$, the new reserve of $i$ is +\[ + r_i' = r_i - y((1-f)x) = -b \ln (1 - e^{-(1-f)x/b}(1 - e^{-r_i/b})). +\] +Thus, the new price is +\[ + p_i(f, b, r') = \frac{1}{1-f} (1 - e^{-(1-f)x/b}(1 - e^{-r_i/b})). +\] + +After executing a sell of $x$ units of outcome $i$ for collateral, the new reserve of $i$ is +\[ + r_i' = r_i + x' = r_i + x - v(x) = b \ln (e^{(x + r_i)/b} - e^{x/b} + 1). +\] +The new price is therefore +\[ + p_i(f, b, r') = \frac{1}{1-f} (e^{(x + r_i)/b} - e^{x/b} + 1). +\] + +\section{Numerical Issues} + +Special care must be taken to avoid over- and underflows when calculating expressions like +\begin{align*} + y(x) &= b \ln (e^{x/b} - 1 + e^{-r_i/b}) + r_i - x, \\ + v(x) &= -b \ln (e^{r_i/b} - 1 + e^{-x/b}) + r_i. +\end{align*} +The magnitude of $y(x)$ is the same as $x$, but the exponentials $e^{x/b}$ and $e^{-r_i/b}$ over- or underflow easily. + +Let $A = 20$. Python calculates $e^A = 485165195.4097903$ and $e^{-A} = 2.061153622438558 \cdot 10^{-9}$. The fixed crate (see \url{https://crates.io/crates/fixed}) can represent these using \texttt{FixedU128} without considerable loss of precision or risk of over- or underflow. Let $M = e^A$. + +Note that for any number $a$, the following are equivalent: 1) $M^{-1} \leq e^a \leq M$, 2) $M^{-1} \leq e^{-a} \leq M$. Thus, the following restrictions prevent over- and underflows when calculating the exponential expressions: + +\begin{itemize} + \item The amount $x$ must satisfy $x \leq Ab$. + \item The price of $i$ must satisfy $p_i(b, r) = e^{-r_i/b} \geq e^{-A}$. +\end{itemize} + +How "bad" are these restrictions? The first restriction is completely irrelevant: Suppose Alice executes a trade of $y(x)$ units of outcome $i$ for $x = Ab$ dollars, the maximum allowed value. Let $q = 1 - e^{-r_i/b} \in (0, 1)$. Then +\begin{align*} + \ln(e^A) - \ln(e^A - q) &= \ln\left(\frac{e^A}{e^A - q}\right) \\ + &\leq \ln\left(\frac{e^A}{e^A - 1}\right) \\ + &\approx 2.0611536900435727 \cdot 10^{-9} \\ + &\leq 10^{-10}. +\end{align*} +Let $\varepsilon = 10^{-10}$. Then we have +\begin{align*} + y(x) &= b\ln(e^A - 1 + e^{-r_i/b}) + r_i - x \\ + &\geq b(\ln(e^A) - \varepsilon) + r_i - x \\ + &= bA - b\varepsilon + r_i - x \\ + &= r_i - b\varepsilon. +\end{align*} +Thus, Alice receives all funds from the pool except $b \varepsilon$, which is very small unless the pool contains an inordinate amount of liquidity. + +The second restriction means that no trades of outcome $i$ can be executed if the price of $i$ drops below the threshold $\varepsilon = e^{-A}$. On markets with two outcomes (binary or scalar), this is equivalent to the price of the other outcome rising above $1 - \varepsilon$. Due to risk considerations, these are generally scenarios that won't occur. + +For markets with two outcomes (binary or scalar), we therefore make the following restriction: \emph{Any trade that moves the price of an outcome below $\varepsilon = .005$ (or equivalently, moves the price of an outcome above $1 - \varepsilon$) is not allowed.} This will ensure that the pool is always in a valid state where it can execute trades. Note that in the presence of a swap fee of 1\%, this isn't even a restriction. + +Markets with more than two outcomes are currently not allowed to use AMM 2.0 pools. The issue in a market with three or more outcomes $A, B, C, \ldots$ is that if $C$ is a clear underdog and most of the trading happens between the favorites $A$ and $B$, then the price of $C$ might drop below the allowed threshold and \emph{brick} the market of $C$ (all trades involving $C$ must be rejected due to numerical issues). While this is most likely to happen if the market is $C$-weakly trivialized (it is common knowledge that $C$ will almost certainly not materialize), which should never happen on a live market, this is unfortunate. A solution for this issue is provided in the near future. + +\newpage + +\begin{thebibliography}{9} + \bibitem{chen_vaughan_2010} + Yiling Chen and Jennifer Wortman Vaughan, + \emph{A new understanding of prediction markets via no-regret learning}, + EC '10: Proceedings of the 11th ACM conference on Electronic commerce, + June 2010, Pages 189–198. + \url{https://doi.org/10.1145/1807342.1807372} + + \bibitem{hanson_2003} + Robin Hanson, + \emph{Logarithmic Market Scoring Rules for Modular Combinatorial Information Aggregation}, + The Journal of Prediction Markets, 1(1), + May 2003. + \url{https://doi.org/10.5750/jpm.v1i1.417} +\end{thebibliography} + +\end{document} diff --git a/zrml/neo-swaps/src/benchmarking.rs b/zrml/neo-swaps/src/benchmarking.rs new file mode 100644 index 000000000..a2dc0b1ee --- /dev/null +++ b/zrml/neo-swaps/src/benchmarking.rs @@ -0,0 +1,239 @@ +// 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(feature = "runtime-benchmarks")] + +use super::*; +use crate::{ + consts::*, traits::liquidity_shares_manager::LiquiditySharesManager, AssetOf, BalanceOf, + MarketIdOf, Pallet as NeoSwaps, Pools, +}; +use frame_benchmarking::v2::*; +use frame_support::{ + assert_ok, + storage::{with_transaction, TransactionOutcome::*}, +}; +use frame_system::RawOrigin; +use orml_traits::MultiCurrency; +use sp_runtime::{Perbill, SaturatedConversion}; +use zeitgeist_primitives::{ + constants::CENT, + traits::CompleteSetOperationsApi, + types::{Asset, Market, MarketCreation, MarketPeriod, MarketStatus, MarketType, ScoringRule}, +}; +use zrml_market_commons::MarketCommonsPalletApi; + +macro_rules! assert_ok_with_transaction { + ($expr:expr) => {{ + assert_ok!(with_transaction(|| match $expr { + Ok(val) => Commit(Ok(val)), + Err(err) => Rollback(Err(err)), + })); + }}; +} + +fn create_market( + caller: T::AccountId, + base_asset: AssetOf, + asset_count: AssetIndexType, +) -> MarketIdOf { + let market = Market { + base_asset, + creation: MarketCreation::Permissionless, + creator_fee: Perbill::zero(), + creator: caller.clone(), + oracle: caller, + metadata: vec![0, 50], + market_type: MarketType::Categorical(asset_count), + period: MarketPeriod::Block(0u32.into()..1u32.into()), + deadlines: Default::default(), + scoring_rule: ScoringRule::Lmsr, + status: MarketStatus::Active, + report: None, + resolved_outcome: None, + dispute_mechanism: None, + bonds: Default::default(), + }; + let maybe_market_id = T::MarketCommons::push_market(market); + maybe_market_id.unwrap() +} + +fn create_market_and_deploy_pool( + caller: T::AccountId, + base_asset: AssetOf, + asset_count: AssetIndexType, + amount: BalanceOf, +) -> MarketIdOf { + let market_id = create_market::(caller.clone(), base_asset, asset_count); + let total_cost = amount + T::MultiCurrency::minimum_balance(base_asset); + assert_ok!(T::MultiCurrency::deposit(base_asset, &caller, total_cost)); + assert_ok_with_transaction!(T::CompleteSetOperations::buy_complete_set( + caller.clone(), + market_id, + amount + )); + assert_ok!(NeoSwaps::::deploy_pool( + RawOrigin::Signed(caller).into(), + market_id, + amount, + vec![_1_2.saturated_into(), _1_2.saturated_into()], + CENT.saturated_into(), + )); + market_id +} + +#[benchmarks] +mod benchmarks { + use super::*; + + #[benchmark] + fn buy() { + let alice: T::AccountId = whitelisted_caller(); + let base_asset = Asset::Ztg; + let asset_count = 2u16; + let market_id = create_market_and_deploy_pool::( + alice, + base_asset, + asset_count, + _10.saturated_into(), + ); + let asset_out = Asset::CategoricalOutcome(market_id, 0); + let amount_in = _1.saturated_into(); + let min_amount_out = 0u8.saturated_into(); + + let bob: T::AccountId = whitelisted_caller(); + assert_ok!(T::MultiCurrency::deposit(base_asset, &bob, amount_in)); + + #[extrinsic_call] + _(RawOrigin::Signed(bob), market_id, asset_count, asset_out, amount_in, min_amount_out); + } + + #[benchmark] + fn sell() { + let alice: T::AccountId = whitelisted_caller(); + let base_asset = Asset::Ztg; + let market_id = + create_market_and_deploy_pool::(alice, base_asset, 2u16, _10.saturated_into()); + let asset_in = Asset::CategoricalOutcome(market_id, 0); + let amount_in = _1.saturated_into(); + let min_amount_out = 0u8.saturated_into(); + + let bob: T::AccountId = whitelisted_caller(); + assert_ok!(T::MultiCurrency::deposit(asset_in, &bob, amount_in)); + + #[extrinsic_call] + _(RawOrigin::Signed(bob), market_id, 2, asset_in, amount_in, min_amount_out); + } + + #[benchmark] + fn join() { + let alice: T::AccountId = whitelisted_caller(); + let base_asset = Asset::Ztg; + let market_id = create_market_and_deploy_pool::( + alice.clone(), + base_asset, + 2u16, + _10.saturated_into(), + ); + let pool_shares_amount = _1.saturated_into(); + let max_amounts_in = vec![u128::MAX.saturated_into(), u128::MAX.saturated_into()]; + + assert_ok!(T::MultiCurrency::deposit(base_asset, &alice, pool_shares_amount)); + assert_ok_with_transaction!(T::CompleteSetOperations::buy_complete_set( + alice.clone(), + market_id, + pool_shares_amount + )); + + #[extrinsic_call] + _(RawOrigin::Signed(alice), market_id, pool_shares_amount, max_amounts_in); + } + + // There are two execution paths in `exit`: 1) Keep pool alive or 2) destroy it. Clearly 1) is + // heavier. + #[benchmark] + fn exit() { + let alice: T::AccountId = whitelisted_caller(); + let base_asset = Asset::Ztg; + let market_id = create_market_and_deploy_pool::( + alice.clone(), + base_asset, + 2u16, + _10.saturated_into(), + ); + let pool_shares_amount = _1.saturated_into(); + let min_amounts_out = vec![0u8.saturated_into(), 0u8.saturated_into()]; + + #[extrinsic_call] + _(RawOrigin::Signed(alice), market_id, pool_shares_amount, min_amounts_out); + + assert!(Pools::::contains_key(market_id)); // Ensure we took the right turn. + } + + #[benchmark] + fn withdraw_fees() { + let alice: T::AccountId = whitelisted_caller(); + let base_asset = Asset::Ztg; + let market_id = create_market_and_deploy_pool::( + alice.clone(), + base_asset, + 2u16, + _10.saturated_into(), + ); + let fee_amount = _1.saturated_into(); + + // Mock up some fees. + let mut pool = Pools::::get(market_id).unwrap(); + assert_ok!(T::MultiCurrency::deposit(base_asset, &pool.account_id, fee_amount)); + assert_ok!(pool.liquidity_shares_manager.deposit_fees(fee_amount)); + Pools::::insert(market_id, pool); + + #[extrinsic_call] + _(RawOrigin::Signed(alice), market_id); + } + + #[benchmark] + fn deploy_pool() { + let alice: T::AccountId = whitelisted_caller(); + let base_asset = Asset::Ztg; + let market_id = create_market::(alice.clone(), base_asset, 2); + let amount = _10.saturated_into(); + let total_cost = amount + T::MultiCurrency::minimum_balance(base_asset); + + assert_ok!(T::MultiCurrency::deposit(base_asset, &alice, total_cost)); + assert_ok_with_transaction!(T::CompleteSetOperations::buy_complete_set( + alice.clone(), + market_id, + amount + )); + + #[extrinsic_call] + _( + RawOrigin::Signed(alice), + market_id, + amount, + vec![_1_2.saturated_into(), _1_2.saturated_into()], + CENT.saturated_into(), + ); + } + + impl_benchmark_test_suite!( + NeoSwaps, + crate::mock::ExtBuilder::default().build(), + crate::mock::Runtime + ); +} diff --git a/zrml/neo-swaps/src/consts.rs b/zrml/neo-swaps/src/consts.rs new file mode 100644 index 000000000..cab971b4b --- /dev/null +++ b/zrml/neo-swaps/src/consts.rs @@ -0,0 +1,47 @@ +// 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 zeitgeist_primitives::constants::BASE; + +pub(crate) const EXP_NUMERICAL_LIMIT: u128 = 20; // Numerical limit for exp arguments. +pub(crate) const MAX_ASSETS: u16 = 128; + +pub(crate) const _1: u128 = BASE; +pub(crate) const _2: u128 = 2 * _1; +pub(crate) const _3: u128 = 3 * _1; +pub(crate) const _4: u128 = 4 * _1; +pub(crate) const _5: u128 = 5 * _1; +pub(crate) const _9: u128 = 9 * _1; +pub(crate) const _10: u128 = 10 * _1; +pub(crate) const _20: u128 = 20 * _1; +pub(crate) const _70: u128 = 70 * _1; +pub(crate) const _80: u128 = 80 * _1; +pub(crate) const _100: u128 = 100 * _1; +pub(crate) const _101: u128 = 101 * _1; + +pub(crate) const _1_2: u128 = _1 / 2; + +pub(crate) const _1_3: u128 = _1 / 3; +pub(crate) const _2_3: u128 = _2 / 3; + +pub(crate) const _1_4: u128 = _1 / 4; +pub(crate) const _3_4: u128 = _3 / 4; + +pub(crate) const _1_5: u128 = _1 / 5; + +pub(crate) const _1_6: u128 = _1 / 6; +pub(crate) const _5_6: u128 = _5 / 6; diff --git a/zrml/neo-swaps/src/lib.rs b/zrml/neo-swaps/src/lib.rs new file mode 100644 index 000000000..a2bf01d6a --- /dev/null +++ b/zrml/neo-swaps/src/lib.rs @@ -0,0 +1,878 @@ +// 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)] + +extern crate alloc; + +mod benchmarking; +mod consts; +mod math; +mod mock; +mod tests; +pub mod traits; +pub mod types; +pub mod weights; + +pub use pallet::*; + +#[frame_support::pallet] +mod pallet { + use crate::{ + consts::MAX_ASSETS, + math::{Math, MathOps}, + traits::{pool_operations::PoolOperations, DistributeFees, LiquiditySharesManager}, + types::{FeeDistribution, Pool, SoloLp}, + weights::*, + }; + use alloc::{collections::BTreeMap, vec, vec::Vec}; + use core::marker::PhantomData; + use frame_support::{ + dispatch::DispatchResultWithPostInfo, + ensure, + pallet_prelude::StorageMap, + require_transactional, + traits::{Get, IsType, StorageVersion}, + transactional, PalletId, Twox64Concat, + }; + use frame_system::{ensure_signed, pallet_prelude::OriginFor}; + use orml_traits::MultiCurrency; + use sp_runtime::{ + traits::{AccountIdConversion, CheckedSub, Saturating, Zero}, + DispatchError, DispatchResult, SaturatedConversion, + }; + use zeitgeist_primitives::{ + constants::{BASE, CENT}, + math::fixed::{bdiv, bmul}, + traits::{CompleteSetOperationsApi, DeployPoolApi}, + types::{Asset, MarketStatus, MarketType, ScalarPosition, ScoringRule}, + }; + use zrml_market_commons::MarketCommonsPalletApi; + + // These should not be config parameters to avoid misconfigurations. + pub(crate) const MIN_SWAP_FEE: u128 = BASE / 1_000; // 0.1%. + pub(crate) const STORAGE_VERSION: StorageVersion = StorageVersion::new(0); + pub(crate) const MAX_SPOT_PRICE: u128 = BASE - CENT / 2; + pub(crate) const MIN_SPOT_PRICE: u128 = CENT / 2; + pub(crate) const MIN_LIQUIDITY: u128 = BASE; + + pub(crate) type AccountIdOf = ::AccountId; + pub(crate) type AssetOf = Asset>; + pub(crate) type BalanceOf = + <::MultiCurrency as MultiCurrency>>::Balance; + pub(crate) type AssetIndexType = u16; + pub(crate) type MarketIdOf = + <::MarketCommons as MarketCommonsPalletApi>::MarketId; + pub(crate) type PoolOf = Pool>; + + #[pallet::config] + pub trait Config: frame_system::Config { + type CompleteSetOperations: CompleteSetOperationsApi< + AccountId = Self::AccountId, + Balance = BalanceOf, + MarketId = MarketIdOf, + >; + + /// Distribute external fees. The fees are paid from the pool account, which in turn has + /// received the fees from the trader. + type ExternalFees: DistributeFees< + Asset = AssetOf, + AccountId = AccountIdOf, + Balance = BalanceOf, + MarketId = MarketIdOf, + >; + + type MarketCommons: MarketCommonsPalletApi; + + type MultiCurrency: MultiCurrency>; + + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + + type WeightInfo: WeightInfoZeitgeist; + + #[pallet::constant] + type MaxSwapFee: Get>; + + #[pallet::constant] + type PalletId: Get; + } + + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] + #[pallet::generate_store(pub(super) trait Store)] + pub struct Pallet(PhantomData); + + #[pallet::storage] + #[pallet::getter(fn pools)] + pub type Pools = StorageMap<_, Twox64Concat, MarketIdOf, PoolOf>; + + #[pallet::event] + #[pallet::generate_deposit(fn deposit_event)] + pub enum Event + where + T: Config, + { + /// Informant bought a position. + BuyExecuted { + who: T::AccountId, + market_id: MarketIdOf, + asset_out: AssetOf, + amount_in: BalanceOf, + amount_out: BalanceOf, + swap_fee_amount: BalanceOf, + external_fee_amount: BalanceOf, + }, + /// Informant sold a position. + SellExecuted { + who: T::AccountId, + market_id: MarketIdOf, + asset_in: AssetOf, + amount_in: BalanceOf, + amount_out: BalanceOf, + swap_fee_amount: BalanceOf, + external_fee_amount: BalanceOf, + }, + /// Liquidity provider withdrew fees. + FeesWithdrawn { who: T::AccountId, market_id: MarketIdOf, amount: BalanceOf }, + /// Liquidity provider joined the pool. + JoinExecuted { + who: T::AccountId, + market_id: MarketIdOf, + pool_shares_amount: BalanceOf, + amounts_in: Vec>, + new_liquidity_parameter: BalanceOf, + }, + /// Liquidity provider left the pool. + ExitExecuted { + who: T::AccountId, + market_id: MarketIdOf, + pool_shares_amount: BalanceOf, + amounts_out: Vec>, + new_liquidity_parameter: BalanceOf, + }, + /// Pool was createed. + PoolDeployed { + who: T::AccountId, + market_id: MarketIdOf, + pool_shares_amount: BalanceOf, + amounts_in: Vec>, + liquidity_parameter: BalanceOf, + }, + /// Pool was destroyed. + PoolDestroyed { + who: T::AccountId, + market_id: MarketIdOf, + pool_shares_amount: BalanceOf, + amounts_out: Vec>, + }, + } + + #[pallet::error] + pub enum Error { + /// The number of assets in the pool is above the allowed maximum. + AssetCountAboveMax, + /// Amount paid is above the specified maximum. + AmountInAboveMax, + /// Amount received is below the specified minimum. + AmountOutBelowMin, + /// Specified asset was not found in this pool. + AssetNotFound, + /// Market already has an associated pool. + DuplicatePool, + /// Incorrect asset count. + IncorrectAssetCount, + // Length of `max_amounts_in`, `max_amounts_out` or `spot_prices` must be equal to the + // number of outcomes in the market. + IncorrectVecLen, + /// User doesn't own enough pool shares. + InsufficientPoolShares, + /// The liquidity in the pool is too low. + LiquidityTooLow, + /// Sum of spot prices is not `1`. + InvalidSpotPrices, + /// Market's trading mechanism is not LMSR. + InvalidTradingMechanism, + /// Pool can only be traded on if the market is active. + MarketNotActive, + /// Deploying pools is only supported for scalar or binary markets. + MarketNotBinaryOrScalar, + /// Some calculation failed. This shouldn't happen. + MathError, + /// The user is not allowed to execute this command. + NotAllowed, + /// This feature is not yet implemented. + NotImplemented, + /// Some value in the operation is too large or small. + NumericalLimits, + /// Outstanding fees prevent liquidity withdrawal. + OutstandingFees, + /// Specified market does not have a pool. + PoolNotFound, + /// Spot price is above the allowed maximum. + SpotPriceAboveMax, + /// Spot price is below the allowed minimum. + SpotPriceBelowMin, + /// Pool's swap fee exceeds the allowed upper limit. + SwapFeeAboveMax, + /// Pool's swap fee is below the allowed lower limit. + SwapFeeBelowMin, + /// This shouldn't happen. + Unexpected, + /// Specified monetary amount is zero. + ZeroAmount, + } + + #[pallet::call] + impl Pallet { + /// Buy outcome tokens from the specified market. + /// + /// The `amount_in` is paid in collateral. The transaction fails if the amount of outcome + /// tokens received is smaller than `min_amount_out`. The user must correctly specify the + /// number of outcomes for benchmarking reasons. + /// + /// # Parameters + /// + /// - `origin`: The origin account making the purchase. + /// - `market_id`: Identifier for the market related to the trade. + /// - `asset_count`: Number of assets in the pool. + /// - `asset_out`: Asset to be purchased. + /// - `amount_in`: Amount of collateral paid by the user. + /// - `min_amount_out`: Minimum number of outcome tokens the user expects to receive. + /// + /// # Complexity + /// + /// Depends on the implementation of `CompleteSetOperationsApi` and `ExternalFees`; when + /// using the canonical implementations, the runtime complexity is `O(asset_count)`. + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::buy())] + #[transactional] + pub fn buy( + origin: OriginFor, + #[pallet::compact] market_id: MarketIdOf, + asset_count: AssetIndexType, + asset_out: AssetOf, + #[pallet::compact] amount_in: BalanceOf, + #[pallet::compact] min_amount_out: BalanceOf, + ) -> DispatchResultWithPostInfo { + let who = ensure_signed(origin)?; + let asset_count_real = T::MarketCommons::market(&market_id)?.outcomes(); + ensure!(asset_count == asset_count_real, Error::::IncorrectAssetCount); + Self::do_buy(who, market_id, asset_out, amount_in, min_amount_out)?; + Ok(Some(T::WeightInfo::buy()).into()) + } + + /// Sell outcome tokens to the specified market. + /// + /// The `amount_in` is paid in outcome tokens. The transaction fails if the amount of outcome + /// tokens received is smaller than `min_amount_out`. The user must correctly specify the + /// number of outcomes for benchmarking reasons. + /// + /// # Parameters + /// + /// - `origin`: The origin account making the sale. + /// - `market_id`: Identifier for the market related to the trade. + /// - `asset_count`: Number of assets in the pool. + /// - `asset_in`: Asset to be sold. + /// - `amount_in`: Amount of outcome tokens paid by the user. + /// - `min_amount_out`: Minimum amount of collateral the user expects to receive. + /// + /// # Complexity + /// + /// Depends on the implementation of `CompleteSetOperationsApi` and `ExternalFees`; when + /// using the canonical implementations, the runtime complexity is `O(asset_count)`. + #[pallet::call_index(1)] + #[pallet::weight(T::WeightInfo::sell())] + #[transactional] + pub fn sell( + origin: OriginFor, + #[pallet::compact] market_id: MarketIdOf, + asset_count: AssetIndexType, + asset_in: AssetOf, + #[pallet::compact] amount_in: BalanceOf, + #[pallet::compact] min_amount_out: BalanceOf, + ) -> DispatchResultWithPostInfo { + let who = ensure_signed(origin)?; + let asset_count_real = T::MarketCommons::market(&market_id)?.outcomes(); + ensure!(asset_count == asset_count_real, Error::::IncorrectAssetCount); + Self::do_sell(who, market_id, asset_in, amount_in, min_amount_out)?; + Ok(Some(T::WeightInfo::sell()).into()) + } + + /// Join the liquidity pool for the specified market. + /// + /// The LP receives pool shares in exchange for staking outcome tokens into the pool. The + /// `max_amounts_in` vector specifies the maximum number of each outcome token that the LP is + /// willing to deposit. These amounts are used to adjust the outcome balances in the pool + /// according to the new proportion of pool shares owned by the LP. + /// + /// Note that the user must acquire the outcome tokens in a separate transaction, either by + /// buying from the pool or by using complete set operations. + /// + /// # Parameters + /// + /// - `market_id`: Identifier for the market related to the pool. + /// - `pool_shares_amount`: The number of new pool shares the LP will receive. + /// - `max_amounts_in`: Vector of the maximum amounts of each outcome token the LP is + /// willing to deposit (with outcomes specified in the order of `MarketCommonsApi`). + /// + /// # Complexity + /// + /// `O(n)` where `n` is the number of assets in the pool. + #[pallet::call_index(2)] + #[pallet::weight(T::WeightInfo::join())] + #[transactional] + pub fn join( + origin: OriginFor, + #[pallet::compact] market_id: MarketIdOf, + #[pallet::compact] pool_shares_amount: BalanceOf, + max_amounts_in: Vec>, + ) -> DispatchResultWithPostInfo { + let who = ensure_signed(origin)?; + let asset_count = T::MarketCommons::market(&market_id)?.outcomes(); + ensure!(max_amounts_in.len() == asset_count as usize, Error::::IncorrectVecLen); + Self::do_join(who, market_id, pool_shares_amount, max_amounts_in)?; + Ok(Some(T::WeightInfo::join()).into()) + } + + /// Exit the liquidity pool for the specified market. + /// + /// The LP relinquishes pool shares in exchange for withdrawing outcome tokens from the + /// pool. The `min_amounts_out` vector specifies the minimum number of each outcome token + /// that the LP expects to withdraw. These minimum amounts are used to adjust the outcome + /// balances in the pool, taking into account the reduction in the LP's pool share + /// ownership. + /// + /// The transaction will fail unless the LP withdraws their fees from the pool beforehand. A + /// batch transaction is very useful here. + /// + /// If the LP withdraws all pool shares that exist, then the pool is afterwards destroyed. A + /// new pool can be deployed at any time, provided that the market is still open. + /// + /// The LP is not allowed to leave a positive but small amount liquidity in the pool. If the + /// liquidity parameter drops below a certain threshold, the transaction will fail. The only + /// solution is to withdraw _all_ liquidity and let the pool die. + /// + /// # Parameters + /// + /// - `market_id`: Identifier for the market related to the pool. + /// - `pool_shares_amount_out`: The number of pool shares the LP will relinquish. + /// - `min_amounts_out`: Vector of the minimum amounts of each outcome token the LP expects + /// to withdraw (with outcomes specified in the order given by `MarketCommonsApi`). + /// + /// # Complexity + /// + /// `O(n)` where `n` is the number of assets in the pool. + #[pallet::call_index(3)] + #[pallet::weight(T::WeightInfo::exit())] + #[transactional] + pub fn exit( + origin: OriginFor, + #[pallet::compact] market_id: MarketIdOf, + #[pallet::compact] pool_shares_amount_out: BalanceOf, + min_amounts_out: Vec>, + ) -> DispatchResultWithPostInfo { + let who = ensure_signed(origin)?; + let asset_count = T::MarketCommons::market(&market_id)?.outcomes(); + ensure!(min_amounts_out.len() == asset_count as usize, Error::::IncorrectVecLen); + Self::do_exit(who, market_id, pool_shares_amount_out, min_amounts_out)?; + Ok(Some(T::WeightInfo::exit()).into()) + } + + /// Withdraw swap fees from the specified market. + /// + /// The transaction will fail if the caller is not a liquidity provider. Should always be + /// used before calling `exit`. + /// + /// # Parameters + /// + /// - `market_id`: Identifier for the market related to the pool. + /// + /// # Complexity + /// + /// `O(1)`. + #[pallet::call_index(4)] + #[pallet::weight(T::WeightInfo::withdraw_fees())] + #[transactional] + pub fn withdraw_fees( + origin: OriginFor, + #[pallet::compact] market_id: MarketIdOf, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + Self::do_withdraw_fees(who, market_id)?; + Ok(()) + } + + /// Deploy a pool for the specified market and provide liquidity. + /// + /// The sender specifies a vector of `spot_prices` for the market's outcomes in the order + /// given by the `MarketCommonsApi`. The transaction will fail if the spot prices don't add + /// up to exactly `BASE`. + /// + /// Depending on the values in the `spot_prices`, the transaction will transfer different + /// amounts of each outcome to the pool. The sender specifies a maximum `amount` of outcome + /// tokens to spend. + /// + /// Note that the sender must acquire the outcome tokens in a separate transaction by using + /// complete set operations. It's therefore convenient to batch this function together with + /// a `buy_complete_set` with `amount` as amount of complete sets to buy. + /// + /// Deploying the pool will cost the signer an additional fee to the tune of the + /// collateral's existential deposit. This fee is placed in the pool account and ensures + /// that swap fees can be stored in the pool account without triggering dusting or failed + /// transfers. + /// + /// The operation is currently limited to binary and scalar markets. + /// + /// # Complexity + /// + /// `O(n)` where `n` is the number of outcomes in the specified market. + #[pallet::call_index(5)] + #[pallet::weight(T::WeightInfo::deploy_pool())] + #[transactional] + pub fn deploy_pool( + origin: OriginFor, + #[pallet::compact] market_id: MarketIdOf, + #[pallet::compact] amount: BalanceOf, + spot_prices: Vec>, + #[pallet::compact] swap_fee: BalanceOf, + ) -> DispatchResultWithPostInfo { + let who = ensure_signed(origin)?; + let asset_count = T::MarketCommons::market(&market_id)?.outcomes() as u32; + ensure!(spot_prices.len() == asset_count as usize, Error::::IncorrectVecLen); + Self::do_deploy_pool(who, market_id, amount, spot_prices, swap_fee)?; + Ok(Some(T::WeightInfo::deploy_pool()).into()) + } + } + + impl Pallet { + #[require_transactional] + fn do_buy( + who: T::AccountId, + market_id: MarketIdOf, + asset_out: AssetOf, + amount_in: BalanceOf, + min_amount_out: BalanceOf, + ) -> DispatchResult { + ensure!(amount_in != Zero::zero(), Error::::ZeroAmount); + let market = T::MarketCommons::market(&market_id)?; + ensure!(market.status == MarketStatus::Active, Error::::MarketNotActive); + Self::try_mutate_pool(&market_id, |pool| { + ensure!(pool.contains(&asset_out), Error::::AssetNotFound); + // Defensive check (shouldn't ever happen)! + ensure!( + pool.calculate_spot_price(asset_out)? <= MAX_SPOT_PRICE.saturated_into(), + Error::::Unexpected + ); + ensure!(amount_in <= pool.calculate_max_amount_in(), Error::::NumericalLimits); + T::MultiCurrency::transfer(pool.collateral, &who, &pool.account_id, amount_in)?; + let FeeDistribution { + remaining: amount_in_minus_fees, + swap_fees: swap_fee_amount, + external_fees: external_fee_amount, + } = Self::distribute_fees(market_id, pool, amount_in)?; + let swap_amount_out = + pool.calculate_swap_amount_out_for_buy(asset_out, amount_in_minus_fees)?; + let amount_out = swap_amount_out.saturating_add(amount_in_minus_fees); + ensure!(amount_out >= min_amount_out, Error::::AmountOutBelowMin); + // Instead of letting `who` buy the complete sets and then transfer almost all of + // the outcomes to the pool account, we prevent `(n-1)` storage reads by using the + // pool account to buy. Note that the fees are already in the pool at this point. + T::CompleteSetOperations::buy_complete_set( + pool.account_id.clone(), + market_id, + amount_in_minus_fees, + )?; + T::MultiCurrency::transfer(asset_out, &pool.account_id, &who, amount_out)?; + for asset in pool.assets().iter() { + pool.increase_reserve(asset, &amount_in_minus_fees)?; + if *asset == asset_out { + pool.decrease_reserve(asset, &amount_out)?; + } + } + let new_price = pool.calculate_spot_price(asset_out)?; + ensure!( + new_price <= MAX_SPOT_PRICE.saturated_into(), + Error::::SpotPriceAboveMax + ); + Self::deposit_event(Event::::BuyExecuted { + who: who.clone(), + market_id, + asset_out, + amount_in, + amount_out, + swap_fee_amount, + external_fee_amount, + }); + Ok(()) + }) + } + + #[require_transactional] + fn do_sell( + who: T::AccountId, + market_id: MarketIdOf, + asset_in: AssetOf, + amount_in: BalanceOf, + min_amount_out: BalanceOf, + ) -> DispatchResult { + ensure!(amount_in != Zero::zero(), Error::::ZeroAmount); + let market = T::MarketCommons::market(&market_id)?; + ensure!(market.status == MarketStatus::Active, Error::::MarketNotActive); + Self::try_mutate_pool(&market_id, |pool| { + ensure!(pool.contains(&asset_in), Error::::AssetNotFound); + // Defensive check (shouldn't ever happen)! + ensure!( + pool.calculate_spot_price(asset_in)? >= MIN_SPOT_PRICE.saturated_into(), + Error::::Unexpected + ); + ensure!(amount_in <= pool.calculate_max_amount_in(), Error::::NumericalLimits); + // Instead of first executing a swap with `(n-1)` transfers from the pool account to + // `who` and then selling complete sets, we prevent `(n-1)` storage reads: 1) + // Transfer `amount_in` units of `asset_in` to the pool account, 2) sell + // `amount_out` complete sets using the pool account, 3) transfer + // `amount_out_minus_fees` units of collateral to `who`. The fees automatically end + // up in the pool. + let amount_out = pool.calculate_swap_amount_out_for_sell(asset_in, amount_in)?; + // Beware! This transfer happen _after_ calculating `amount_out`: + T::MultiCurrency::transfer(asset_in, &who, &pool.account_id, amount_in)?; + T::CompleteSetOperations::sell_complete_set( + pool.account_id.clone(), + market_id, + amount_out, + )?; + let FeeDistribution { + remaining: amount_out_minus_fees, + swap_fees: swap_fee_amount, + external_fees: external_fee_amount, + } = Self::distribute_fees(market_id, pool, amount_out)?; + ensure!(amount_out_minus_fees >= min_amount_out, Error::::AmountOutBelowMin); + T::MultiCurrency::transfer( + pool.collateral, + &pool.account_id, + &who, + amount_out_minus_fees, + )?; + for asset in pool.assets().iter() { + if *asset == asset_in { + pool.increase_reserve(asset, &amount_in)?; + } + pool.decrease_reserve(asset, &amount_out)?; + } + let new_price = pool.calculate_spot_price(asset_in)?; + ensure!( + new_price >= MIN_SPOT_PRICE.saturated_into(), + Error::::SpotPriceBelowMin + ); + Self::deposit_event(Event::::SellExecuted { + who: who.clone(), + market_id, + asset_in, + amount_in, + amount_out, + swap_fee_amount, + external_fee_amount, + }); + Ok(()) + }) + } + + #[require_transactional] + fn do_join( + who: T::AccountId, + market_id: MarketIdOf, + pool_shares_amount: BalanceOf, + max_amounts_in: Vec>, + ) -> DispatchResult { + ensure!(pool_shares_amount != Zero::zero(), Error::::ZeroAmount); + let market = T::MarketCommons::market(&market_id)?; + ensure!(market.status == MarketStatus::Active, Error::::MarketNotActive); + Self::try_mutate_pool(&market_id, |pool| { + // FIXME Round up to avoid exploits. + let ratio = bdiv( + pool_shares_amount.saturated_into(), + pool.liquidity_shares_manager.total_shares()?.saturated_into(), + )?; + let mut amounts_in = vec![]; + for (&asset, &max_amount_in) in pool.assets().iter().zip(max_amounts_in.iter()) { + let balance_in_pool = pool.reserve_of(&asset)?; + // FIXME Round up to avoid exploits. + let amount_in = bmul(ratio, balance_in_pool.saturated_into())?.saturated_into(); + amounts_in.push(amount_in); + ensure!(amount_in <= max_amount_in, Error::::AmountInAboveMax); + T::MultiCurrency::transfer(asset, &who, &pool.account_id, amount_in)?; + } + for ((_, balance), &amount_in) in pool.reserves.iter_mut().zip(amounts_in.iter()) { + *balance = balance.saturating_add(amount_in); + } + pool.liquidity_shares_manager.join(&who, pool_shares_amount)?; + let new_liquidity_parameter = pool.liquidity_parameter.saturating_add( + bmul(ratio.saturated_into(), pool.liquidity_parameter.saturated_into())? + .saturated_into(), + ); + pool.liquidity_parameter = new_liquidity_parameter; + Self::deposit_event(Event::::JoinExecuted { + who: who.clone(), + market_id, + pool_shares_amount, + amounts_in, + new_liquidity_parameter, + }); + Ok(()) + }) + } + + #[require_transactional] + fn do_exit( + who: T::AccountId, + market_id: MarketIdOf, + pool_shares_amount: BalanceOf, + min_amounts_out: Vec>, + ) -> DispatchResult { + ensure!(pool_shares_amount != Zero::zero(), Error::::ZeroAmount); + let _ = T::MarketCommons::market(&market_id)?; + Pools::::try_mutate_exists(market_id, |maybe_pool| { + let pool = + maybe_pool.as_mut().ok_or::(Error::::PoolNotFound.into())?; + ensure!( + pool.liquidity_shares_manager.fees == Zero::zero(), + Error::::OutstandingFees + ); + let ratio = bdiv( + pool_shares_amount.saturated_into(), + pool.liquidity_shares_manager.total_shares()?.saturated_into(), + )?; + let mut amounts_out = vec![]; + for (&asset, &min_amount_out) in pool.assets().iter().zip(min_amounts_out.iter()) { + let balance_in_pool = pool.reserve_of(&asset)?; + let amount_out: BalanceOf = + bmul(ratio, balance_in_pool.saturated_into())?.saturated_into(); + amounts_out.push(amount_out); + ensure!(amount_out >= min_amount_out, Error::::AmountOutBelowMin); + T::MultiCurrency::transfer(asset, &pool.account_id, &who, amount_out)?; + } + for ((_, balance), &amount_out) in pool.reserves.iter_mut().zip(amounts_out.iter()) + { + *balance = balance.saturating_sub(amount_out); + } + pool.liquidity_shares_manager.exit(&who, pool_shares_amount)?; + if pool.liquidity_shares_manager.total_shares()? == Zero::zero() { + // FIXME We will withdraw all remaining funds (the "buffer"). This is an ugly + // hack and system should offer the option to whitelist accounts. + let remaining = + T::MultiCurrency::free_balance(pool.collateral, &pool.account_id); + T::MultiCurrency::withdraw(pool.collateral, &pool.account_id, remaining)?; + *maybe_pool = None; // Delete the storage map entry. + Self::deposit_event(Event::::PoolDestroyed { + who: who.clone(), + market_id, + pool_shares_amount, + amounts_out, + }); + } else { + let liq = pool.liquidity_parameter; + let new_liquidity_parameter = liq.saturating_sub( + bmul(ratio.saturated_into(), liq.saturated_into())?.saturated_into(), + ); + ensure!( + new_liquidity_parameter >= MIN_LIQUIDITY.saturated_into(), + Error::::LiquidityTooLow + ); + pool.liquidity_parameter = new_liquidity_parameter; + Self::deposit_event(Event::::ExitExecuted { + who: who.clone(), + market_id, + pool_shares_amount, + amounts_out, + new_liquidity_parameter, + }); + } + Ok(()) + }) + } + + #[require_transactional] + fn do_withdraw_fees(who: T::AccountId, market_id: MarketIdOf) -> DispatchResult { + Self::try_mutate_pool(&market_id, |pool| { + let amount = pool.liquidity_shares_manager.withdraw_fees(&who)?; + T::MultiCurrency::transfer(pool.collateral, &pool.account_id, &who, amount)?; // Should never fail. + Self::deposit_event(Event::::FeesWithdrawn { + who: who.clone(), + market_id, + amount, + }); + Ok(()) + }) + } + + #[require_transactional] + fn do_deploy_pool( + who: T::AccountId, + market_id: MarketIdOf, + amount: BalanceOf, + spot_prices: Vec>, + swap_fee: BalanceOf, + ) -> DispatchResult { + ensure!(!Pools::::contains_key(market_id), Error::::DuplicatePool); + let market = T::MarketCommons::market(&market_id)?; + ensure!(market.creator == who, Error::::NotAllowed); + ensure!(market.status == MarketStatus::Active, Error::::MarketNotActive); + ensure!(market.scoring_rule == ScoringRule::Lmsr, Error::::InvalidTradingMechanism); + let asset_count = spot_prices.len(); + ensure!(asset_count as u16 == market.outcomes(), Error::::IncorrectVecLen); + ensure!(market.outcomes() == 2, Error::::MarketNotBinaryOrScalar); + ensure!(market.outcomes() <= MAX_ASSETS, Error::::AssetCountAboveMax); + ensure!(swap_fee >= MIN_SWAP_FEE.saturated_into(), Error::::SwapFeeBelowMin); + ensure!(swap_fee <= T::MaxSwapFee::get(), Error::::SwapFeeAboveMax); + ensure!( + spot_prices + .iter() + .fold(Zero::zero(), |acc: BalanceOf, &val| acc.saturating_add(val)) + == BASE.saturated_into(), + Error::::InvalidSpotPrices + ); + for &p in spot_prices.iter() { + ensure!( + p.saturated_into::() >= MIN_SPOT_PRICE, + Error::::SpotPriceBelowMin + ); + ensure!( + p.saturated_into::() <= MAX_SPOT_PRICE, + Error::::SpotPriceAboveMax + ); + } + let (liquidity_parameter, amounts_in) = + Math::::calculate_reserves_from_spot_prices(amount, spot_prices)?; + ensure!( + liquidity_parameter >= MIN_LIQUIDITY.saturated_into(), + Error::::LiquidityTooLow + ); + let pool_account_id = Self::pool_account_id(&market_id); + let assets = Self::outcomes(market_id)?; + let mut reserves = BTreeMap::new(); + for (&amount_in, &asset) in amounts_in.iter().zip(assets.iter()) { + T::MultiCurrency::transfer(asset, &who, &pool_account_id, amount_in)?; + let _ = reserves.insert(asset, amount_in); + } + let pool = Pool { + account_id: pool_account_id, + reserves, + collateral: market.base_asset, + liquidity_parameter, + liquidity_shares_manager: SoloLp::new(who.clone(), amount), + swap_fee, + }; + // FIXME Ensure that the existential deposit doesn't kill fees. This is an ugly hack and + // system should offer the option to whitelist accounts. + T::MultiCurrency::transfer( + pool.collateral, + &who, + &pool.account_id, + T::MultiCurrency::minimum_balance(pool.collateral), + )?; + Pools::::insert(market_id, pool); + Self::deposit_event(Event::::PoolDeployed { + who, + market_id, + pool_shares_amount: amount, + amounts_in, + liquidity_parameter, + }); + Ok(()) + } + + #[inline] + pub(crate) fn pool_account_id(market_id: &MarketIdOf) -> T::AccountId { + T::PalletId::get().into_sub_account_truncating((*market_id).saturated_into::()) + } + + /// Distribute swap fees and external fees and returns the remaining amount. + /// + /// # Arguments + /// + /// - `market_id`: The ID of the market to which the pool belongs. + /// - `pool`: The pool on which the trade was executed. + /// - `amount`: The gross amount from which the fee is deduced. + /// + /// Will fail if the total amount of fees is more than the gross amount. In particular, the + /// function will fail if the external fees exceed the gross amount. + #[require_transactional] + fn distribute_fees( + market_id: MarketIdOf, + pool: &mut PoolOf, + amount: BalanceOf, + ) -> Result, DispatchError> { + 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 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 }) + } + + // FIXME Carbon copy of a function in prediction-markets. To be removed later. + fn outcomes(market_id: MarketIdOf) -> Result>, DispatchError> { + let market = T::MarketCommons::market(&market_id)?; + Ok(match market.market_type { + MarketType::Categorical(categories) => { + let mut assets = Vec::new(); + for i in 0..categories { + assets.push(Asset::CategoricalOutcome(market_id, i)); + } + assets + } + MarketType::Scalar(_) => { + vec![ + Asset::ScalarOutcome(market_id, ScalarPosition::Long), + Asset::ScalarOutcome(market_id, ScalarPosition::Short), + ] + } + }) + } + + fn try_mutate_pool(market_id: &MarketIdOf, mutator: F) -> DispatchResult + where + F: FnMut(&mut PoolOf) -> DispatchResult, + { + Pools::::try_mutate(market_id, |maybe_pool| { + maybe_pool.as_mut().ok_or(Error::::PoolNotFound.into()).and_then(mutator) + }) + } + } + + impl DeployPoolApi for Pallet { + type AccountId = T::AccountId; + type Balance = BalanceOf; + type MarketId = MarketIdOf; + + fn deploy_pool( + who: Self::AccountId, + market_id: Self::MarketId, + amount: Self::Balance, + spot_prices: Vec, + swap_fee: Self::Balance, + ) -> DispatchResult { + Self::do_deploy_pool(who, market_id, amount, spot_prices, swap_fee) + } + } +} diff --git a/zrml/neo-swaps/src/math.rs b/zrml/neo-swaps/src/math.rs new file mode 100644 index 000000000..f0978deb6 --- /dev/null +++ b/zrml/neo-swaps/src/math.rs @@ -0,0 +1,282 @@ +// 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::{BalanceOf, Config, Error}; +use alloc::vec::Vec; +use core::marker::PhantomData; +use fixed::FixedU128; +use hydra_dx_math::transcendental::{exp, ln}; +use sp_runtime::{DispatchError, SaturatedConversion}; +use typenum::U80; + +type Fractional = U80; +type Fixed = FixedU128; + +pub(crate) trait MathOps { + fn calculate_swap_amount_out_for_buy( + reserve: BalanceOf, + amount_in: BalanceOf, + liquidity: BalanceOf, + ) -> Result, DispatchError>; + fn calculate_swap_amount_out_for_sell( + reserve: BalanceOf, + amount_in: BalanceOf, + liquidity: BalanceOf, + ) -> Result, DispatchError>; + fn calculate_spot_price( + reserve: BalanceOf, + liquidity: BalanceOf, + ) -> Result, DispatchError>; + fn calculate_reserves_from_spot_prices( + amount: BalanceOf, + spot_prices: Vec>, + ) -> Result<(BalanceOf, Vec>), DispatchError>; +} + +pub(crate) struct Math(PhantomData); + +impl MathOps for Math { + fn calculate_swap_amount_out_for_buy( + reserve: BalanceOf, + amount_in: BalanceOf, + liquidity: BalanceOf, + ) -> Result, DispatchError> { + let reserve = reserve.saturated_into(); + let amount_in = amount_in.saturated_into(); + let liquidity = liquidity.saturated_into(); + detail::calculate_swap_amount_out_for_buy(reserve, amount_in, liquidity) + .map(|result| result.saturated_into()) + .ok_or_else(|| Error::::MathError.into()) + } + + fn calculate_swap_amount_out_for_sell( + reserve: BalanceOf, + amount_in: BalanceOf, + liquidity: BalanceOf, + ) -> Result, DispatchError> { + let reserve = reserve.saturated_into(); + let amount_in = amount_in.saturated_into(); + let liquidity = liquidity.saturated_into(); + detail::calculate_swap_amount_out_for_sell(reserve, amount_in, liquidity) + .map(|result| result.saturated_into()) + .ok_or_else(|| Error::::MathError.into()) + } + + fn calculate_spot_price( + reserve: BalanceOf, + liquidity: BalanceOf, + ) -> Result, DispatchError> { + let reserve = reserve.saturated_into(); + let liquidity = liquidity.saturated_into(); + detail::calculate_spot_price(reserve, liquidity) + .map(|result| result.saturated_into()) + .ok_or_else(|| Error::::MathError.into()) + } + + fn calculate_reserves_from_spot_prices( + amount: BalanceOf, + spot_prices: Vec>, + ) -> Result<(BalanceOf, Vec>), DispatchError> { + let amount = amount.saturated_into(); + let spot_prices = spot_prices.into_iter().map(|p| p.saturated_into()).collect(); + let (liquidity, spot_prices) = + detail::calculate_reserves_from_spot_prices(amount, spot_prices) + .ok_or_else(|| -> DispatchError { Error::::MathError.into() })?; + let liquidity = liquidity.saturated_into(); + let spot_prices = spot_prices.into_iter().map(|p| p.saturated_into()).collect(); + Ok((liquidity, spot_prices)) + } +} + +mod detail { + use super::*; + use zeitgeist_primitives::{ + constants::DECIMALS, + math::fixed::{IntoFixedDecimal, IntoFixedFromDecimal}, + }; + + /// Calculate b * ln( e^(x/b) − 1 + e^(−r_i/b) ) + r_i − x + pub(super) fn calculate_swap_amount_out_for_buy( + reserve: u128, + amount_in: u128, + liquidity: u128, + ) -> Option { + let result_fixed = calculate_swap_amount_out_for_buy_fixed( + to_fixed(reserve)?, + to_fixed(amount_in)?, + to_fixed(liquidity)?, + )?; + from_fixed(result_fixed) + } + + /// Calculate –1 * b * ln( e^(-x/b) − 1 + e^(r_i/b) ) + r_i + pub(super) fn calculate_swap_amount_out_for_sell( + reserve: u128, + amount_in: u128, + liquidity: u128, + ) -> Option { + let result_fixed = calculate_swap_amount_out_for_sell_fixed( + to_fixed(reserve)?, + to_fixed(amount_in)?, + to_fixed(liquidity)?, + )?; + from_fixed(result_fixed) + } + + /// Calculate e^(-r_i/b). + pub(super) fn calculate_spot_price(reserve: u128, liquidity: u128) -> Option { + let result_fixed = calculate_spot_price_fixed(to_fixed(reserve)?, to_fixed(liquidity)?)?; + from_fixed(result_fixed) + } + + pub(super) fn calculate_reserves_from_spot_prices( + amount: u128, + spot_prices: Vec, + ) -> Option<(u128, Vec)> { + let (liquidity_fixed, reserve_fixed) = calculate_reserve_from_spot_prices_fixed( + to_fixed(amount)?, + spot_prices.into_iter().map(to_fixed).collect::>>()?, + )?; + let liquidity = from_fixed(liquidity_fixed)?; + let reserve = reserve_fixed.into_iter().map(from_fixed).collect::>>()?; + Some((liquidity, reserve)) + } + + fn to_fixed(value: B) -> Option + where + B: Into + From, + { + value.to_fixed_from_fixed_decimal(DECIMALS).ok() + } + + fn from_fixed(value: Fixed) -> Option + where + B: Into + From, + { + value.to_fixed_decimal(DECIMALS).ok() + } + + fn calculate_swap_amount_out_for_buy_fixed( + reserve: Fixed, + amount_in: Fixed, + liquidity: Fixed, + ) -> Option { + // FIXME Defensive programming: Check for underflow in x/b and r_i/b. + let exp_x_over_b: Fixed = exp(amount_in.checked_div(liquidity)?, false).ok()?; + let exp_neg_r_over_b = exp(reserve.checked_div(liquidity)?, true).ok()?; + // FIXME Defensive programming: Check for underflow in the exponential expressions. + let inside_ln = + exp_x_over_b.checked_add(exp_neg_r_over_b)?.checked_sub(Fixed::checked_from_num(1)?)?; + let (ln_result, ln_neg) = ln(inside_ln).ok()?; + let blob = liquidity.checked_mul(ln_result)?; + let reserve_plus_blob = + if ln_neg { reserve.checked_sub(blob)? } else { reserve.checked_add(blob)? }; + reserve_plus_blob.checked_sub(amount_in) + } + + fn calculate_swap_amount_out_for_sell_fixed( + reserve: Fixed, + amount_in: Fixed, + liquidity: Fixed, + ) -> Option { + // FIXME Defensive programming: Check for underflow in x/b and r_i/b. + let exp_neg_x_over_b: Fixed = exp(amount_in.checked_div(liquidity)?, true).ok()?; + let exp_r_over_b = exp(reserve.checked_div(liquidity)?, false).ok()?; + // FIXME Defensive programming: Check for underflow in the exponential expressions. + let inside_ln = + exp_neg_x_over_b.checked_add(exp_r_over_b)?.checked_sub(Fixed::checked_from_num(1)?)?; + let (ln_result, ln_neg) = ln(inside_ln).ok()?; + let blob = liquidity.checked_mul(ln_result)?; + if ln_neg { reserve.checked_add(blob) } else { reserve.checked_sub(blob) } + } + + pub(crate) fn calculate_spot_price_fixed(reserve: Fixed, liquidity: Fixed) -> Option { + exp(reserve.checked_div(liquidity)?, true).ok() + } + + fn calculate_reserve_from_spot_prices_fixed( + amount: Fixed, + spot_prices: Vec, + ) -> Option<(Fixed, Vec)> { + // FIXME Defensive programming - ensure against underflows + let tmp_reserves = spot_prices + .iter() + // Drop the bool (second tuple component) as ln(p) is always negative. + .map(|&price| ln(price).map(|(value, _)| value)) + .collect::, _>>() + .ok()?; + let max_value = *tmp_reserves.iter().max()?; + let liquidity = amount.checked_div(max_value)?; + let reserves: Vec = + tmp_reserves.iter().map(|&r| r.checked_mul(liquidity)).collect::>>()?; + Some((liquidity, reserves)) + } + + #[cfg(test)] + mod tests { + use super::*; + use crate::{assert_approx, consts::*}; + use std::str::FromStr; + use test_case::test_case; + + // Example taken from + // https://docs.gnosis.io/conditionaltokens/docs/introduction3/#an-example-with-lmsr + #[test] + fn calculate_swap_amount_out_for_buy_works() { + let liquidity = 144269504088; + assert_eq!( + calculate_swap_amount_out_for_buy(_10, _10, liquidity).unwrap(), + 58496250072 + ); + } + + #[test] + fn calculate_swap_amount_out_for_sell_works() { + let liquidity = 144269504088; + assert_eq!( + calculate_swap_amount_out_for_sell(_10, _10, liquidity).unwrap(), + 41503749928 + ); + } + + #[test] + fn calcuate_spot_price_works() { + let liquidity = 144269504088; + assert_eq!(calculate_spot_price(_10, liquidity).unwrap(), _1_2); + assert_eq!(calculate_spot_price(_10 - 58496250072, liquidity).unwrap(), _3_4); + assert_eq!(calculate_spot_price(_20, liquidity).unwrap(), _1_4); + } + + #[test] + fn calculate_reserves_from_spot_prices_works() { + let expected_liquidity = 144269504088; + let (liquidity, reserves) = + calculate_reserves_from_spot_prices(_10, vec![_1_2, _1_2]).unwrap(); + assert_approx!(liquidity, expected_liquidity, 1); + assert_eq!(reserves, vec![_10, _10]); + } + + // This test ensures that we don't mess anything up when we change precision. + #[test_case(false, Fixed::from_str("10686474581524.462146990468650739308072").unwrap())] + #[test_case(true, Fixed::from_str("0.000000000000093576229688").unwrap())] + fn exp_does_not_overflow_or_underflow(neg: bool, expected: Fixed) { + let value = 30; + let result: Fixed = exp(Fixed::checked_from_num(value).unwrap(), neg).unwrap(); + assert_eq!(result, expected); + } + } +} diff --git a/zrml/neo-swaps/src/mock.rs b/zrml/neo-swaps/src/mock.rs new file mode 100644 index 000000000..5939c574e --- /dev/null +++ b/zrml/neo-swaps/src/mock.rs @@ -0,0 +1,506 @@ +// Copyright 2022-2023 Forecasting Technologies LTD. +// Copyright 2021-2022 Zeitgeist PM LLC. +// +// This file is part of Zeitgeist. +// +// Zeitgeist is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at +// your option) any later version. +// +// Zeitgeist is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +#![cfg(feature = "mock")] +#![allow( + // Mocks are only used for fuzzing and unit tests + clippy::arithmetic_side_effects, + clippy::too_many_arguments, +)] + +use crate as zrml_neo_swaps; +use crate::{consts::*, AssetOf, MarketIdOf}; +use core::marker::PhantomData; +use frame_support::{ + construct_runtime, ord_parameter_types, parameter_types, + traits::{Contains, Everything, NeverEnsureOrigin}, +}; +use frame_system::{EnsureRoot, EnsureSignedBy}; +#[cfg(feature = "parachain")] +use orml_asset_registry::AssetMetadata; +use orml_traits::MultiCurrency; +use sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, Get, IdentityLookup}, + DispatchResult, Percent, SaturatedConversion, +}; +use substrate_fixed::{types::extra::U33, FixedI128, FixedU128}; +#[cfg(feature = "parachain")] +use zeitgeist_primitives::types::Asset; +use zeitgeist_primitives::{ + constants::mock::{ + AddOutcomePeriod, AggregationPeriod, AppealBond, AppealPeriod, AuthorizedPalletId, + BalanceFractionalDecimals, BlockHashCount, BlocksPerYear, CorrectionPeriod, CourtPalletId, + ExistentialDeposit, ExistentialDeposits, ExitFee, GdVotingPeriod, GetNativeCurrencyId, + GlobalDisputeLockId, GlobalDisputesPalletId, InflationPeriod, LiquidityMiningPalletId, + LockId, MaxAppeals, MaxApprovals, MaxAssets, MaxCourtParticipants, MaxCreatorFee, + MaxDelegations, MaxDisputeDuration, MaxDisputes, MaxEditReasonLen, MaxGlobalDisputeVotes, + MaxGracePeriod, MaxInRatio, MaxLocks, MaxMarketLifetime, MaxOracleDuration, MaxOutRatio, + MaxOwners, MaxRejectReasonLen, MaxReserves, MaxSelectedDraws, MaxSubsidyPeriod, MaxSwapFee, + MaxTotalWeight, MaxWeight, MinAssets, MinCategories, MinDisputeDuration, MinJurorStake, + MinOracleDuration, MinOutcomeVoteAmount, MinSubsidy, MinSubsidyPeriod, MinWeight, + MinimumPeriod, NeoMaxSwapFee, NeoSwapsPalletId, OutcomeBond, OutcomeFactor, OutsiderBond, + PmPalletId, RemoveKeysLimit, RequestInterval, SimpleDisputesPalletId, SwapsPalletId, + TreasuryPalletId, VotePeriod, VotingOutcomeFee, BASE, CENT, + }, + traits::DeployPoolApi, + types::{ + AccountIdTest, Amount, Balance, BasicCurrencyAdapter, BlockNumber, BlockTest, CurrencyId, + Hash, Index, MarketId, Moment, PoolId, UncheckedExtrinsicTest, + }, +}; +use zrml_neo_swaps::{traits::DistributeFees, BalanceOf}; +use zrml_rikiddo::types::{EmaMarketVolume, FeeSigmoid, RikiddoSigmoidMV}; + +pub const ALICE: AccountIdTest = 0; +#[allow(unused)] +pub const BOB: AccountIdTest = 1; +pub const CHARLIE: AccountIdTest = 2; +pub const DAVE: AccountIdTest = 3; +pub const EVE: AccountIdTest = 4; +pub const FEE_ACCOUNT: AccountIdTest = 5; +pub const SUDO: AccountIdTest = 123456; +pub const EXTERNAL_FEES: Balance = CENT; + +#[cfg(feature = "parachain")] +pub const FOREIGN_ASSET: Asset = Asset::ForeignAsset(1); + +parameter_types! { + pub const FeeAccount: AccountIdTest = FEE_ACCOUNT; +} +ord_parameter_types! { + pub const AuthorizedDisputeResolutionUser: AccountIdTest = ALICE; +} +ord_parameter_types! { + pub const Sudo: AccountIdTest = SUDO; +} +parameter_types! { + pub storage NeoMinSwapFee: Balance = 0; +} +parameter_types! { + pub const MinSubsidyPerAccount: Balance = BASE; + pub const AdvisoryBond: Balance = 0; + pub const AdvisoryBondSlashPercentage: Percent = Percent::from_percent(10); + pub const OracleBond: Balance = 0; + pub const ValidityBond: Balance = 0; + pub const DisputeBond: Balance = 0; + pub const MaxCategories: u16 = MAX_ASSETS + 1; +} + +pub struct DeployPoolNoop; + +impl DeployPoolApi for DeployPoolNoop { + type AccountId = AccountIdTest; + type Balance = Balance; + type MarketId = MarketId; + + fn deploy_pool( + _who: Self::AccountId, + _market_id: Self::MarketId, + _amount: Self::Balance, + _swap_prices: Vec, + _swap_fee: Self::Balance, + ) -> DispatchResult { + Ok(()) + } +} + +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 = zeitgeist_primitives::math::fixed::bmul( + amount.saturated_into(), + EXTERNAL_FEES.saturated_into(), + ) + .unwrap() + .saturated_into(); + let _ = T::MultiCurrency::transfer(asset, &account, &F::get(), fees); + fees + } +} + +pub type UncheckedExtrinsic = UncheckedExtrinsicTest; + +pub struct DustRemovalWhitelist; + +impl Contains for DustRemovalWhitelist { + fn contains(account_id: &AccountIdTest) -> bool { + *account_id == FEE_ACCOUNT + } +} + +construct_runtime!( + pub enum Runtime + where + Block = BlockTest, + NodeBlock = BlockTest, + UncheckedExtrinsic = UncheckedExtrinsic, + { + NeoSwaps: zrml_neo_swaps::{Call, Event, Pallet}, + Authorized: zrml_authorized::{Event, Pallet, Storage}, + Balances: pallet_balances::{Call, Config, Event, Pallet, Storage}, + Court: zrml_court::{Event, Pallet, Storage}, + AssetManager: orml_currencies::{Call, Pallet, Storage}, + LiquidityMining: zrml_liquidity_mining::{Config, Event, Pallet}, + MarketCommons: zrml_market_commons::{Pallet, Storage}, + PredictionMarkets: zrml_prediction_markets::{Event, Pallet, Storage}, + RandomnessCollectiveFlip: pallet_randomness_collective_flip::{Pallet, Storage}, + RikiddoSigmoidFeeMarketEma: zrml_rikiddo::{Pallet, Storage}, + SimpleDisputes: zrml_simple_disputes::{Event, Pallet, Storage}, + GlobalDisputes: zrml_global_disputes::{Event, Pallet, Storage}, + Swaps: zrml_swaps::{Call, Event, Pallet}, + System: frame_system::{Call, Config, Event, Pallet, Storage}, + Timestamp: pallet_timestamp::{Pallet}, + Tokens: orml_tokens::{Config, Event, Pallet, Storage}, + Treasury: pallet_treasury::{Call, Event, Pallet, Storage}, + } +); + +impl crate::Config for Runtime { + type MultiCurrency = AssetManager; + type CompleteSetOperations = PredictionMarkets; + type ExternalFees = ExternalFees; + type MarketCommons = MarketCommons; + type RuntimeEvent = RuntimeEvent; + type MaxSwapFee = NeoMaxSwapFee; + type PalletId = NeoSwapsPalletId; + type WeightInfo = zrml_neo_swaps::weights::WeightInfo; +} + +impl pallet_randomness_collective_flip::Config for Runtime {} + +impl zrml_rikiddo::Config for Runtime { + type Timestamp = Timestamp; + type Balance = Balance; + type FixedTypeU = FixedU128; + type FixedTypeS = FixedI128; + type BalanceFractionalDecimals = BalanceFractionalDecimals; + type PoolId = PoolId; + type Rikiddo = RikiddoSigmoidMV< + Self::FixedTypeU, + Self::FixedTypeS, + FeeSigmoid, + EmaMarketVolume, + >; +} + +impl zrml_prediction_markets::Config for Runtime { + type AdvisoryBond = AdvisoryBond; + type AdvisoryBondSlashPercentage = AdvisoryBondSlashPercentage; + type ApproveOrigin = EnsureSignedBy; + #[cfg(feature = "parachain")] + type AssetRegistry = MockRegistry; + type Authorized = Authorized; + type CloseOrigin = EnsureSignedBy; + type Court = Court; + type DeployPool = DeployPoolNoop; + type DestroyOrigin = EnsureSignedBy; + type DisputeBond = DisputeBond; + type RuntimeEvent = RuntimeEvent; + type GlobalDisputes = GlobalDisputes; + type LiquidityMining = LiquidityMining; + type MaxCategories = MaxCategories; + type MaxDisputes = MaxDisputes; + type MinDisputeDuration = MinDisputeDuration; + type MinOracleDuration = MinOracleDuration; + type MaxCreatorFee = MaxCreatorFee; + type MaxDisputeDuration = MaxDisputeDuration; + type MaxGracePeriod = MaxGracePeriod; + type MaxOracleDuration = MaxOracleDuration; + type MaxSubsidyPeriod = MaxSubsidyPeriod; + type MaxMarketLifetime = MaxMarketLifetime; + type MinCategories = MinCategories; + type MinSubsidyPeriod = MinSubsidyPeriod; + type MaxEditReasonLen = MaxEditReasonLen; + type MaxRejectReasonLen = MaxRejectReasonLen; + type OracleBond = OracleBond; + type OutsiderBond = OutsiderBond; + type PalletId = PmPalletId; + type RejectOrigin = EnsureSignedBy; + type RequestEditOrigin = EnsureSignedBy; + type ResolveOrigin = EnsureSignedBy; + type AssetManager = AssetManager; + type SimpleDisputes = SimpleDisputes; + type Slash = Treasury; + type Swaps = Swaps; + type ValidityBond = ValidityBond; + type WeightInfo = zrml_prediction_markets::weights::WeightInfo; +} + +impl zrml_authorized::Config for Runtime { + type AuthorizedDisputeResolutionOrigin = + EnsureSignedBy; + type CorrectionPeriod = CorrectionPeriod; + type RuntimeEvent = RuntimeEvent; + type DisputeResolution = zrml_prediction_markets::Pallet; + type MarketCommons = MarketCommons; + type PalletId = AuthorizedPalletId; + type WeightInfo = zrml_authorized::weights::WeightInfo; +} + +impl zrml_court::Config for Runtime { + type AppealBond = AppealBond; + type BlocksPerYear = BlocksPerYear; + type DisputeResolution = zrml_prediction_markets::Pallet; + type VotePeriod = VotePeriod; + type AggregationPeriod = AggregationPeriod; + type AppealPeriod = AppealPeriod; + type LockId = LockId; + type Currency = Balances; + type RuntimeEvent = RuntimeEvent; + type InflationPeriod = InflationPeriod; + type MarketCommons = MarketCommons; + type MaxAppeals = MaxAppeals; + type MaxDelegations = MaxDelegations; + type MaxSelectedDraws = MaxSelectedDraws; + type MaxCourtParticipants = MaxCourtParticipants; + type MinJurorStake = MinJurorStake; + type MonetaryGovernanceOrigin = EnsureRoot; + type PalletId = CourtPalletId; + type Random = RandomnessCollectiveFlip; + type RequestInterval = RequestInterval; + type Slash = Treasury; + type TreasuryPalletId = TreasuryPalletId; + type WeightInfo = zrml_court::weights::WeightInfo; +} + +impl zrml_liquidity_mining::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type MarketCommons = MarketCommons; + type MarketId = MarketId; + type PalletId = LiquidityMiningPalletId; + type WeightInfo = zrml_liquidity_mining::weights::WeightInfo; +} + +impl frame_system::Config for Runtime { + type AccountData = pallet_balances::AccountData; + type AccountId = AccountIdTest; + type BaseCallFilter = Everything; + type BlockHashCount = BlockHashCount; + type BlockLength = (); + type BlockNumber = BlockNumber; + type BlockWeights = (); + type RuntimeCall = RuntimeCall; + type DbWeight = (); + type RuntimeEvent = RuntimeEvent; + type Hash = Hash; + type Hashing = BlakeTwo256; + type Header = Header; + type Index = Index; + type Lookup = IdentityLookup; + type MaxConsumers = frame_support::traits::ConstU32<16>; + type OnKilledAccount = (); + type OnNewAccount = (); + type OnSetCode = (); + type RuntimeOrigin = RuntimeOrigin; + type PalletInfo = PalletInfo; + type SS58Prefix = (); + type SystemWeightInfo = (); + type Version = (); +} + +impl orml_currencies::Config for Runtime { + type GetNativeCurrencyId = GetNativeCurrencyId; + type MultiCurrency = Tokens; + type NativeCurrency = BasicCurrencyAdapter; + type WeightInfo = (); +} + +impl orml_tokens::Config for Runtime { + type Amount = Amount; + type Balance = Balance; + type CurrencyId = CurrencyId; + type DustRemovalWhitelist = DustRemovalWhitelist; + type RuntimeEvent = RuntimeEvent; + type ExistentialDeposits = ExistentialDeposits; + type MaxLocks = MaxLocks; + type MaxReserves = MaxReserves; + type CurrencyHooks = (); + type ReserveIdentifier = [u8; 8]; + type WeightInfo = (); +} + +impl pallet_balances::Config for Runtime { + type AccountStore = System; + type Balance = Balance; + type DustRemoval = (); + type RuntimeEvent = RuntimeEvent; + type ExistentialDeposit = ExistentialDeposit; + type MaxLocks = MaxLocks; + type MaxReserves = MaxReserves; + type ReserveIdentifier = [u8; 8]; + type WeightInfo = (); +} + +impl zrml_market_commons::Config for Runtime { + type Currency = Balances; + 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 zrml_simple_disputes::Config for Runtime { + type AssetManager = AssetManager; + type RuntimeEvent = RuntimeEvent; + type OutcomeBond = OutcomeBond; + type OutcomeFactor = OutcomeFactor; + type DisputeResolution = zrml_prediction_markets::Pallet; + type MarketCommons = MarketCommons; + type MaxDisputes = MaxDisputes; + type PalletId = SimpleDisputesPalletId; + type WeightInfo = zrml_simple_disputes::weights::WeightInfo; +} + +impl zrml_global_disputes::Config for Runtime { + type AddOutcomePeriod = AddOutcomePeriod; + type RuntimeEvent = RuntimeEvent; + type DisputeResolution = zrml_prediction_markets::Pallet; + type MarketCommons = MarketCommons; + type Currency = Balances; + type GlobalDisputeLockId = GlobalDisputeLockId; + type GlobalDisputesPalletId = GlobalDisputesPalletId; + type MaxGlobalDisputeVotes = MaxGlobalDisputeVotes; + type MaxOwners = MaxOwners; + type MinOutcomeVoteAmount = MinOutcomeVoteAmount; + type RemoveKeysLimit = RemoveKeysLimit; + type GdVotingPeriod = GdVotingPeriod; + type VotingOutcomeFee = VotingOutcomeFee; + type WeightInfo = zrml_global_disputes::weights::WeightInfo; +} + +impl zrml_swaps::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type ExitFee = ExitFee; + type FixedTypeU = ::FixedTypeU; + type FixedTypeS = ::FixedTypeS; + type LiquidityMining = LiquidityMining; + type MarketCommons = MarketCommons; + type MaxAssets = MaxAssets; + type MaxInRatio = MaxInRatio; + type MaxOutRatio = MaxOutRatio; + type MaxSwapFee = MaxSwapFee; + type MaxTotalWeight = MaxTotalWeight; + type MaxWeight = MaxWeight; + type MinAssets = MinAssets; + type MinSubsidy = MinSubsidy; + type MinSubsidyPerAccount = MinSubsidyPerAccount; + type MinWeight = MinWeight; + type PalletId = SwapsPalletId; + type RikiddoSigmoidFeeMarketEma = RikiddoSigmoidFeeMarketEma; + type AssetManager = AssetManager; + type WeightInfo = zrml_swaps::weights::WeightInfo; +} + +impl pallet_treasury::Config for Runtime { + type ApproveOrigin = EnsureSignedBy; + type Burn = (); + type BurnDestination = (); + type Currency = Balances; + type RuntimeEvent = RuntimeEvent; + type MaxApprovals = MaxApprovals; + type OnSlash = (); + type PalletId = TreasuryPalletId; + type ProposalBond = (); + type ProposalBondMinimum = (); + type ProposalBondMaximum = (); + type RejectOrigin = EnsureSignedBy; + type SpendFunds = (); + type SpendOrigin = NeverEnsureOrigin; + type SpendPeriod = (); + type WeightInfo = (); +} + +#[cfg(feature = "parachain")] +zrml_prediction_markets::impl_mock_registry! { + MockRegistry, + CurrencyId, + Balance, + zeitgeist_primitives::types::CustomMetadata +} + +#[allow(unused)] +pub struct ExtBuilder { + balances: Vec<(AccountIdTest, Balance)>, +} + +// FIXME Remove this in favor of adding whatever the account need in the individual tests. +#[allow(unused)] +impl Default for ExtBuilder { + fn default() -> Self { + Self { balances: vec![(ALICE, _101), (CHARLIE, _1), (DAVE, _1), (EVE, _1)] } + } +} + +#[allow(unused)] +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(); + #[cfg(feature = "parachain")] + use frame_support::traits::GenesisBuild; + #[cfg(feature = "parachain")] + orml_tokens::GenesisConfig:: { balances: vec![(ALICE, FOREIGN_ASSET, _101)] } + .assimilate_storage(&mut t) + .unwrap(); + #[cfg(feature = "parachain")] + let custom_metadata = zeitgeist_primitives::types::CustomMetadata { + allow_as_base_asset: true, + ..Default::default() + }; + #[cfg(feature = "parachain")] + orml_asset_registry_mock::GenesisConfig { + metadata: vec![( + FOREIGN_ASSET, + AssetMetadata { + decimals: 18, + name: "MKL".as_bytes().to_vec(), + symbol: "MKL".as_bytes().to_vec(), + existential_deposit: 0, + location: None, + additional: custom_metadata, + }, + )], + } + .assimilate_storage(&mut t) + .unwrap(); + t.into() + } +} diff --git a/zrml/neo-swaps/src/tests/buy.rs b/zrml/neo-swaps/src/tests/buy.rs new file mode 100644 index 000000000..d93aa5ce8 --- /dev/null +++ b/zrml/neo-swaps/src/tests/buy.rs @@ -0,0 +1,355 @@ +// 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 super::*; +use test_case::test_case; + +// Example taken from +// https://docs.gnosis.io/conditionaltokens/docs/introduction3/#an-example-with-lmsr +#[test] +fn buy_works() { + ExtBuilder::default().build().execute_with(|| { + frame_system::Pallet::::set_block_number(1); + let liquidity = _10; + let spot_prices = vec![_1_2, _1_2]; + let swap_fee = CENT; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(2), + liquidity, + spot_prices.clone(), + swap_fee, + ); + let pool = Pools::::get(market_id).unwrap(); + let total_fee_percentage = swap_fee + EXTERNAL_FEES; + let amount_in_minus_fees = _10; + let amount_in = bdiv(amount_in_minus_fees, _1 - total_fee_percentage).unwrap(); // This is exactly _10 after deducting fees. + let expected_fees = amount_in - amount_in_minus_fees; + let expected_swap_fee_amount = expected_fees / 2; + let expected_external_fee_amount = expected_fees / 2; + let pool_outcomes_before: Vec<_> = + pool.assets().iter().map(|a| pool.reserve_of(a).unwrap()).collect(); + let pool_liquidity_before = pool.liquidity_parameter; + let asset_out = pool.assets()[0]; + assert_ok!(AssetManager::deposit(BASE_ASSET, &BOB, amount_in)); + // Deposit some stuff in the pool account to check that the pools `reserves` fields tracks + // the reserve correctly. + assert_ok!(AssetManager::deposit(asset_out, &pool.account_id, _100)); + assert_ok!(NeoSwaps::buy( + RuntimeOrigin::signed(BOB), + market_id, + 2, + asset_out, + amount_in, + 0, + )); + let pool = Pools::::get(market_id).unwrap(); + assert_eq!(pool.liquidity_parameter, pool_liquidity_before); + assert_eq!(pool.liquidity_shares_manager.owner, ALICE); + assert_eq!(pool.liquidity_shares_manager.total_shares, liquidity); + assert_eq!(pool.liquidity_shares_manager.fees, expected_swap_fee_amount); + let pool_outcomes_after: Vec<_> = pool + .assets() + .iter() + .map(|a| pool.reserve_of(a).unwrap()) + .collect(); + let expected_swap_amount_out = 58496250072; + let expected_amount_in_minus_fees = _10 + 1; // Note: This is 1 Pennock of the correct result. + let expected_amount_out = expected_swap_amount_out + expected_amount_in_minus_fees; + assert_eq!(AssetManager::free_balance(BASE_ASSET, &BOB), 0); + assert_eq!(AssetManager::free_balance(asset_out, &BOB), expected_amount_out); + assert_eq!(pool_outcomes_after[0], pool_outcomes_before[0] - expected_swap_amount_out); + assert_eq!( + pool_outcomes_after[1], + pool_outcomes_before[0] + expected_amount_in_minus_fees, + ); + let expected_pool_account_balance = + expected_swap_fee_amount + AssetManager::minimum_balance(pool.collateral); + assert_eq!( + AssetManager::free_balance(BASE_ASSET, &pool.account_id), + expected_pool_account_balance + ); + assert_eq!( + AssetManager::free_balance(BASE_ASSET, &FEE_ACCOUNT), + expected_external_fee_amount + ); + let price_sum = pool + .assets() + .iter() + .map(|&a| pool.calculate_spot_price(a).unwrap()) + .sum::(); + assert_eq!(price_sum, _1); + System::assert_last_event( + Event::BuyExecuted { + who: BOB, + market_id, + asset_out, + amount_in, + amount_out: expected_amount_out, + swap_fee_amount: expected_swap_fee_amount, + external_fee_amount: expected_external_fee_amount, + } + .into(), + ); + }); +} + +#[test] +fn buy_fails_on_incorrect_asset_count() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + assert_noop!( + NeoSwaps::buy( + RuntimeOrigin::signed(BOB), + market_id, + 1, + Asset::ScalarOutcome(market_id, ScalarPosition::Long), + _1, + 0 + ), + Error::::IncorrectAssetCount + ); + }); +} + +#[test] +fn buy_fails_on_market_not_found() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + Markets::::remove(market_id); + assert_noop!( + NeoSwaps::buy( + RuntimeOrigin::signed(BOB), + market_id, + 2, + Asset::ScalarOutcome(market_id, ScalarPosition::Long), + _1, + 0 + ), + zrml_market_commons::Error::::MarketDoesNotExist, + ); + }); +} + +#[test_case(MarketStatus::Proposed)] +#[test_case(MarketStatus::Suspended)] +#[test_case(MarketStatus::Closed)] +#[test_case(MarketStatus::CollectingSubsidy)] +#[test_case(MarketStatus::InsufficientSubsidy)] +#[test_case(MarketStatus::Reported)] +#[test_case(MarketStatus::Disputed)] +#[test_case(MarketStatus::Resolved)] +fn buy_fails_on_inactive_market(market_status: MarketStatus) { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + MarketCommons::mutate_market(&market_id, |market| { + market.status = market_status; + Ok(()) + }) + .unwrap(); + assert_noop!( + NeoSwaps::buy( + RuntimeOrigin::signed(BOB), + market_id, + 2, + Asset::ScalarOutcome(market_id, ScalarPosition::Long), + _1, + 0 + ), + Error::::MarketNotActive, + ); + }); +} + +#[test] +fn buy_fails_on_pool_not_found() { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Scalar(0..=1), ScoringRule::Lmsr); + assert_noop!( + NeoSwaps::buy( + RuntimeOrigin::signed(BOB), + market_id, + 2, + Asset::ScalarOutcome(market_id, ScalarPosition::Long), + _1, + 0 + ), + Error::::PoolNotFound, + ); + }); +} + +#[test_case(MarketType::Categorical(2))] +#[test_case(MarketType::Scalar(0..=1))] +fn buy_fails_on_asset_not_found(market_type: MarketType) { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + market_type, + _10, + vec![_1_2, _1_2], + CENT, + ); + assert_noop!( + NeoSwaps::buy( + RuntimeOrigin::signed(BOB), + market_id, + 2, + Asset::CategoricalOutcome(market_id, 2), + _1, + 0 + ), + Error::::AssetNotFound, + ); + }); +} + +#[test] +fn buy_fails_on_numerical_limits() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + let pool = Pools::::get(market_id).unwrap(); + let amount_in = 100 * pool.liquidity_parameter; + assert_ok!(AssetManager::deposit(BASE_ASSET, &BOB, amount_in)); + assert_noop!( + NeoSwaps::buy( + RuntimeOrigin::signed(BOB), + market_id, + 2, + Asset::ScalarOutcome(market_id, ScalarPosition::Long), + amount_in, + 0, + ), + Error::::NumericalLimits, + ); + }); +} + +#[test] +fn buy_fails_on_insufficient_funds() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + let amount_in = _10; + #[cfg(not(feature = "parachain"))] + let expected_error = pallet_balances::Error::::InsufficientBalance; + #[cfg(feature = "parachain")] + let expected_error = orml_tokens::Error::::BalanceTooLow; + assert_ok!(AssetManager::deposit(BASE_ASSET, &BOB, amount_in - 1)); + assert_noop!( + NeoSwaps::buy( + RuntimeOrigin::signed(BOB), + market_id, + 2, + Asset::ScalarOutcome(market_id, ScalarPosition::Long), + amount_in, + 0, + ), + expected_error, + ); + }); +} + +#[test] +fn buy_fails_on_amount_out_below_min() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + let amount_in = _1; + assert_ok!(AssetManager::deposit(BASE_ASSET, &BOB, amount_in)); + // Buying 1 at price of .5 will return less than 2 outcomes due to slippage. + assert_noop!( + NeoSwaps::buy( + RuntimeOrigin::signed(BOB), + market_id, + 2, + Asset::ScalarOutcome(market_id, ScalarPosition::Long), + amount_in, + _2, + ), + Error::::AmountOutBelowMin, + ); + }); +} + +#[test] +fn buy_fails_on_spot_price_above_max() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(2), + _10, + vec![_1_2, _1_2], + CENT, + ); + assert_noop!( + NeoSwaps::buy( + RuntimeOrigin::signed(ALICE), + market_id, + 2, + Asset::CategoricalOutcome(market_id, 0), + _70, + 0, + ), + Error::::SpotPriceAboveMax + ); + }); +} diff --git a/zrml/neo-swaps/src/tests/deploy_pool.rs b/zrml/neo-swaps/src/tests/deploy_pool.rs new file mode 100644 index 000000000..59a98522d --- /dev/null +++ b/zrml/neo-swaps/src/tests/deploy_pool.rs @@ -0,0 +1,481 @@ +// 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 super::*; +use test_case::test_case; + +#[test] +fn deploy_pool_works_with_binary_markets() { + ExtBuilder::default().build().execute_with(|| { + frame_system::Pallet::::set_block_number(1); + let alice_before = AssetManager::free_balance(BASE_ASSET, &ALICE); + let amount = _10; + let spot_prices = vec![_1_2, _1_2]; + let swap_fee = CENT; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(2), + amount, + spot_prices.clone(), + swap_fee, + ); + let assets = + vec![Asset::CategoricalOutcome(market_id, 0), Asset::CategoricalOutcome(market_id, 1)]; + let pool = Pools::::get(market_id).unwrap(); + let expected_liquidity = 144269504088; + let buffer = AssetManager::minimum_balance(pool.collateral); + assert_eq!(pool.assets(), assets); + assert_approx!(pool.liquidity_parameter, expected_liquidity, 1); + assert_eq!(pool.collateral, BASE_ASSET); + assert_eq!(pool.liquidity_shares_manager.owner, ALICE); + assert_eq!(pool.liquidity_shares_manager.total_shares, amount); + assert_eq!(pool.liquidity_shares_manager.fees, 0); + assert_eq!(pool.swap_fee, swap_fee); + assert_eq!(AssetManager::free_balance(pool.collateral, &pool.account_id), buffer); + assert_eq!(AssetManager::free_balance(assets[0], &pool.account_id), amount); + assert_eq!(AssetManager::free_balance(assets[1], &pool.account_id), amount); + assert_eq!(pool.reserve_of(&assets[0]).unwrap(), amount); + assert_eq!(pool.reserve_of(&assets[1]).unwrap(), amount); + assert_eq!(pool.calculate_spot_price(assets[0]).unwrap(), spot_prices[0]); + assert_eq!(pool.calculate_spot_price(assets[1]).unwrap(), spot_prices[1]); + assert_eq!(AssetManager::free_balance(BASE_ASSET, &ALICE), alice_before - amount - buffer); + assert_eq!(AssetManager::free_balance(assets[0], &ALICE), 0); + assert_eq!(AssetManager::free_balance(assets[1], &ALICE), 0); + System::assert_last_event( + Event::PoolDeployed { + who: ALICE, + market_id, + pool_shares_amount: amount, + amounts_in: vec![amount, amount], + liquidity_parameter: pool.liquidity_parameter, + } + .into(), + ); + }); +} + +#[test] +fn deploy_pool_works_with_scalar_marktes() { + ExtBuilder::default().build().execute_with(|| { + frame_system::Pallet::::set_block_number(1); + let alice_before = AssetManager::free_balance(BASE_ASSET, &ALICE); + let amount = _100; + let spot_prices = vec![_1_6, _5_6 + 1]; + let swap_fee = CENT; + let market_id: MarketId = 0; + let assets = vec![ + Asset::ScalarOutcome(market_id, ScalarPosition::Long), + Asset::ScalarOutcome(market_id, ScalarPosition::Short), + ]; + // Deploy some funds in the pool account to ensure that rogue funds don't screw up price + // calculatings. + let rogue_funds = _100; + assert_ok!(AssetManager::deposit( + assets[0], + &Pallet::::pool_account_id(&market_id), + rogue_funds + )); + let _ = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + amount, + spot_prices.clone(), + swap_fee, + ); + let pool = Pools::::get(market_id).unwrap(); + let expected_liquidity = 558110626551; + let expected_amounts = vec![amount, 101755598229]; + let buffer = AssetManager::minimum_balance(pool.collateral); + assert_eq!(pool.assets(), assets); + assert_approx!(pool.liquidity_parameter, expected_liquidity, 1_000); + assert_eq!(pool.collateral, BASE_ASSET); + assert_eq!(pool.liquidity_shares_manager.owner, ALICE); + assert_eq!(pool.liquidity_shares_manager.total_shares, amount); + assert_eq!(pool.liquidity_shares_manager.fees, 0); + assert_eq!(pool.swap_fee, swap_fee); + assert_eq!( + AssetManager::free_balance(assets[0], &pool.account_id), + expected_amounts[0] + rogue_funds + ); + assert_eq!(AssetManager::free_balance(assets[1], &pool.account_id), expected_amounts[1]); + assert_eq!(pool.reserve_of(&assets[0]).unwrap(), expected_amounts[0]); + assert_eq!(pool.reserve_of(&assets[1]).unwrap(), expected_amounts[1]); + assert_eq!(pool.calculate_spot_price(assets[0]).unwrap(), spot_prices[0]); + assert_eq!(pool.calculate_spot_price(assets[1]).unwrap(), spot_prices[1]); + assert_eq!(AssetManager::free_balance(BASE_ASSET, &ALICE), alice_before - amount - buffer); + assert_eq!(AssetManager::free_balance(assets[0], &ALICE), 0); + assert_eq!(AssetManager::free_balance(assets[1], &ALICE), amount - expected_amounts[1]); + let price_sum = + pool.assets().iter().map(|&a| pool.calculate_spot_price(a).unwrap()).sum::(); + assert_eq!(price_sum, _1); + System::assert_last_event( + Event::PoolDeployed { + who: ALICE, + market_id, + pool_shares_amount: amount, + amounts_in: expected_amounts, + liquidity_parameter: pool.liquidity_parameter, + } + .into(), + ); + }); +} + +#[test] +fn deploy_pool_fails_on_incorrect_vec_len() { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Scalar(0..=1), ScoringRule::Lmsr); + assert_noop!( + NeoSwaps::deploy_pool(RuntimeOrigin::signed(ALICE), market_id, _10, vec![_1_3], CENT), + Error::::IncorrectVecLen + ); + }); +} + +#[test] +fn deploy_pool_fails_on_market_not_found() { + ExtBuilder::default().build().execute_with(|| { + assert_noop!( + NeoSwaps::deploy_pool(RuntimeOrigin::signed(ALICE), 0, _10, vec![_1_4, _3_4], CENT), + zrml_market_commons::Error::::MarketDoesNotExist, + ); + }); +} + +#[test_case(MarketStatus::Proposed)] +#[test_case(MarketStatus::Suspended)] +#[test_case(MarketStatus::Closed)] +#[test_case(MarketStatus::CollectingSubsidy)] +#[test_case(MarketStatus::InsufficientSubsidy)] +#[test_case(MarketStatus::Reported)] +#[test_case(MarketStatus::Disputed)] +#[test_case(MarketStatus::Resolved)] +fn deploy_pool_fails_on_inactive_market(market_status: MarketStatus) { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Scalar(0..=1), ScoringRule::Lmsr); + MarketCommons::mutate_market(&market_id, |market| { + market.status = market_status; + Ok(()) + }) + .unwrap(); + assert_noop!( + NeoSwaps::deploy_pool( + RuntimeOrigin::signed(ALICE), + market_id, + _1, + vec![_1_2, _1_2], + CENT, + ), + Error::::MarketNotActive, + ); + }); +} + +#[test] +fn deploy_pool_fails_on_duplicate_pool() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + assert_noop!( + NeoSwaps::deploy_pool( + RuntimeOrigin::signed(ALICE), + market_id, + _2, + vec![_1_2, _1_2], + CENT, + ), + Error::::DuplicatePool, + ); + }); +} + +#[test] +fn deploy_pool_fails_on_not_allowed() { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Scalar(0..=1), ScoringRule::Lmsr); + assert_noop!( + NeoSwaps::deploy_pool( + RuntimeOrigin::signed(BOB), + market_id, + _10, + vec![_1_4, _3_4], + CENT + ), + Error::::NotAllowed + ); + }); +} + +#[test] +fn deploy_pool_fails_on_invalid_trading_mechanism() { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Scalar(0..=1), ScoringRule::CPMM); + assert_noop!( + NeoSwaps::deploy_pool( + RuntimeOrigin::signed(ALICE), + market_id, + _10, + vec![_1_4, _3_4], + CENT + ), + Error::::InvalidTradingMechanism + ); + }); +} + +#[test] +fn deploy_pool_fails_on_market_is_not_binary_or_scalar() { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Categorical(3), ScoringRule::Lmsr); + let liquidity = _10; + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity, + )); + assert_noop!( + NeoSwaps::deploy_pool( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity, + vec![_1_3, _1_3, _1_3], + CENT + ), + Error::::MarketNotBinaryOrScalar + ); + }); +} + +// FIXME This test currently fails because the `ensure!` throwing `AssetCountAboveMax` is +// currently unreachable if the market is not binary/scalar. +#[test] +#[should_panic] +fn deploy_pool_fails_on_asset_count_above_max() { + ExtBuilder::default().build().execute_with(|| { + let category_count = MAX_ASSETS + 1; + let market_id = create_market( + ALICE, + BASE_ASSET, + MarketType::Categorical(category_count), + ScoringRule::Lmsr, + ); + let liquidity = _10; + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity, + )); + // Depending on the value of MAX_ASSETS and PRICE_BARRIER_*, this `spot_prices` vector + // might violate some other rules for deploying pools. + let mut spot_prices = vec![_1 / category_count as u128; category_count as usize - 1]; + spot_prices.push(_1 - spot_prices.iter().sum::()); + assert_noop!( + NeoSwaps::deploy_pool( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity, + spot_prices, + CENT + ), + Error::::AssetCountAboveMax + ); + }); +} + +#[test] +fn deploy_pool_fails_on_swap_fee_below_min() { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Categorical(2), ScoringRule::Lmsr); + let liquidity = _10; + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity, + )); + assert_noop!( + NeoSwaps::deploy_pool( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity, + vec![_1_4, _3_4], + MIN_SWAP_FEE - 1, + ), + Error::::SwapFeeBelowMin + ); + }); +} + +#[test] +fn deploy_pool_fails_on_swap_fee_above_max() { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Categorical(2), ScoringRule::Lmsr); + let liquidity = _10; + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity, + )); + assert_noop!( + NeoSwaps::deploy_pool( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity, + vec![_1_4, _3_4], + ::MaxSwapFee::get() + 1, + ), + Error::::SwapFeeAboveMax + ); + }); +} + +#[test_case(vec![_1_4, _3_4 - 1])] +#[test_case(vec![_1_4 + 1, _3_4])] +fn deploy_pool_fails_on_invalid_spot_prices(spot_prices: Vec>) { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Categorical(2), ScoringRule::Lmsr); + let liquidity = _10; + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity, + )); + assert_noop!( + NeoSwaps::deploy_pool( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity, + spot_prices, + CENT + ), + Error::::InvalidSpotPrices + ); + }); +} + +#[test] +fn deploy_pool_fails_on_spot_price_below_min() { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Categorical(2), ScoringRule::Lmsr); + let liquidity = _10; + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity, + )); + let spot_price = MIN_SPOT_PRICE - 1; + assert_noop!( + NeoSwaps::deploy_pool( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity, + vec![spot_price, _1 - spot_price], + CENT + ), + Error::::SpotPriceBelowMin + ); + }); +} + +#[test] +fn deploy_pool_fails_on_spot_price_above_max() { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Categorical(2), ScoringRule::Lmsr); + let liquidity = _10; + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity, + )); + let spot_price = MAX_SPOT_PRICE + 1; + assert_noop!( + NeoSwaps::deploy_pool( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity, + vec![spot_price, _1 - spot_price], + CENT + ), + Error::::SpotPriceAboveMax + ); + }); +} + +#[test] +fn deploy_pool_fails_on_insufficient_funds() { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Categorical(2), ScoringRule::Lmsr); + let liquidity = _10; + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity - 1, + )); + assert_noop!( + NeoSwaps::deploy_pool( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity, + vec![_3_4, _1_4], + CENT + ), + orml_tokens::Error::::BalanceTooLow + ); + }); +} + +#[test] +fn deploy_pool_fails_on_liquidity_too_low() { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Scalar(0..=1), ScoringRule::Lmsr); + let amount = _1_2; + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(ALICE), + market_id, + amount, + )); + assert_noop!( + NeoSwaps::deploy_pool( + RuntimeOrigin::signed(ALICE), + market_id, + amount, + vec![_1_2, _1_2], + CENT + ), + Error::::LiquidityTooLow + ); + }); +} diff --git a/zrml/neo-swaps/src/tests/exit.rs b/zrml/neo-swaps/src/tests/exit.rs new file mode 100644 index 000000000..0bfe15412 --- /dev/null +++ b/zrml/neo-swaps/src/tests/exit.rs @@ -0,0 +1,317 @@ +// 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 super::*; + +#[test] +fn exit_works() { + ExtBuilder::default().build().execute_with(|| { + frame_system::Pallet::::set_block_number(1); + let liquidity = _10; + let spot_prices = vec![_1_6, _5_6 + 1]; + let swap_fee = CENT; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + liquidity, + spot_prices.clone(), + swap_fee, + ); + let pool_shares_amount = _4; // Remove 40% to the pool. + let pool_before = Pools::::get(market_id).unwrap(); + let alice_outcomes_before = [ + AssetManager::free_balance(pool_before.assets()[0], &ALICE), + AssetManager::free_balance(pool_before.assets()[1], &ALICE), + ]; + let pool_outcomes_before: Vec<_> = + pool_before.assets().iter().map(|a| pool_before.reserve_of(a).unwrap()).collect(); + assert_ok!(NeoSwaps::exit( + RuntimeOrigin::signed(ALICE), + market_id, + pool_shares_amount, + vec![0, 0], + )); + let pool_after = Pools::::get(market_id).unwrap(); + let ratio = bdiv(pool_shares_amount, liquidity).unwrap(); + let pool_outcomes_after: Vec<_> = + pool_after.assets().iter().map(|a| pool_after.reserve_of(a).unwrap()).collect(); + let expected_pool_diff = vec![ + bmul(ratio, pool_outcomes_before[0]).unwrap(), + bmul(ratio, pool_outcomes_before[1]).unwrap(), + ]; + let alice_outcomes_after = [ + AssetManager::free_balance(pool_after.assets()[0], &ALICE), + AssetManager::free_balance(pool_after.assets()[1], &ALICE), + ]; + assert_eq!(pool_outcomes_after[0], pool_outcomes_before[0] - expected_pool_diff[0]); + assert_eq!(pool_outcomes_after[1], pool_outcomes_before[1] - expected_pool_diff[1]); + assert_eq!(alice_outcomes_after[0], alice_outcomes_before[0] + expected_pool_diff[0]); + assert_eq!(alice_outcomes_after[1], alice_outcomes_before[1] + expected_pool_diff[1]); + assert_eq!( + pool_after.liquidity_parameter, + bmul(_1 - ratio, pool_before.liquidity_parameter).unwrap() + ); + assert_eq!( + pool_after.liquidity_shares_manager.shares_of(&ALICE).unwrap(), + liquidity - pool_shares_amount + ); + System::assert_last_event( + Event::ExitExecuted { + who: ALICE, + market_id, + pool_shares_amount, + amounts_out: expected_pool_diff, + new_liquidity_parameter: pool_after.liquidity_parameter, + } + .into(), + ); + }); +} + +#[test] +fn exit_destroys_pool() { + ExtBuilder::default().build().execute_with(|| { + frame_system::Pallet::::set_block_number(1); + let liquidity = _10; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + liquidity, + vec![_1_6, _5_6 + 1], + CENT, + ); + let pool = Pools::::get(market_id).unwrap(); + let amounts_out = vec![ + pool.reserve_of(&pool.assets()[0]).unwrap(), + pool.reserve_of(&pool.assets()[1]).unwrap(), + ]; + let alice_before = [ + AssetManager::free_balance(pool.assets()[0], &ALICE), + AssetManager::free_balance(pool.assets()[1], &ALICE), + ]; + assert_ok!(NeoSwaps::exit(RuntimeOrigin::signed(ALICE), market_id, liquidity, vec![0, 0])); + assert!(!Pools::::contains_key(market_id)); + assert_eq!(AssetManager::free_balance(pool.collateral, &pool.account_id), 0); + assert_eq!(AssetManager::free_balance(pool.assets()[0], &pool.account_id), 0); + assert_eq!(AssetManager::free_balance(pool.assets()[1], &pool.account_id), 0); + assert_eq!( + AssetManager::free_balance(pool.assets()[0], &ALICE), + alice_before[0] + amounts_out[0] + ); + assert_eq!( + AssetManager::free_balance(pool.assets()[1], &ALICE), + alice_before[1] + amounts_out[1] + ); + System::assert_last_event( + Event::PoolDestroyed { + who: ALICE, + market_id, + pool_shares_amount: liquidity, + amounts_out, + } + .into(), + ); + }); +} + +#[test] +fn exit_fails_on_incorrect_vec_len() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + assert_noop!( + NeoSwaps::exit(RuntimeOrigin::signed(ALICE), market_id, _1, vec![0]), + Error::::IncorrectVecLen + ); + }); +} + +#[test] +fn exit_fails_on_market_not_found() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + Markets::::remove(market_id); + assert_noop!( + NeoSwaps::exit(RuntimeOrigin::signed(ALICE), market_id, _1, vec![0, 0]), + zrml_market_commons::Error::::MarketDoesNotExist + ); + }); +} + +#[test] +fn exit_fails_on_pool_not_found() { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Scalar(0..=1), ScoringRule::Lmsr); + assert_noop!( + NeoSwaps::exit(RuntimeOrigin::signed(ALICE), market_id, _1, vec![0, 0]), + Error::::PoolNotFound, + ); + }); +} + +#[test] +fn exit_fails_on_insufficient_funds() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _10; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + liquidity, + vec![_1_2, _1_2], + CENT, + ); + assert_noop!( + NeoSwaps::exit( + RuntimeOrigin::signed(ALICE), + market_id, + liquidity + 1, // One more than Alice has. + vec![0, 0] + ), + Error::::InsufficientPoolShares, + ); + }); +} + +#[test] +fn exit_fails_on_amount_out_below_min() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _20, + vec![_1_2, _1_2], + CENT, + ); + let pool_shares_amount = _5; + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(ALICE), + market_id, + pool_shares_amount, + )); + assert_noop!( + NeoSwaps::exit( + RuntimeOrigin::signed(ALICE), + market_id, + pool_shares_amount, + vec![pool_shares_amount + 1, pool_shares_amount] + ), + Error::::AmountOutBelowMin + ); + }); +} + +#[test] +fn exit_fails_if_not_allowed() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _20, + vec![_1_2, _1_2], + CENT, + ); + let pool_shares_amount = _5; + assert_ok!(AssetManager::deposit(BASE_ASSET, &BOB, pool_shares_amount)); + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(BOB), + market_id, + pool_shares_amount, + )); + assert_noop!( + NeoSwaps::exit( + RuntimeOrigin::signed(BOB), + market_id, + pool_shares_amount, + vec![pool_shares_amount, pool_shares_amount] + ), + Error::::NotAllowed + ); + }); +} + +#[test] +fn exit_fails_on_outstanding_fees() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _20, + vec![_1_2, _1_2], + CENT, + ); + let pool_shares_amount = _20; + assert_ok!(AssetManager::deposit(BASE_ASSET, &BOB, pool_shares_amount)); + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(BOB), + market_id, + pool_shares_amount, + )); + assert_ok!(Pools::::try_mutate(market_id, |pool| pool + .as_mut() + .unwrap() + .liquidity_shares_manager + .deposit_fees(1))); + assert_noop!( + NeoSwaps::exit( + RuntimeOrigin::signed(BOB), + market_id, + pool_shares_amount, + vec![pool_shares_amount, pool_shares_amount] + ), + Error::::OutstandingFees + ); + }); +} + +#[test] +fn exit_pool_fails_on_liquidity_too_low() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + // Will result in liquidity of about 0.7213475204444817. + assert_noop!( + NeoSwaps::exit(RuntimeOrigin::signed(ALICE), market_id, _10 - _1_2, vec![0, 0]), + Error::::LiquidityTooLow + ); + }); +} diff --git a/zrml/neo-swaps/src/tests/join.rs b/zrml/neo-swaps/src/tests/join.rs new file mode 100644 index 000000000..ff07328af --- /dev/null +++ b/zrml/neo-swaps/src/tests/join.rs @@ -0,0 +1,249 @@ +// 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 super::*; +use test_case::test_case; + +#[test] +fn join_works() { + ExtBuilder::default().build().execute_with(|| { + frame_system::Pallet::::set_block_number(1); + let liquidity = _10; + let spot_prices = vec![_1_6, _5_6 + 1]; + let swap_fee = CENT; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + liquidity, + spot_prices.clone(), + swap_fee, + ); + let pool_shares_amount = _4; // Add 40% to the pool. + assert_ok!(AssetManager::deposit(BASE_ASSET, &ALICE, pool_shares_amount)); + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(ALICE), + market_id, + pool_shares_amount, + )); + let pool_before = Pools::::get(market_id).unwrap(); + let alice_long_before = AssetManager::free_balance(pool_before.assets()[1], &ALICE); + let pool_outcomes_before: Vec<_> = + pool_before.assets().iter().map(|a| pool_before.reserve_of(a).unwrap()).collect(); + assert_ok!(NeoSwaps::join( + RuntimeOrigin::signed(ALICE), + market_id, + pool_shares_amount, + vec![u128::MAX, u128::MAX], + )); + let pool_after = Pools::::get(market_id).unwrap(); + let ratio = bdiv(liquidity + pool_shares_amount, liquidity).unwrap(); + let pool_outcomes_after: Vec<_> = + pool_after.assets().iter().map(|a| pool_after.reserve_of(a).unwrap()).collect(); + assert_eq!(pool_outcomes_after[0], bmul(ratio, pool_outcomes_before[0]).unwrap()); + assert_eq!(pool_outcomes_after[1], bmul(ratio, pool_outcomes_before[1]).unwrap()); + let long_diff = pool_outcomes_after[1] - pool_outcomes_before[1]; + assert_eq!(AssetManager::free_balance(pool_after.assets()[0], &ALICE), 0); + assert_eq!( + AssetManager::free_balance(pool_after.assets()[1], &ALICE), + alice_long_before - long_diff + ); + assert_eq!( + pool_after.liquidity_parameter, + bmul(ratio, pool_before.liquidity_parameter).unwrap() + ); + assert_eq!( + pool_after.liquidity_shares_manager.shares_of(&ALICE).unwrap(), + liquidity + pool_shares_amount + ); + System::assert_last_event( + Event::JoinExecuted { + who: ALICE, + market_id, + pool_shares_amount, + amounts_in: vec![pool_shares_amount, long_diff], + new_liquidity_parameter: pool_after.liquidity_parameter, + } + .into(), + ); + }); +} + +#[test] +fn join_fails_on_incorrect_vec_len() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + assert_noop!( + NeoSwaps::join(RuntimeOrigin::signed(ALICE), market_id, _1, vec![0]), + Error::::IncorrectVecLen + ); + }); +} + +#[test] +fn join_fails_on_market_not_found() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + Markets::::remove(market_id); + assert_noop!( + NeoSwaps::join(RuntimeOrigin::signed(ALICE), market_id, _1, vec![u128::MAX, u128::MAX]), + zrml_market_commons::Error::::MarketDoesNotExist + ); + }); +} + +#[test_case(MarketStatus::Proposed)] +#[test_case(MarketStatus::Suspended)] +#[test_case(MarketStatus::Closed)] +#[test_case(MarketStatus::CollectingSubsidy)] +#[test_case(MarketStatus::InsufficientSubsidy)] +#[test_case(MarketStatus::Reported)] +#[test_case(MarketStatus::Disputed)] +#[test_case(MarketStatus::Resolved)] +fn join_fails_on_inactive_market(market_status: MarketStatus) { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + MarketCommons::mutate_market(&market_id, |market| { + market.status = market_status; + Ok(()) + }) + .unwrap(); + assert_noop!( + NeoSwaps::join(RuntimeOrigin::signed(BOB), market_id, _1, vec![u128::MAX, u128::MAX]), + Error::::MarketNotActive, + ); + }); +} + +#[test] +fn join_fails_on_pool_not_found() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market(ALICE, BASE_ASSET, MarketType::Scalar(0..=1), ScoringRule::Lmsr); + assert_noop!( + NeoSwaps::join( + RuntimeOrigin::signed(ALICE), + market_id, + _1, + vec![u128::MAX, u128::MAX], + ), + Error::::PoolNotFound, + ); + }); +} + +#[test] +fn join_fails_on_insufficient_funds() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + assert_noop!( + NeoSwaps::join( + RuntimeOrigin::signed(ALICE), + market_id, + _100, + vec![u128::MAX, u128::MAX] + ), + orml_tokens::Error::::BalanceTooLow + ); + }); +} + +#[test] +fn join_fails_on_amount_in_above_max() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _20, + vec![_1_2, _1_2], + CENT, + ); + let pool_shares_amount = _10; + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(ALICE), + market_id, + pool_shares_amount, + )); + assert_noop!( + NeoSwaps::join( + RuntimeOrigin::signed(ALICE), + market_id, + pool_shares_amount, + vec![pool_shares_amount - 1, pool_shares_amount] + ), + Error::::AmountInAboveMax + ); + }); +} + +#[test] +fn join_fails_if_not_allowed() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _20, + vec![_1_2, _1_2], + CENT, + ); + let pool_shares_amount = _5; + assert_ok!(AssetManager::deposit(BASE_ASSET, &BOB, pool_shares_amount)); + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(BOB), + market_id, + pool_shares_amount, + )); + assert_noop!( + NeoSwaps::join( + RuntimeOrigin::signed(BOB), + market_id, + pool_shares_amount, + vec![pool_shares_amount, pool_shares_amount] + ), + Error::::NotAllowed + ); + }); +} diff --git a/zrml/neo-swaps/src/tests/mod.rs b/zrml/neo-swaps/src/tests/mod.rs new file mode 100644 index 000000000..0e2633f84 --- /dev/null +++ b/zrml/neo-swaps/src/tests/mod.rs @@ -0,0 +1,120 @@ +// 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(all(feature = "mock", test))] + +mod buy; +mod deploy_pool; +mod exit; +mod join; +mod sell; +mod withdraw_fees; + +use crate::{consts::*, mock::*, traits::*, *}; +use frame_support::{assert_noop, assert_ok}; +use orml_traits::MultiCurrency; +use sp_runtime::Perbill; +use zeitgeist_primitives::{ + constants::CENT, + math::fixed::{bdiv, bmul}, + types::{ + AccountIdTest, Asset, Deadlines, MarketCreation, MarketId, MarketPeriod, MarketStatus, + MarketType, MultiHash, ScalarPosition, ScoringRule, + }, +}; +use zrml_market_commons::{MarketCommonsPalletApi, Markets}; + +#[cfg(not(feature = "parachain"))] +const BASE_ASSET: Asset = Asset::Ztg; +#[cfg(feature = "parachain")] +const BASE_ASSET: Asset = FOREIGN_ASSET; + +fn create_market( + creator: AccountIdTest, + base_asset: Asset, + market_type: MarketType, + scoring_rule: ScoringRule, +) -> MarketId { + let mut metadata = [2u8; 50]; + metadata[0] = 0x15; + metadata[1] = 0x30; + assert_ok!(PredictionMarkets::create_market( + RuntimeOrigin::signed(creator), + base_asset, + Perbill::zero(), + EVE, + MarketPeriod::Block(0..2), + Deadlines { + grace_period: 0_u32.into(), + oracle_duration: ::MinOracleDuration::get(), + dispute_duration: 0_u32.into(), + }, + MultiHash::Sha3_384(metadata), + MarketCreation::Permissionless, + market_type, + None, + scoring_rule, + )); + MarketCommons::latest_market_id().unwrap() +} + +fn create_market_and_deploy_pool( + creator: AccountIdTest, + base_asset: Asset, + market_type: MarketType, + amount: BalanceOf, + spot_prices: Vec>, + swap_fee: BalanceOf, +) -> MarketId { + let market_id = create_market(creator, base_asset, market_type, ScoringRule::Lmsr); + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(ALICE), + market_id, + amount, + )); + println!("{:?}", AssetManager::free_balance(base_asset, &ALICE)); + assert_ok!(NeoSwaps::deploy_pool( + RuntimeOrigin::signed(ALICE), + market_id, + amount, + spot_prices.clone(), + swap_fee, + )); + market_id +} + +#[macro_export] +macro_rules! assert_approx { + ($left:expr, $right:expr, $precision:expr $(,)?) => { + match (&$left, &$right, &$precision) { + (left_val, right_val, precision_val) => { + let diff = if *left_val > *right_val { + *left_val - *right_val + } else { + *right_val - *left_val + }; + if diff > *precision_val { + panic!( + "assertion `left approx== right` failed\n left: {}\n right: {}\n \ + precision: {}\ndifference: {}", + *left_val, *right_val, *precision_val, diff + ); + } + } + } + }; +} diff --git a/zrml/neo-swaps/src/tests/sell.rs b/zrml/neo-swaps/src/tests/sell.rs new file mode 100644 index 000000000..58cdd4ba5 --- /dev/null +++ b/zrml/neo-swaps/src/tests/sell.rs @@ -0,0 +1,336 @@ +// 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 super::*; +use test_case::test_case; + +#[test] +fn sell_works() { + ExtBuilder::default().build().execute_with(|| { + frame_system::Pallet::::set_block_number(1); + let liquidity = _10; + let spot_prices = vec![_1_4, _3_4]; + let swap_fee = CENT; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + liquidity, + spot_prices.clone(), + swap_fee, + ); + let pool = Pools::::get(market_id).unwrap(); + let amount_in = _10; + let pool_outcomes_before: Vec<_> = + pool.assets().iter().map(|a| pool.reserve_of(a).unwrap()).collect(); + let pool_liquidity_before = pool.liquidity_parameter; + AssetManager::deposit(BASE_ASSET, &BOB, amount_in).unwrap(); + assert_ok!(PredictionMarkets::buy_complete_set( + RuntimeOrigin::signed(BOB), + market_id, + amount_in, + )); + let asset_in = pool.assets()[1]; + assert_ok!(NeoSwaps::sell( + RuntimeOrigin::signed(BOB), + market_id, + 2, + asset_in, + amount_in, + 0, + )); + let total_fee_percentage = swap_fee + EXTERNAL_FEES; + let expected_amount_out = 59632253897u128; + let expected_fees = bmul(total_fee_percentage, expected_amount_out).unwrap(); + let expected_swap_fee_amount = expected_fees / 2; + let expected_external_fee_amount = expected_fees - expected_swap_fee_amount; + let expected_amount_out_minus_fees = expected_amount_out - expected_fees; + assert_eq!(AssetManager::free_balance(BASE_ASSET, &BOB), expected_amount_out_minus_fees); + assert_eq!(AssetManager::free_balance(asset_in, &BOB), 0); + let pool = Pools::::get(market_id).unwrap(); + assert_eq!(pool.liquidity_parameter, pool_liquidity_before); + assert_eq!(pool.liquidity_shares_manager.owner, ALICE); + assert_eq!(pool.liquidity_shares_manager.total_shares, liquidity); + assert_eq!(pool.liquidity_shares_manager.fees, expected_swap_fee_amount); + let pool_outcomes_after: Vec<_> = + pool.assets().iter().map(|a| pool.reserve_of(a).unwrap()).collect(); + assert_eq!(pool_outcomes_after[0], pool_outcomes_before[0] - expected_amount_out); + assert_eq!( + pool_outcomes_after[1], + pool_outcomes_before[1] + (amount_in - expected_amount_out) + ); + let expected_pool_account_balance = + expected_swap_fee_amount + AssetManager::minimum_balance(pool.collateral); + assert_eq!( + AssetManager::free_balance(BASE_ASSET, &pool.account_id), + expected_pool_account_balance + ); + assert_eq!( + AssetManager::free_balance(BASE_ASSET, &FEE_ACCOUNT), + expected_external_fee_amount + ); + assert_eq!( + AssetManager::total_issuance(pool.assets()[0]), + liquidity + amount_in - expected_amount_out + ); + assert_eq!( + AssetManager::total_issuance(pool.assets()[1]), + liquidity + amount_in - expected_amount_out + ); + let price_sum = + pool.assets().iter().map(|&a| pool.calculate_spot_price(a).unwrap()).sum::(); + assert_eq!(price_sum, _1); + System::assert_last_event( + Event::SellExecuted { + who: BOB, + market_id, + asset_in, + amount_in, + amount_out: expected_amount_out, + swap_fee_amount: expected_swap_fee_amount, + external_fee_amount: expected_external_fee_amount, + } + .into(), + ); + }); +} + +#[test] +fn sell_fails_on_incorrect_asset_count() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + assert_noop!( + NeoSwaps::sell( + RuntimeOrigin::signed(BOB), + market_id, + 1, + Asset::ScalarOutcome(market_id, ScalarPosition::Long), + _1, + 0 + ), + Error::::IncorrectAssetCount + ); + }); +} + +#[test] +fn sell_fails_on_market_not_found() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + Markets::::remove(market_id); + assert_noop!( + NeoSwaps::sell( + RuntimeOrigin::signed(BOB), + market_id, + 2, + Asset::ScalarOutcome(market_id, ScalarPosition::Long), + _1, + 0 + ), + zrml_market_commons::Error::::MarketDoesNotExist, + ); + }); +} + +#[test_case(MarketStatus::Proposed)] +#[test_case(MarketStatus::Suspended)] +#[test_case(MarketStatus::Closed)] +#[test_case(MarketStatus::CollectingSubsidy)] +#[test_case(MarketStatus::InsufficientSubsidy)] +#[test_case(MarketStatus::Reported)] +#[test_case(MarketStatus::Disputed)] +#[test_case(MarketStatus::Resolved)] +fn sell_fails_on_inactive_market(market_status: MarketStatus) { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + MarketCommons::mutate_market(&market_id, |market| { + market.status = market_status; + Ok(()) + }) + .unwrap(); + assert_noop!( + NeoSwaps::sell( + RuntimeOrigin::signed(BOB), + market_id, + 2, + Asset::ScalarOutcome(market_id, ScalarPosition::Long), + _1, + 0 + ), + Error::::MarketNotActive, + ); + }); +} + +#[test] +fn sell_fails_on_pool_not_found() { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Scalar(0..=1), ScoringRule::Lmsr); + assert_noop!( + NeoSwaps::sell( + RuntimeOrigin::signed(BOB), + market_id, + 2, + Asset::ScalarOutcome(market_id, ScalarPosition::Long), + _1, + 0 + ), + Error::::PoolNotFound, + ); + }); +} + +#[test_case(MarketType::Categorical(2))] +#[test_case(MarketType::Scalar(0..=1))] +fn sell_fails_on_asset_not_found(market_type: MarketType) { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + market_type, + _10, + vec![_1_2, _1_2], + CENT, + ); + assert_noop!( + NeoSwaps::sell( + RuntimeOrigin::signed(BOB), + market_id, + 2, + Asset::CategoricalOutcome(market_id, 2), + _1, + u128::MAX, + ), + Error::::AssetNotFound, + ); + }); +} + +#[test] +fn sell_fails_on_numerical_limits() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + let pool = Pools::::get(market_id).unwrap(); + let asset_in = Asset::ScalarOutcome(market_id, ScalarPosition::Long); + let amount_in = 100 * pool.liquidity_parameter; + assert_ok!(AssetManager::deposit(asset_in, &BOB, amount_in)); + assert_noop!( + NeoSwaps::buy(RuntimeOrigin::signed(BOB), market_id, 2, asset_in, amount_in, 0), + Error::::NumericalLimits, + ); + }); +} + +#[test] +fn sell_fails_on_insufficient_funds() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _10, + vec![_1_2, _1_2], + CENT, + ); + let amount_in = _10; + let asset_in = Asset::ScalarOutcome(market_id, ScalarPosition::Long); + assert_ok!(AssetManager::deposit(asset_in, &BOB, amount_in - 1)); + assert_noop!( + NeoSwaps::sell( + RuntimeOrigin::signed(BOB), + market_id, + 2, + asset_in, + amount_in, + u128::MAX, + ), + orml_tokens::Error::::BalanceTooLow, + ); + }); +} + +#[test] +fn sell_fails_on_amount_out_below_min() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + _100, + vec![_1_2, _1_2], + CENT, + ); + let amount_in = _20; + let asset_in = Asset::ScalarOutcome(market_id, ScalarPosition::Long); + assert_ok!(AssetManager::deposit(asset_in, &BOB, amount_in)); + // Selling 20 at price of .5 will return less than 10 dollars due to slippage. + assert_noop!( + NeoSwaps::sell(RuntimeOrigin::signed(BOB), market_id, 2, asset_in, amount_in, _10), + Error::::AmountOutBelowMin, + ); + }); +} + +#[test] +fn sell_fails_on_spot_price_below_min() { + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(2), + _10, + vec![_1_2, _1_2], + CENT, + ); + let asset_in = Asset::CategoricalOutcome(market_id, 0); + let amount_in = _80; + assert_ok!(AssetManager::deposit(asset_in, &BOB, amount_in)); + assert_noop!( + NeoSwaps::sell(RuntimeOrigin::signed(BOB), market_id, 2, asset_in, amount_in, 0), + Error::::SpotPriceBelowMin + ); + }); +} diff --git a/zrml/neo-swaps/src/tests/withdraw_fees.rs b/zrml/neo-swaps/src/tests/withdraw_fees.rs new file mode 100644 index 000000000..3fc71d6d2 --- /dev/null +++ b/zrml/neo-swaps/src/tests/withdraw_fees.rs @@ -0,0 +1,67 @@ +// 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 super::*; + +#[test] +fn withdraw_fees_works() { + ExtBuilder::default().build().execute_with(|| { + frame_system::Pallet::::set_block_number(1); + let liquidity = _10; + let spot_prices = vec![_1_6, _5_6 + 1]; + let swap_fee = CENT; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + liquidity, + spot_prices.clone(), + swap_fee, + ); + // Mock up some fees for Alice to withdraw. + let mut pool = Pools::::get(market_id).unwrap(); + let fees = 123456789; + assert_ok!(AssetManager::deposit(pool.collateral, &pool.account_id, fees)); + pool.liquidity_shares_manager.fees = fees; + Pools::::insert(market_id, pool.clone()); + let alice_before = AssetManager::free_balance(pool.collateral, &ALICE); + assert_ok!(NeoSwaps::withdraw_fees(RuntimeOrigin::signed(ALICE), market_id)); + let expected_pool_account_balance = AssetManager::minimum_balance(pool.collateral); + assert_eq!( + AssetManager::free_balance(pool.collateral, &pool.account_id), + expected_pool_account_balance + ); + assert_eq!(AssetManager::free_balance(pool.collateral, &ALICE), alice_before + fees); + let pool_after = Pools::::get(market_id).unwrap(); + assert_eq!(pool_after.liquidity_shares_manager.fees, 0); + System::assert_last_event( + Event::FeesWithdrawn { who: ALICE, market_id, amount: fees }.into(), + ); + }); +} + +#[test] +fn withdraw_fees_fails_on_pool_not_found() { + ExtBuilder::default().build().execute_with(|| { + let market_id = + create_market(ALICE, BASE_ASSET, MarketType::Scalar(0..=1), ScoringRule::Lmsr); + assert_noop!( + NeoSwaps::withdraw_fees(RuntimeOrigin::signed(ALICE), market_id), + Error::::PoolNotFound + ); + }); +} diff --git a/zrml/neo-swaps/src/traits/distribute_fees.rs b/zrml/neo-swaps/src/traits/distribute_fees.rs new file mode 100644 index 000000000..a6b67ad50 --- /dev/null +++ b/zrml/neo-swaps/src/traits/distribute_fees.rs @@ -0,0 +1,43 @@ +// 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 . + +/// Trait for distributing fees collected from trading to external recipients like the treasury. +pub trait DistributeFees { + type Asset; + type AccountId; + type Balance; + type MarketId; + + /// Deduct and distribute the swap fees of the pool from the specified amount and returns the + /// deducted fees. + /// + /// # Arguments + /// + /// - `market_id`: The market on which the fees are taken. + /// - `asset`: The asset the fee is paid in. + /// - `account`: The account which pays the fees. + /// - `amount`: The gross amount from which fees are deducted. + /// + /// Note that this function is infallible. If distribution is impossible or fails midway, it + /// should return the balance of the already successfully deducted fees. + fn distribute( + market_id: Self::MarketId, + asset: Self::Asset, + account: Self::AccountId, + amount: Self::Balance, + ) -> Self::Balance; +} diff --git a/zrml/neo-swaps/src/traits/liquidity_shares_manager.rs b/zrml/neo-swaps/src/traits/liquidity_shares_manager.rs new file mode 100644 index 000000000..97ad3f333 --- /dev/null +++ b/zrml/neo-swaps/src/traits/liquidity_shares_manager.rs @@ -0,0 +1,49 @@ +// 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::{BalanceOf, Config}; +use sp_runtime::{DispatchError, DispatchResult}; + +/// Trait for managing pool share tokens and distributing fees to LPs according to their share of +/// the total issuance of pool share tokens. +pub trait LiquiditySharesManager { + /// Add `amount` units of pool shares to the account of `who`. + fn join(&mut self, who: &T::AccountId, amount: BalanceOf) -> DispatchResult; + + /// Remove `amount` units of pool shares from the account of `who`. + fn exit(&mut self, who: &T::AccountId, amount: BalanceOf) -> DispatchResult; + + /// Transfer `amount` units of pool shares from `sender` to `receiver`. + fn split( + &mut self, + sender: &T::AccountId, + receiver: &T::AccountId, + amount: BalanceOf, + ) -> DispatchResult; + + /// Deposit a lump sum of fees `amount` to the pool share holders. + fn deposit_fees(&mut self, amount: BalanceOf) -> DispatchResult; + + /// Withdraw and return the share of the fees belonging to `who`. + fn withdraw_fees(&mut self, who: &T::AccountId) -> Result, DispatchError>; + + /// Return the pool shares balance of `who`. + fn shares_of(&self, who: &T::AccountId) -> Result, DispatchError>; + + /// Return the total issuance of pool shares. + fn total_shares(&self) -> Result, DispatchError>; +} diff --git a/zrml/neo-swaps/src/traits/mod.rs b/zrml/neo-swaps/src/traits/mod.rs new file mode 100644 index 000000000..061239e9f --- /dev/null +++ b/zrml/neo-swaps/src/traits/mod.rs @@ -0,0 +1,24 @@ +// 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 . + +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/traits/pool_operations.rs b/zrml/neo-swaps/src/traits/pool_operations.rs new file mode 100644 index 000000000..e187361c8 --- /dev/null +++ b/zrml/neo-swaps/src/traits/pool_operations.rs @@ -0,0 +1,83 @@ +// 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::pallet::{AssetOf, BalanceOf, Config}; +use alloc::vec::Vec; +use sp_runtime::{DispatchError, DispatchResult}; + +/// Trait for LMSR calculations and access to pool data. +pub(crate) trait PoolOperations { + /// Return an ordered vector containing the assets held in the pool. + fn assets(&self) -> Vec>; + + /// Return `true` if the pool holds `asset`. + fn contains(&self, asset: &AssetOf) -> bool; + + /// Return the reserve of `asset` held in the pool. + /// + /// Beware! The reserve need not coincide with the balance in the pool account. + fn reserve_of(&self, asset: &AssetOf) -> Result, DispatchError>; + + /// Perform a checked addition to the balance of `asset`. + fn increase_reserve( + &mut self, + asset: &AssetOf, + increase_amount: &BalanceOf, + ) -> DispatchResult; + + /// Perform a checked subtraction from the balance of `asset`. + fn decrease_reserve( + &mut self, + asset: &AssetOf, + decrease_amount: &BalanceOf, + ) -> DispatchResult; + + /// Calculate the amount received from the swap that is executed when buying (the function + /// `y(x)` from the documentation). + /// + /// Note that `y(x)` does not include the amount of `asset_out` received from buying complete + /// sets and is therefore _not_ the total amount received from the buy. + /// + /// # Parameters + /// + /// - `asset_out`: The outcome being bought. + /// - `amount_in`: The amount of collateral paid. + fn calculate_swap_amount_out_for_buy( + &self, + asset_out: AssetOf, + amount_in: BalanceOf, + ) -> Result, DispatchError>; + + /// Calculate the amount receives from selling an outcome to the pool. + /// + /// # Parameters + /// + /// - `asset_in`: The outcome being sold. + /// - `amount_in`: The amount of `asset_in` sold. + fn calculate_swap_amount_out_for_sell( + &self, + asset_in: AssetOf, + amount_in: BalanceOf, + ) -> Result, DispatchError>; + + /// Calculate the spot price of `asset`. + fn calculate_spot_price(&self, asset: AssetOf) -> Result, DispatchError>; + + /// Calculate the maximum number of units of outcomes anyone is allowed to swap in or out of the + /// pool. + fn calculate_max_amount_in(&self) -> BalanceOf; +} diff --git a/zrml/neo-swaps/src/types/fee_distribution.rs b/zrml/neo-swaps/src/types/fee_distribution.rs new file mode 100644 index 000000000..25ff4a09e --- /dev/null +++ b/zrml/neo-swaps/src/types/fee_distribution.rs @@ -0,0 +1,24 @@ +// 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::{BalanceOf, Config}; + +pub(crate) struct FeeDistribution { + pub(crate) remaining: BalanceOf, + pub(crate) swap_fees: BalanceOf, + pub(crate) external_fees: BalanceOf, +} diff --git a/zrml/neo-swaps/src/types/market_creator_fee.rs b/zrml/neo-swaps/src/types/market_creator_fee.rs new file mode 100644 index 000000000..087627267 --- /dev/null +++ b/zrml/neo-swaps/src/types/market_creator_fee.rs @@ -0,0 +1,59 @@ +// 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 new file mode 100644 index 000000000..dc0c4aaf9 --- /dev/null +++ b/zrml/neo-swaps/src/types/mod.rs @@ -0,0 +1,26 @@ +// 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 . + +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/neo-swaps/src/types/pool.rs b/zrml/neo-swaps/src/types/pool.rs new file mode 100644 index 000000000..c9cec18d8 --- /dev/null +++ b/zrml/neo-swaps/src/types/pool.rs @@ -0,0 +1,135 @@ +// 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::{ + consts::{EXP_NUMERICAL_LIMIT, MAX_ASSETS}, + math::{Math, MathOps}, + pallet::{AssetOf, BalanceOf, Config}, + traits::{LiquiditySharesManager, PoolOperations}, + Error, +}; +use alloc::{collections::BTreeMap, vec::Vec}; +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_runtime::{ + traits::{CheckedAdd, CheckedSub}, + DispatchError, DispatchResult, RuntimeDebug, SaturatedConversion, Saturating, +}; + +#[derive(TypeInfo, Clone, Encode, Eq, Decode, PartialEq, RuntimeDebug)] +#[scale_info(skip_type_params(T))] +pub struct Pool +where + LSM: LiquiditySharesManager, +{ + pub account_id: T::AccountId, + pub reserves: BTreeMap, BalanceOf>, + pub collateral: AssetOf, + pub liquidity_parameter: BalanceOf, + pub liquidity_shares_manager: LSM, + pub swap_fee: BalanceOf, +} + +impl + TypeInfo> PoolOperations for Pool +where + BalanceOf: SaturatedConversion, +{ + fn assets(&self) -> Vec> { + self.reserves.keys().cloned().collect() + } + + fn contains(&self, asset: &AssetOf) -> bool { + self.reserves.contains_key(asset) + } + + fn reserve_of(&self, asset: &AssetOf) -> Result, DispatchError> { + Ok(*self.reserves.get(asset).ok_or(Error::::AssetNotFound)?) + } + + fn increase_reserve( + &mut self, + asset: &AssetOf, + increase_amount: &BalanceOf, + ) -> DispatchResult { + let value = self.reserves.get_mut(asset).ok_or(Error::::AssetNotFound)?; + *value = value.checked_add(increase_amount).ok_or(Error::::MathError)?; + Ok(()) + } + + fn decrease_reserve( + &mut self, + asset: &AssetOf, + decrease_amount: &BalanceOf, + ) -> DispatchResult { + let value = self.reserves.get_mut(asset).ok_or(Error::::AssetNotFound)?; + *value = value.checked_sub(decrease_amount).ok_or(Error::::MathError)?; + Ok(()) + } + + fn calculate_swap_amount_out_for_buy( + &self, + asset_out: AssetOf, + amount_in: BalanceOf, + ) -> Result, DispatchError> { + let reserve = self.reserve_of(&asset_out)?; + Math::::calculate_swap_amount_out_for_buy(reserve, amount_in, self.liquidity_parameter) + } + + fn calculate_swap_amount_out_for_sell( + &self, + asset_in: AssetOf, + amount_in: BalanceOf, + ) -> Result, DispatchError> { + let reserve = self.reserve_of(&asset_in)?; + Math::::calculate_swap_amount_out_for_sell(reserve, amount_in, self.liquidity_parameter) + } + + fn calculate_spot_price(&self, asset: AssetOf) -> Result, DispatchError> { + let reserve = self.reserve_of(&asset)?; + Math::::calculate_spot_price(reserve, self.liquidity_parameter) + } + + fn calculate_max_amount_in(&self) -> BalanceOf { + // Saturation is OK. If this saturates, the maximum amount in is just the numerical limit. + self.liquidity_parameter.saturating_mul(EXP_NUMERICAL_LIMIT.saturated_into()) + } +} + +impl> MaxEncodedLen for Pool +where + T::AccountId: MaxEncodedLen, + AssetOf: MaxEncodedLen, + BalanceOf: MaxEncodedLen, + LSM: MaxEncodedLen, +{ + fn max_encoded_len() -> usize { + let len_account_id = T::AccountId::max_encoded_len(); + let len_reserves = 1usize.saturating_add((MAX_ASSETS as usize).saturating_mul( + >::max_encoded_len().saturating_add(BalanceOf::::max_encoded_len()), + )); + let len_collateral = AssetOf::::max_encoded_len(); + let len_liquidity_parameter = BalanceOf::::max_encoded_len(); + let len_liquidity_shares_manager = LSM::max_encoded_len(); + let len_swap_fee = BalanceOf::::max_encoded_len(); + len_account_id + .saturating_add(len_reserves) + .saturating_add(len_collateral) + .saturating_add(len_liquidity_parameter) + .saturating_add(len_liquidity_shares_manager) + .saturating_add(len_swap_fee) + } +} diff --git a/zrml/neo-swaps/src/types/solo_lp.rs b/zrml/neo-swaps/src/types/solo_lp.rs new file mode 100644 index 000000000..02cbed618 --- /dev/null +++ b/zrml/neo-swaps/src/types/solo_lp.rs @@ -0,0 +1,88 @@ +// 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::LiquiditySharesManager, BalanceOf, Config, Error}; +use frame_support::ensure; +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_runtime::{ + traits::{AtLeast32BitUnsigned, CheckedAdd, CheckedSub, Zero}, + DispatchError, DispatchResult, RuntimeDebug, +}; + +#[derive(TypeInfo, MaxEncodedLen, Clone, Encode, Eq, Decode, PartialEq, RuntimeDebug)] +#[scale_info(skip_type_params(T))] +pub struct SoloLp { + pub owner: T::AccountId, + pub total_shares: BalanceOf, + pub fees: BalanceOf, +} + +impl SoloLp { + pub(crate) fn new(owner: T::AccountId, total_shares: BalanceOf) -> SoloLp { + SoloLp { owner, total_shares, fees: Zero::zero() } + } +} + +impl LiquiditySharesManager for SoloLp +where + T::AccountId: PartialEq, + BalanceOf: AtLeast32BitUnsigned + Copy + Zero, +{ + fn join(&mut self, who: &T::AccountId, shares: BalanceOf) -> DispatchResult { + ensure!(*who == self.owner, Error::::NotAllowed); + self.total_shares = self.total_shares.checked_add(&shares).ok_or(Error::::MathError)?; + Ok(()) + } + + fn exit(&mut self, who: &T::AccountId, shares: BalanceOf) -> DispatchResult { + ensure!(*who == self.owner, Error::::NotAllowed); + ensure!(shares <= self.total_shares, Error::::InsufficientPoolShares); + self.total_shares = self.total_shares.checked_sub(&shares).ok_or(Error::::MathError)?; + Ok(()) + } + + fn split( + &mut self, + _sender: &T::AccountId, + _receiver: &T::AccountId, + _amount: BalanceOf, + ) -> DispatchResult { + Err(Error::::NotImplemented.into()) + } + + fn deposit_fees(&mut self, amount: BalanceOf) -> DispatchResult { + self.fees = self.fees.checked_add(&amount).ok_or(Error::::MathError)?; + Ok(()) + } + + fn withdraw_fees(&mut self, who: &T::AccountId) -> Result, DispatchError> { + ensure!(*who == self.owner, Error::::NotAllowed); + let result = self.fees; + self.fees = Zero::zero(); + Ok(result) + } + + fn shares_of(&self, who: &T::AccountId) -> Result, DispatchError> { + ensure!(*who == self.owner, Error::::NotAllowed); + Ok(self.total_shares) + } + + fn total_shares(&self) -> Result, DispatchError> { + Ok(self.total_shares) + } +} diff --git a/zrml/neo-swaps/src/weights.rs b/zrml/neo-swaps/src/weights.rs new file mode 100644 index 000000000..618636182 --- /dev/null +++ b/zrml/neo-swaps/src/weights.rs @@ -0,0 +1,163 @@ +// Copyright 2022-2023 Forecasting Technologies LTD. +// Copyright 2021-2022 Zeitgeist PM LLC. +// +// This file is part of Zeitgeist. +// +// Zeitgeist is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at +// your option) any later version. +// +// Zeitgeist is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +//! Autogenerated weights for zrml_neo_swaps +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev +//! DATE: `2023-09-15`, STEPS: `2`, REPEAT: `0`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `mkl-mac`, CPU: `` +//! EXECUTION: `Some(Native)`, WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` + +// Executed Command: +// ./target/release/zeitgeist +// benchmark +// pallet +// --chain=dev +// --steps=2 +// --repeat=0 +// --pallet=zrml_neo_swaps +// --extrinsic=* +// --execution=native +// --wasm-execution=compiled +// --heap-pages=4096 +// --template=./misc/weight_template.hbs +// --header=./HEADER_GPL3 +// --output=./zrml/neo-swaps/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_neo_swaps (automatically generated) +pub trait WeightInfoZeitgeist { + fn buy() -> Weight; + fn sell() -> Weight; + fn join() -> Weight; + fn exit() -> Weight; + fn withdraw_fees() -> Weight; + fn deploy_pool() -> Weight; +} + +/// Weight functions for zrml_neo_swaps (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(541), added: 3016, mode: MaxEncodedLen) + /// Storage: NeoSwaps Pools (r:1 w:1) + /// Proof: NeoSwaps Pools (max_values: None, max_size: Some(4652), added: 7127, mode: MaxEncodedLen) + /// Storage: System Account (r:2 w:2) + /// Proof: System Account (max_values: None, max_size: Some(132), added: 2607, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:3 w:3) + /// Proof: Tokens Accounts (max_values: None, max_size: Some(123), added: 2598, mode: MaxEncodedLen) + /// Storage: Tokens TotalIssuance (r:2 w:2) + /// Proof: Tokens TotalIssuance (max_values: None, max_size: Some(43), added: 2518, mode: MaxEncodedLen) + fn buy() -> Weight { + // Proof Size summary in bytes: + // Measured: `2868` + // Estimated: `28187` + // Minimum execution time: 234_000 nanoseconds. + Weight::from_parts(234_000_000, 28187) + .saturating_add(T::DbWeight::get().reads(9_u64)) + .saturating_add(T::DbWeight::get().writes(8_u64)) + } + /// Storage: MarketCommons Markets (r:1 w:0) + /// Proof: MarketCommons Markets (max_values: None, max_size: Some(541), added: 3016, mode: MaxEncodedLen) + /// Storage: NeoSwaps Pools (r:1 w:1) + /// Proof: NeoSwaps Pools (max_values: None, max_size: Some(4652), added: 7127, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:3 w:3) + /// Proof: Tokens Accounts (max_values: None, max_size: Some(123), added: 2598, mode: MaxEncodedLen) + /// Storage: System Account (r:2 w:2) + /// Proof: System Account (max_values: None, max_size: Some(132), added: 2607, mode: MaxEncodedLen) + /// Storage: Tokens TotalIssuance (r:2 w:2) + /// Proof: Tokens TotalIssuance (max_values: None, max_size: Some(43), added: 2518, mode: MaxEncodedLen) + fn sell() -> Weight { + // Proof Size summary in bytes: + // Measured: `3034` + // Estimated: `28187` + // Minimum execution time: 296_000 nanoseconds. + Weight::from_parts(296_000_000, 28187) + .saturating_add(T::DbWeight::get().reads(9_u64)) + .saturating_add(T::DbWeight::get().writes(8_u64)) + } + /// Storage: MarketCommons Markets (r:1 w:0) + /// Proof: MarketCommons Markets (max_values: None, max_size: Some(541), added: 3016, mode: MaxEncodedLen) + /// Storage: NeoSwaps Pools (r:1 w:1) + /// Proof: NeoSwaps Pools (max_values: None, max_size: Some(4652), added: 7127, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:4 w:4) + /// Proof: Tokens Accounts (max_values: None, max_size: Some(123), added: 2598, mode: MaxEncodedLen) + fn join() -> Weight { + // Proof Size summary in bytes: + // Measured: `2756` + // Estimated: `20535` + // Minimum execution time: 118_000 nanoseconds. + Weight::from_parts(118_000_000, 20535) + .saturating_add(T::DbWeight::get().reads(6_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: MarketCommons Markets (r:1 w:0) + /// Proof: MarketCommons Markets (max_values: None, max_size: Some(541), added: 3016, mode: MaxEncodedLen) + /// Storage: NeoSwaps Pools (r:1 w:1) + /// Proof: NeoSwaps Pools (max_values: None, max_size: Some(4652), added: 7127, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:4 w:4) + /// Proof: Tokens Accounts (max_values: None, max_size: Some(123), added: 2598, mode: MaxEncodedLen) + /// Storage: System Account (r:1 w:0) + /// Proof: System Account (max_values: None, max_size: Some(132), added: 2607, mode: MaxEncodedLen) + fn exit() -> Weight { + // Proof Size summary in bytes: + // Measured: `2524` + // Estimated: `23142` + // Minimum execution time: 116_000 nanoseconds. + Weight::from_parts(116_000_000, 23142) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: NeoSwaps Pools (r:1 w:1) + /// Proof: NeoSwaps Pools (max_values: None, max_size: Some(4652), added: 7127, mode: MaxEncodedLen) + /// Storage: System Account (r:1 w:1) + /// Proof: System Account (max_values: None, max_size: Some(132), added: 2607, mode: MaxEncodedLen) + fn withdraw_fees() -> Weight { + // Proof Size summary in bytes: + // Measured: `1819` + // Estimated: `9734` + // Minimum execution time: 73_000 nanoseconds. + Weight::from_parts(73_000_000, 9734) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: MarketCommons Markets (r:1 w:0) + /// Proof: MarketCommons Markets (max_values: None, max_size: Some(541), added: 3016, mode: MaxEncodedLen) + /// Storage: NeoSwaps Pools (r:1 w:1) + /// Proof: NeoSwaps Pools (max_values: None, max_size: Some(4652), added: 7127, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:4 w:4) + /// Proof: Tokens Accounts (max_values: None, max_size: Some(123), added: 2598, mode: MaxEncodedLen) + /// Storage: System Account (r:1 w:1) + /// Proof: System Account (max_values: None, max_size: Some(132), added: 2607, mode: MaxEncodedLen) + fn deploy_pool() -> Weight { + // Proof Size summary in bytes: + // Measured: `2241` + // Estimated: `23142` + // Minimum execution time: 149_000 nanoseconds. + Weight::from_parts(149_000_000, 23142) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(6_u64)) + } +} diff --git a/zrml/prediction-markets/src/benchmarks.rs b/zrml/prediction-markets/src/benchmarks.rs index 777bff022..b914ce9a3 100644 --- a/zrml/prediction-markets/src/benchmarks.rs +++ b/zrml/prediction-markets/src/benchmarks.rs @@ -154,7 +154,8 @@ fn setup_redeem_shares_common( panic!("setup_redeem_shares_common: Unsupported market type: {market_type:?}"); } - Pallet::::do_buy_complete_set(caller.clone(), market_id, LIQUIDITY.saturated_into())?; + Call::::buy_complete_set { market_id, amount: LIQUIDITY.saturated_into() } + .dispatch_bypass_filter(RawOrigin::Signed(caller.clone()).into())?; let close_origin = T::CloseOrigin::try_successful_origin().unwrap(); let resolve_origin = T::ResolveOrigin::try_successful_origin().unwrap(); Call::::admin_move_market_to_closed { market_id }.dispatch_bypass_filter(close_origin)?; @@ -1286,6 +1287,44 @@ benchmarks! { let _ = >::process_subsidy_collecting_markets(current_block, current_time); } + create_market_and_deploy_pool { + let m in 0..63; // Number of markets closing on the same block. + + let base_asset = Asset::Ztg; + let range_start = (5 * MILLISECS_PER_BLOCK) as u64; + let range_end = (100 * MILLISECS_PER_BLOCK) as u64; + let period = MarketPeriod::Timestamp(range_start..range_end); + let market_type = MarketType::Categorical(2); + let (caller, oracle, deadlines, metadata) = create_market_common_parameters::()?; + let price = (BASE / 2).saturated_into(); + let amount = (10u128 * BASE).saturated_into(); + + ::AssetManager::deposit( + base_asset, + &caller, + amount, + )?; + for i in 0..m { + MarketIdsPerCloseTimeFrame::::try_mutate( + Pallet::::calculate_time_frame_of_moment(range_end), + |ids| ids.try_push(i.into()), + ).unwrap(); + } + }: _( + RawOrigin::Signed(caller), + base_asset, + Perbill::zero(), + oracle, + period, + deadlines, + metadata, + MarketType::Categorical(2), + Some(MarketDisputeMechanism::Court), + amount, + vec![price, price], + (BASE / 100).saturated_into() + ) + impl_benchmark_test_suite!( PredictionMarket, crate::mock::ExtBuilder::default().build(), diff --git a/zrml/prediction-markets/src/lib.rs b/zrml/prediction-markets/src/lib.rs index 8e0fbf90c..d798345d9 100644 --- a/zrml/prediction-markets/src/lib.rs +++ b/zrml/prediction-markets/src/lib.rs @@ -40,6 +40,7 @@ mod pallet { dispatch::{DispatchResultWithPostInfo, Pays, Weight}, ensure, log, pallet_prelude::{ConstU32, StorageMap, StorageValue, ValueQuery}, + require_transactional, storage::{with_transaction, TransactionOutcome}, traits::{ tokens::BalanceStatus, Currency, EnsureOrigin, Get, Hooks, Imbalance, IsType, @@ -61,7 +62,8 @@ mod pallet { use zeitgeist_primitives::{ constants::MILLISECS_PER_BLOCK, traits::{ - DisputeApi, DisputeMaxWeightApi, DisputeResolutionApi, Swaps, ZeitgeistAssetManager, + CompleteSetOperationsApi, DeployPoolApi, DisputeApi, DisputeMaxWeightApi, + DisputeResolutionApi, Swaps, ZeitgeistAssetManager, }, types::{ Asset, Bond, Deadlines, GlobalDisputeItem, Market, MarketBonds, MarketCreation, @@ -535,7 +537,7 @@ mod pallet { ); match m.scoring_rule { - ScoringRule::CPMM | ScoringRule::Orderbook => { + ScoringRule::CPMM | ScoringRule::Lmsr | ScoringRule::Orderbook => { m.status = MarketStatus::Active; } ScoringRule::RikiddoSigmoidFeeMarketEma => { @@ -623,7 +625,11 @@ mod pallet { #[pallet::compact] amount: BalanceOf, ) -> DispatchResultWithPostInfo { let sender = ensure_signed(origin)?; - Self::do_buy_complete_set(sender, market_id, amount) + Self::do_buy_complete_set(sender, market_id, amount)?; + let market = >::market(&market_id)?; + let assets = Self::outcome_assets(market_id, &market); + let assets_len: u32 = assets.len().saturated_into(); + Ok(Some(T::WeightInfo::buy_complete_set(assets_len)).into()) } /// Dispute on a market that has been reported or already disputed. @@ -793,57 +799,20 @@ mod pallet { ) -> DispatchResultWithPostInfo { // TODO(#787): Handle Rikiddo benchmarks! let sender = ensure_signed(origin)?; - - let bonds = match creation { - MarketCreation::Advised => MarketBonds { - creation: Some(Bond::new(sender.clone(), T::AdvisoryBond::get())), - oracle: Some(Bond::new(sender.clone(), T::OracleBond::get())), - ..Default::default() - }, - MarketCreation::Permissionless => MarketBonds { - creation: Some(Bond::new(sender.clone(), T::ValidityBond::get())), - oracle: Some(Bond::new(sender.clone(), T::OracleBond::get())), - ..Default::default() - }, - }; - - let market = Self::construct_market( + let (ids_len, _) = Self::do_create_market( + sender, base_asset, - sender.clone(), creator_fee, oracle, period, deadlines, metadata, - creation.clone(), + creation, market_type, dispute_mechanism, scoring_rule, - None, - None, - bonds.clone(), )?; - - T::AssetManager::reserve_named( - &Self::reserve_id(), - Asset::Ztg, - &sender, - bonds.total_amount_bonded(&sender), - )?; - - let market_id = >::push_market(market.clone())?; - let market_account = >::market_account(market_id); - let mut extra_weight = Weight::zero(); - - if market.status == MarketStatus::CollectingSubsidy { - extra_weight = Self::start_subsidy(&market, market_id)?; - } - - let ids_amount: u32 = Self::insert_auto_close(&market_id)?; - - Self::deposit_event(Event::MarketCreated(market_id, market_account, market)); - - Ok(Some(T::WeightInfo::create_market(ids_amount).saturating_add(extra_weight)).into()) + Ok(Some(T::WeightInfo::create_market(ids_len)).into()) } /// Edit a proposed market for which request is made. @@ -1331,9 +1300,7 @@ mod pallet { /// /// Complexity: `O(n)`, where `n` is the number of assets for a categorical market. #[pallet::call_index(15)] - #[pallet::weight( - T::WeightInfo::sell_complete_set(T::MaxCategories::get().into()) - )] + #[pallet::weight(T::WeightInfo::sell_complete_set(T::MaxCategories::get().into()))] #[transactional] pub fn sell_complete_set( origin: OriginFor, @@ -1341,41 +1308,9 @@ mod pallet { #[pallet::compact] amount: BalanceOf, ) -> DispatchResultWithPostInfo { let sender = ensure_signed(origin)?; - ensure!(amount != BalanceOf::::zero(), Error::::ZeroAmount); - + Self::do_sell_complete_set(sender, market_id, amount)?; let market = >::market(&market_id)?; - ensure!( - matches!(market.scoring_rule, ScoringRule::CPMM | ScoringRule::Orderbook), - Error::::InvalidScoringRule - ); - Self::ensure_market_is_active(&market)?; - - let market_account = >::market_account(market_id); - ensure!( - T::AssetManager::free_balance(market.base_asset, &market_account) >= amount, - "Market account does not have sufficient reserves.", - ); - let assets = Self::outcome_assets(market_id, &market); - - // verify first. - for asset in assets.iter() { - // Ensures that the sender has sufficient amount of each - // share in the set. - ensure!( - T::AssetManager::free_balance(*asset, &sender) >= amount, - Error::::InsufficientShareBalance, - ); - } - - // write last. - for asset in assets.iter() { - T::AssetManager::slash(*asset, &sender, amount); - } - - T::AssetManager::transfer(market.base_asset, &market_account, &sender, amount)?; - - Self::deposit_event(Event::SoldCompleteSet(market_id, amount, sender)); let assets_len: u32 = assets.len().saturated_into(); Ok(Some(T::WeightInfo::sell_complete_set(assets_len)).into()) } @@ -1483,6 +1418,50 @@ mod pallet { Ok(Some(T::WeightInfo::start_global_dispute(ids_len_1, ids_len_2)).into()) } + + /// Create a market, deploy a LMSR pool, and buy outcome tokens and provide liquidity to the + /// market. + /// + /// # Weight + /// + /// `O(n)` where `n` is the number of markets which close on the same block, plus the + /// resources consumed by `DeployPool::create_pool`. In the standard implementation using + /// neo-swaps, this is `O(m)` where `m` is the number of assets in the market. + #[pallet::weight(T::WeightInfo::create_market_and_deploy_pool(CacheSize::get()))] + #[transactional] + #[pallet::call_index(17)] + pub fn create_market_and_deploy_pool( + origin: OriginFor, + base_asset: Asset>, + creator_fee: Perbill, + oracle: T::AccountId, + period: MarketPeriod>, + deadlines: Deadlines, + metadata: MultiHash, + market_type: MarketType, + dispute_mechanism: Option, + #[pallet::compact] amount: BalanceOf, + spot_prices: Vec>, + #[pallet::compact] swap_fee: BalanceOf, + ) -> DispatchResultWithPostInfo { + let who = ensure_signed(origin)?; + let (ids_len, market_id) = Self::do_create_market( + who.clone(), + base_asset, + creator_fee, + oracle, + period, + deadlines, + metadata, + MarketCreation::Permissionless, + market_type, + dispute_mechanism, + ScoringRule::Lmsr, + )?; + Self::do_buy_complete_set(who.clone(), market_id, amount)?; + T::DeployPool::deploy_pool(who, market_id, amount, spot_prices, swap_fee)?; + Ok(Some(T::WeightInfo::create_market_and_deploy_pool(ids_len)).into()) + } } #[pallet::config] @@ -1539,6 +1518,13 @@ mod pallet { Origin = Self::RuntimeOrigin, >; + /// Used to deploy neo-swaps pools. + type DeployPool: DeployPoolApi< + AccountId = Self::AccountId, + Balance = BalanceOf, + MarketId = MarketIdOf, + >; + /// The origin that is allowed to destroy markets. type DestroyOrigin: EnsureOrigin; @@ -2069,6 +2055,71 @@ mod pallet { Ok(()) } + #[require_transactional] + fn do_create_market( + who: T::AccountId, + base_asset: Asset>, + creator_fee: Perbill, + oracle: T::AccountId, + period: MarketPeriod>, + deadlines: Deadlines, + metadata: MultiHash, + creation: MarketCreation, + market_type: MarketType, + dispute_mechanism: Option, + scoring_rule: ScoringRule, + ) -> Result<(u32, MarketIdOf), DispatchError> { + let bonds = match creation { + MarketCreation::Advised => MarketBonds { + creation: Some(Bond::new(who.clone(), T::AdvisoryBond::get())), + oracle: Some(Bond::new(who.clone(), T::OracleBond::get())), + ..Default::default() + }, + MarketCreation::Permissionless => MarketBonds { + creation: Some(Bond::new(who.clone(), T::ValidityBond::get())), + oracle: Some(Bond::new(who.clone(), T::OracleBond::get())), + ..Default::default() + }, + }; + + let market = Self::construct_market( + base_asset, + who.clone(), + creator_fee, + oracle, + period, + deadlines, + metadata, + creation.clone(), + market_type, + dispute_mechanism, + scoring_rule, + None, + None, + bonds.clone(), + )?; + + T::AssetManager::reserve_named( + &Self::reserve_id(), + Asset::Ztg, + &who, + bonds.total_amount_bonded(&who), + )?; + + let market_id = >::push_market(market.clone())?; + let market_account = >::market_account(market_id); + + if market.status == MarketStatus::CollectingSubsidy { + let _ = Self::start_subsidy(&market, market_id)?; + } + + let ids_amount: u32 = Self::insert_auto_close(&market_id)?; + + Self::deposit_event(Event::MarketCreated(market_id, market_account, market)); + + Ok((ids_amount, market_id)) + } + pub fn outcome_assets( market_id: MarketIdOf, market: &MarketOf, @@ -2238,11 +2289,60 @@ mod pallet { Ok(()) } + #[require_transactional] + pub(crate) fn do_sell_complete_set( + who: T::AccountId, + market_id: MarketIdOf, + amount: BalanceOf, + ) -> DispatchResult { + ensure!(amount != BalanceOf::::zero(), Error::::ZeroAmount); + + let market = >::market(&market_id)?; + ensure!( + matches!( + market.scoring_rule, + ScoringRule::CPMM | ScoringRule::Lmsr | ScoringRule::Orderbook + ), + Error::::InvalidScoringRule + ); + Self::ensure_market_is_active(&market)?; + + let market_account = >::market_account(market_id); + ensure!( + T::AssetManager::free_balance(market.base_asset, &market_account) >= amount, + "Market account does not have sufficient reserves.", + ); + + let assets = Self::outcome_assets(market_id, &market); + + // verify first. + for asset in assets.iter() { + // Ensures that the sender has sufficient amount of each + // share in the set. + ensure!( + T::AssetManager::free_balance(*asset, &who) >= amount, + Error::::InsufficientShareBalance, + ); + } + + // write last. + for asset in assets.iter() { + T::AssetManager::slash(*asset, &who, amount); + } + + T::AssetManager::transfer(market.base_asset, &market_account, &who, amount)?; + + Self::deposit_event(Event::SoldCompleteSet(market_id, amount, who)); + + Ok(()) + } + + #[require_transactional] pub(crate) fn do_buy_complete_set( who: T::AccountId, market_id: MarketIdOf, amount: BalanceOf, - ) -> DispatchResultWithPostInfo { + ) -> DispatchResult { ensure!(amount != BalanceOf::::zero(), Error::::ZeroAmount); let market = >::market(&market_id)?; ensure!( @@ -2250,7 +2350,10 @@ mod pallet { Error::::NotEnoughBalance ); ensure!( - matches!(market.scoring_rule, ScoringRule::CPMM | ScoringRule::Orderbook), + matches!( + market.scoring_rule, + ScoringRule::CPMM | ScoringRule::Lmsr | ScoringRule::Orderbook + ), Error::::InvalidScoringRule ); Self::ensure_market_is_active(&market)?; @@ -2265,8 +2368,7 @@ mod pallet { Self::deposit_event(Event::BoughtCompleteSet(market_id, amount, who)); - let assets_len: u32 = assets.len().saturated_into(); - Ok(Some(T::WeightInfo::buy_complete_set(assets_len)).into()) + Ok(()) } pub(crate) fn do_reject_market( @@ -3073,7 +3175,9 @@ mod pallet { } let status: MarketStatus = match creation { MarketCreation::Permissionless => match scoring_rule { - ScoringRule::CPMM | ScoringRule::Orderbook => MarketStatus::Active, + ScoringRule::CPMM | ScoringRule::Lmsr | ScoringRule::Orderbook => { + MarketStatus::Active + } ScoringRule::RikiddoSigmoidFeeMarketEma => MarketStatus::CollectingSubsidy, }, MarketCreation::Advised => MarketStatus::Proposed, @@ -3256,4 +3360,29 @@ mod pallet { remove_auto_resolve::(market_id, resolve_at) } } + + impl CompleteSetOperationsApi for Pallet + where + T: Config, + { + type AccountId = T::AccountId; + type Balance = BalanceOf; + type MarketId = MarketIdOf; + + fn buy_complete_set( + who: Self::AccountId, + market_id: Self::MarketId, + amount: Self::Balance, + ) -> DispatchResult { + Self::do_buy_complete_set(who, market_id, amount) + } + + fn sell_complete_set( + who: Self::AccountId, + market_id: Self::MarketId, + amount: Self::Balance, + ) -> DispatchResult { + Self::do_sell_complete_set(who, market_id, amount) + } + } } diff --git a/zrml/prediction-markets/src/mock.rs b/zrml/prediction-markets/src/mock.rs index 016ab3e76..0abb8b8dd 100644 --- a/zrml/prediction-markets/src/mock.rs +++ b/zrml/prediction-markets/src/mock.rs @@ -34,34 +34,32 @@ use sp_arithmetic::per_things::Percent; use sp_runtime::{ testing::Header, traits::{BlakeTwo256, IdentityLookup}, + DispatchError, DispatchResult, }; +use std::cell::RefCell; use substrate_fixed::{types::extra::U33, FixedI128, FixedU128}; use zeitgeist_primitives::{ constants::mock::{ - AggregationPeriod, AppealBond, AppealPeriod, AuthorizedPalletId, BalanceFractionalDecimals, - BlockHashCount, BlocksPerYear, CorrectionPeriod, CourtPalletId, ExistentialDeposit, - ExistentialDeposits, ExitFee, GetNativeCurrencyId, InflationPeriod, - LiquidityMiningPalletId, LockId, MaxAppeals, MaxApprovals, MaxAssets, MaxCategories, - MaxCourtParticipants, MaxCreatorFee, MaxDelegations, MaxDisputeDuration, MaxDisputes, - MaxEditReasonLen, MaxGracePeriod, MaxInRatio, MaxMarketLifetime, MaxOracleDuration, - MaxOutRatio, MaxRejectReasonLen, MaxReserves, MaxSelectedDraws, MaxSubsidyPeriod, - MaxSwapFee, MaxTotalWeight, MaxWeight, MinAssets, MinCategories, MinDisputeDuration, - MinJurorStake, MinOracleDuration, MinSubsidy, MinSubsidyPeriod, MinWeight, MinimumPeriod, - OutcomeBond, OutcomeFactor, OutsiderBond, PmPalletId, RequestInterval, - SimpleDisputesPalletId, SwapsPalletId, TreasuryPalletId, VotePeriod, BASE, CENT, - MILLISECS_PER_BLOCK, + AddOutcomePeriod, AggregationPeriod, AppealBond, AppealPeriod, AuthorizedPalletId, + BalanceFractionalDecimals, BlockHashCount, BlocksPerYear, CorrectionPeriod, CourtPalletId, + ExistentialDeposit, ExistentialDeposits, ExitFee, GdVotingPeriod, GetNativeCurrencyId, + GlobalDisputeLockId, GlobalDisputesPalletId, InflationPeriod, LiquidityMiningPalletId, + LockId, MaxAppeals, MaxApprovals, MaxAssets, MaxCategories, MaxCourtParticipants, + MaxCreatorFee, MaxDelegations, MaxDisputeDuration, MaxDisputes, MaxEditReasonLen, + MaxGlobalDisputeVotes, MaxGracePeriod, MaxInRatio, MaxMarketLifetime, MaxOracleDuration, + MaxOutRatio, MaxOwners, MaxRejectReasonLen, MaxReserves, MaxSelectedDraws, + MaxSubsidyPeriod, MaxSwapFee, MaxTotalWeight, MaxWeight, MinAssets, MinCategories, + MinDisputeDuration, MinJurorStake, MinOracleDuration, MinOutcomeVoteAmount, MinSubsidy, + MinSubsidyPeriod, MinWeight, MinimumPeriod, OutcomeBond, OutcomeFactor, OutsiderBond, + PmPalletId, RemoveKeysLimit, RequestInterval, SimpleDisputesPalletId, SwapsPalletId, + TreasuryPalletId, VotePeriod, VotingOutcomeFee, BASE, CENT, MILLISECS_PER_BLOCK, }, + traits::DeployPoolApi, types::{ AccountIdTest, Amount, Asset, Balance, BasicCurrencyAdapter, BlockNumber, BlockTest, CurrencyId, Hash, Index, MarketId, Moment, PoolId, SerdeWrapper, UncheckedExtrinsicTest, }, }; - -use zeitgeist_primitives::constants::mock::{ - AddOutcomePeriod, GdVotingPeriod, GlobalDisputeLockId, GlobalDisputesPalletId, - MaxGlobalDisputeVotes, MaxOwners, MinOutcomeVoteAmount, RemoveKeysLimit, VotingOutcomeFee, -}; - use zrml_rikiddo::types::{EmaMarketVolume, FeeSigmoid, RikiddoSigmoidMV}; pub const ALICE: AccountIdTest = 0; @@ -74,6 +72,76 @@ pub const SUDO: AccountIdTest = 69; pub const INITIAL_BALANCE: u128 = 1_000 * BASE; +#[allow(unused)] +pub struct DeployPoolMock; + +#[allow(unused)] +#[derive(Clone)] +pub struct DeployPoolArgs { + who: AccountIdTest, + market_id: MarketId, + amount: Balance, + swap_prices: Vec, + swap_fee: Balance, +} + +thread_local! { + pub static DEPLOY_POOL_CALL_DATA: RefCell> = RefCell::new(vec![]); + pub static DEPLOY_POOL_RETURN_VALUE: RefCell = RefCell::new(Ok(())); +} + +#[allow(unused)] +impl DeployPoolApi for DeployPoolMock { + type AccountId = AccountIdTest; + type Balance = Balance; + type MarketId = MarketId; + + fn deploy_pool( + who: Self::AccountId, + market_id: Self::MarketId, + amount: Self::Balance, + swap_prices: Vec, + swap_fee: Self::Balance, + ) -> DispatchResult { + DEPLOY_POOL_CALL_DATA.with(|value| { + value.borrow_mut().push(DeployPoolArgs { + who, + market_id, + amount, + swap_prices, + swap_fee, + }) + }); + DEPLOY_POOL_RETURN_VALUE.with(|v| *v.borrow()) + } +} + +#[allow(unused)] +impl DeployPoolMock { + pub fn called_once_with( + who: AccountIdTest, + market_id: MarketId, + amount: Balance, + swap_prices: Vec, + swap_fee: Balance, + ) -> bool { + if DEPLOY_POOL_CALL_DATA.with(|value| value.borrow().len()) != 1 { + return false; + } + let args = DEPLOY_POOL_CALL_DATA.with(|value| value.borrow()[0].clone()); + args.who == who + && args.market_id == market_id + && args.amount == amount + && args.swap_prices == swap_prices + && args.swap_fee == swap_fee + } + + pub fn return_error() { + DEPLOY_POOL_RETURN_VALUE + .with(|value| *value.borrow_mut() = Err(DispatchError::Other("neo-swaps"))); + } +} + ord_parameter_types! { pub const Sudo: AccountIdTest = SUDO; } @@ -123,6 +191,7 @@ impl crate::Config for Runtime { type MaxCreatorFee = MaxCreatorFee; type Court = Court; type DestroyOrigin = EnsureSignedBy; + type DeployPool = DeployPoolMock; type DisputeBond = DisputeBond; type RuntimeEvent = RuntimeEvent; type GlobalDisputes = GlobalDisputes; @@ -379,6 +448,7 @@ pub struct ExtBuilder { impl Default for ExtBuilder { fn default() -> Self { + DEPLOY_POOL_CALL_DATA.with(|value| value.borrow_mut().clear()); Self { balances: vec![ (ALICE, INITIAL_BALANCE), diff --git a/zrml/prediction-markets/src/tests.rs b/zrml/prediction-markets/src/tests.rs index ab152fb8d..9dc7b9085 100644 --- a/zrml/prediction-markets/src/tests.rs +++ b/zrml/prediction-markets/src/tests.rs @@ -5668,6 +5668,76 @@ fn create_market_sets_the_correct_market_parameters_and_reserves_the_correct_amo }); } +#[test] +fn create_market_and_deploy_pool_works() { + ExtBuilder::default().build().execute_with(|| { + let creator = ALICE; + let creator_fee = Perbill::from_parts(1); + let oracle = BOB; + let period = MarketPeriod::Block(1..2); + let deadlines = Deadlines { + grace_period: 1, + oracle_duration: ::MinOracleDuration::get() + 2, + dispute_duration: ::MinDisputeDuration::get() + 3, + }; + let metadata = gen_metadata(0x99); + let MultiHash::Sha3_384(multihash) = metadata; + let market_type = MarketType::Categorical(7); + let dispute_mechanism = Some(MarketDisputeMechanism::Authorized); + let amount = 1234567890; + let swap_prices = vec![50 * CENT, 50 * CENT]; + let swap_fee = CENT; + let market_id = 0; + assert_ok!(PredictionMarkets::create_market_and_deploy_pool( + RuntimeOrigin::signed(creator), + Asset::Ztg, + creator_fee, + oracle, + period.clone(), + deadlines, + metadata, + market_type.clone(), + dispute_mechanism.clone(), + amount, + swap_prices.clone(), + swap_fee, + )); + let market = MarketCommons::market(&0).unwrap(); + let bonds = MarketBonds { + creation: Some(Bond::new(ALICE, ::ValidityBond::get())), + oracle: Some(Bond::new(ALICE, ::OracleBond::get())), + outsider: None, + dispute: None, + }; + assert_eq!(market.creator, creator); + assert_eq!(market.creation, MarketCreation::Permissionless); + assert_eq!(market.creator_fee, creator_fee); + assert_eq!(market.oracle, oracle); + assert_eq!(market.metadata, multihash); + assert_eq!(market.market_type, market_type); + assert_eq!(market.period, period); + assert_eq!(market.deadlines, deadlines); + assert_eq!(market.scoring_rule, ScoringRule::Lmsr); + assert_eq!(market.status, MarketStatus::Active); + assert_eq!(market.report, None); + assert_eq!(market.resolved_outcome, None); + assert_eq!(market.dispute_mechanism, dispute_mechanism); + assert_eq!(market.bonds, bonds); + // Check that the correct amount of full sets were bought. + assert_eq!( + AssetManager::free_balance(Asset::CategoricalOutcome(market_id, 0), &ALICE), + amount + ); + assert!(DeployPoolMock::called_once_with( + creator, + market_id, + amount, + swap_prices, + swap_fee + )); + }); +} + #[test] fn create_cpmm_market_and_deploy_assets_sets_the_correct_market_parameters_and_reserves_the_correct_amount() { @@ -5727,6 +5797,44 @@ fn create_cpmm_market_and_deploy_assets_sets_the_correct_market_parameters_and_r }); } +#[test] +fn create_market_and_deploy_pool_errors() { + ExtBuilder::default().build().execute_with(|| { + let creator = ALICE; + let oracle = BOB; + let period = MarketPeriod::Block(1..2); + let deadlines = Deadlines { + grace_period: 1, + oracle_duration: ::MinOracleDuration::get() + 2, + dispute_duration: ::MinDisputeDuration::get() + 3, + }; + let metadata = gen_metadata(0x99); + let market_type = MarketType::Categorical(7); + let dispute_mechanism = Some(MarketDisputeMechanism::Authorized); + let amount = 1234567890; + let swap_prices = vec![50 * CENT, 50 * CENT]; + let swap_fee = CENT; + DeployPoolMock::return_error(); + assert_noop!( + PredictionMarkets::create_market_and_deploy_pool( + RuntimeOrigin::signed(creator), + Asset::Ztg, + Perbill::zero(), + oracle, + period.clone(), + deadlines, + metadata, + market_type.clone(), + dispute_mechanism.clone(), + amount, + swap_prices.clone(), + swap_fee, + ), + DispatchError::Other("neo-swaps"), + ); + }); +} + #[test] fn create_market_functions_respect_fee_boundaries() { ExtBuilder::default().build().execute_with(|| { diff --git a/zrml/prediction-markets/src/weights.rs b/zrml/prediction-markets/src/weights.rs index 3ef74599b..a2ea80eed 100644 --- a/zrml/prediction-markets/src/weights.rs +++ b/zrml/prediction-markets/src/weights.rs @@ -82,6 +82,7 @@ pub trait WeightInfoZeitgeist { fn market_status_manager(b: u32, f: u32) -> Weight; fn market_resolution_manager(r: u32, d: u32) -> Weight; fn process_subsidy_collecting_markets_dummy() -> Weight; + fn create_market_and_deploy_pool(m: u32) -> Weight; } /// Weight functions for zrml_prediction_markets (automatically generated) @@ -755,4 +756,31 @@ impl WeightInfoZeitgeist for WeightInfo { .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } + /// Storage: Timestamp Now (r:1 w:0) + /// Proof: Timestamp Now (max_values: Some(1), max_size: Some(8), added: 503, mode: MaxEncodedLen) + /// Storage: Balances Reserves (r:1 w:1) + /// Proof: Balances Reserves (max_values: None, max_size: Some(1249), added: 3724, mode: MaxEncodedLen) + /// Storage: MarketCommons MarketCounter (r:1 w:1) + /// Proof: MarketCommons MarketCounter (max_values: Some(1), max_size: Some(16), added: 511, mode: MaxEncodedLen) + /// Storage: PredictionMarkets MarketIdsPerCloseTimeFrame (r:1 w:1) + /// Proof: PredictionMarkets MarketIdsPerCloseTimeFrame (max_values: None, max_size: Some(1050), added: 3525, mode: MaxEncodedLen) + /// Storage: System Account (r:2 w:2) + /// Proof: System Account (max_values: None, max_size: Some(132), added: 2607, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:4 w:4) + /// Proof: Tokens Accounts (max_values: None, max_size: Some(123), added: 2598, mode: MaxEncodedLen) + /// Storage: Tokens TotalIssuance (r:2 w:2) + /// Proof: Tokens TotalIssuance (max_values: None, max_size: Some(43), added: 2518, mode: MaxEncodedLen) + /// Storage: NeoSwaps Pools (r:1 w:1) + /// Proof: NeoSwaps Pools (max_values: None, max_size: Some(4652), added: 7127, mode: MaxEncodedLen) + /// Storage: MarketCommons Markets (r:0 w:1) + /// Proof: MarketCommons Markets (max_values: None, max_size: Some(541), added: 3016, mode: MaxEncodedLen) + fn create_market_and_deploy_pool(_m: u32) -> Weight { + // Proof Size summary in bytes: + // Measured: `291 + m * (17 ±0)` + // Estimated: `36032` + // Minimum execution time: 166_000 nanoseconds. + Weight::from_parts(172_000_000, 36032) + .saturating_add(T::DbWeight::get().reads(13_u64)) + .saturating_add(T::DbWeight::get().writes(13_u64)) + } } diff --git a/zrml/swaps/src/lib.rs b/zrml/swaps/src/lib.rs index aaaf31a4f..e4e02fc38 100644 --- a/zrml/swaps/src/lib.rs +++ b/zrml/swaps/src/lib.rs @@ -1952,7 +1952,7 @@ mod pallet { let pool_amount = >::zero(); (pool_status, total_subsidy, total_weight, weights, pool_amount) } - ScoringRule::Orderbook => { + ScoringRule::Lmsr | ScoringRule::Orderbook => { return Err(Error::::InvalidScoringRule.into()); } }; @@ -2513,7 +2513,7 @@ mod pallet { T::RikiddoSigmoidFeeMarketEma::cost(pool_id, &outstanding_after)?; cost_before.checked_sub(&cost_after).ok_or(ArithmeticError::Overflow)? } - ScoringRule::Orderbook => { + ScoringRule::Lmsr | ScoringRule::Orderbook => { return Err(Error::::InvalidScoringRule.into()); } }; @@ -2565,7 +2565,9 @@ mod pallet { ScoringRule::RikiddoSigmoidFeeMarketEma => Ok( T::WeightInfo::swap_exact_amount_in_rikiddo(pool.assets.len().saturated_into()), ), - ScoringRule::Orderbook => Err(Error::::InvalidScoringRule.into()), + ScoringRule::Lmsr | ScoringRule::Orderbook => { + Err(Error::::InvalidScoringRule.into()) + } } } @@ -2673,7 +2675,7 @@ mod pallet { T::RikiddoSigmoidFeeMarketEma::cost(pool_id, &outstanding_after)?; cost_after.checked_sub(&cost_before).ok_or(ArithmeticError::Overflow)? } - ScoringRule::Orderbook => { + ScoringRule::Lmsr | ScoringRule::Orderbook => { return Err(Error::::InvalidScoringRule.into()); } }; @@ -2737,7 +2739,9 @@ mod pallet { pool.assets.len().saturated_into(), )) } - ScoringRule::Orderbook => Err(Error::::InvalidScoringRule.into()), + ScoringRule::Lmsr | ScoringRule::Orderbook => { + Err(Error::::InvalidScoringRule.into()) + } } } } diff --git a/zrml/swaps/src/utils.rs b/zrml/swaps/src/utils.rs index a34e2e719..9006b5ee6 100644 --- a/zrml/swaps/src/utils.rs +++ b/zrml/swaps/src/utils.rs @@ -216,7 +216,7 @@ where return Err(Error::::UnsupportedTrade.into()); } } - ScoringRule::Orderbook => { + ScoringRule::Lmsr | 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::Orderbook => { + ScoringRule::Lmsr | 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::Orderbook => { + ScoringRule::Lmsr | ScoringRule::Orderbook => { return Err(Error::::InvalidScoringRule.into()); } }