diff --git a/Cargo.lock b/Cargo.lock index 9722c255b..f59d33291 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14378,6 +14378,7 @@ dependencies = [ "frame-support", "frame-system", "more-asserts", + "num-traits", "orml-currencies", "orml-tokens", "orml-traits", @@ -14619,6 +14620,7 @@ dependencies = [ name = "zrml-neo-swaps" version = "0.4.2" dependencies = [ + "cfg-if", "env_logger 0.10.1", "fixed", "frame-benchmarking", diff --git a/primitives/Cargo.toml b/primitives/Cargo.toml index 903a91f70..53d3b282e 100644 --- a/primitives/Cargo.toml +++ b/primitives/Cargo.toml @@ -3,6 +3,7 @@ arbitrary = { workspace = true, optional = true } fixed = { workspace = true } frame-support = { workspace = true } frame-system = { workspace = true } +num-traits = { workspace = true } orml-currencies = { workspace = true } orml-tokens = { workspace = true } orml-traits = { workspace = true } diff --git a/primitives/src/constants/mock.rs b/primitives/src/constants/mock.rs index 8235e6eee..0e1fee734 100644 --- a/primitives/src/constants/mock.rs +++ b/primitives/src/constants/mock.rs @@ -75,6 +75,7 @@ parameter_types! { parameter_types! { pub storage NeoExitFee: Balance = CENT; pub const NeoMaxSwapFee: Balance = 10 * CENT; + pub const MaxLiquidityTreeDepth: u32 = 3u32; pub const NeoSwapsPalletId: PalletId = PalletId(*b"zge/neos"); } diff --git a/primitives/src/math/checked_ops_res.rs b/primitives/src/math/checked_ops_res.rs index 107979fff..02e9ec3de 100644 --- a/primitives/src/math/checked_ops_res.rs +++ b/primitives/src/math/checked_ops_res.rs @@ -16,6 +16,7 @@ // along with Zeitgeist. If not, see . use frame_support::dispatch::DispatchError; +use num_traits::{checked_pow, One}; use sp_arithmetic::{ traits::{CheckedAdd, CheckedDiv, CheckedMul, CheckedSub}, ArithmeticError, @@ -49,6 +50,13 @@ where fn checked_div_res(&self, other: &Self) -> Result; } +pub trait CheckedPowRes +where + Self: Sized, +{ + fn checked_pow_res(&self, exp: usize) -> Result; +} + impl CheckedAddRes for T where T: CheckedAdd, @@ -88,3 +96,13 @@ where self.checked_div(other).ok_or(DispatchError::Arithmetic(ArithmeticError::DivisionByZero)) } } + +impl CheckedPowRes for T +where + T: Copy + One + CheckedMul, +{ + #[inline] + fn checked_pow_res(&self, exp: usize) -> Result { + checked_pow(*self, exp).ok_or(DispatchError::Arithmetic(ArithmeticError::Overflow)) + } +} diff --git a/primitives/src/math/fixed.rs b/primitives/src/math/fixed.rs index f02d9ceca..0af4fe0b5 100644 --- a/primitives/src/math/fixed.rs +++ b/primitives/src/math/fixed.rs @@ -73,8 +73,8 @@ where fn bmul_ceil(&self, other: Self) -> Result; } -/// Performs fixed point division and errors with `DispatchError` in case of over- or -/// underflows and division by zero. +/// Performs fixed point division and errors with `DispatchError` in case of over- or underflows and +/// division by zero. pub trait FixedDiv where Self: Sized, @@ -90,6 +90,22 @@ where fn bdiv_ceil(&self, other: Self) -> Result; } +/// Performs fixed point multiplication and division, calculating `self * multiplier / divisor`. +pub trait FixedMulDiv +where + Self: Sized, +{ + /// Calculates the fixed point product `self * multiplier / divisor` and rounds to the nearest + /// representable fixed point number. + fn bmul_bdiv(&self, multiplier: Self, divisor: Self) -> Result; + + /// Calculates the fixed point product `self * multiplier / divisor` and rounds down. + fn bmul_bdiv_floor(&self, multiplier: Self, divisor: Self) -> Result; + + /// Calculates the fixed point product `self * multiplier / divisor` and rounds up. + fn bmul_bdiv_ceil(&self, multiplier: Self, divisor: Self) -> Result; +} + impl FixedMul for T where T: AtLeast32BitUnsigned, @@ -137,6 +153,63 @@ where } } +/// Helper function for implementing `FixedMulDiv` in a numerically clean way. +/// +/// The main idea is to keep the fixed point number scaled up between the multiplication and +/// division operation, so as to not lose any precision. Multiplication-first is preferred as it +/// grants better precision, but may suffer from overflows. If an overflow occurs, division-first is +/// used instead. +fn bmul_bdiv_common(x: &T, multiplier: T, divisor: T, adjustment: T) -> Result +where + T: AtLeast32BitUnsigned + Copy, +{ + // Try to multiply first, then divide. This overflows if the (mathematical) product of `x` and + // `multiplier` is around 3M. Use divide-first if this is the case. + let maybe_prod = x.checked_mul_res(&multiplier); + let maybe_scaled_prod = maybe_prod.and_then(|r| r.checked_mul_res(&ZeitgeistBase::get()?)); + if let Ok(scaled_prod) = maybe_scaled_prod { + // Multiply first, then divide. + let quot = scaled_prod.checked_div_res(&divisor)?; + let adjusted_quot = quot.checked_add_res(&adjustment)?; + adjusted_quot.checked_div_res(&ZeitgeistBase::get()?) + } else { + // Divide first, multiply later. It's cleaner to use the maximum of (x, multiplier) as + // divident. + let smallest = x.min(&multiplier); + let largest = x.max(&multiplier); + let scaled_divident = largest.checked_mul_res(&ZeitgeistBase::get()?)?; + let quot = scaled_divident.checked_div_res(&divisor)?; + let prod = quot.checked_mul_res(smallest)?; + let adjusted_prod = prod.checked_add_res(&adjustment)?; + adjusted_prod.checked_div_res(&ZeitgeistBase::get()?) + } +} + +/// Numerically clean implementation of `FixedMulDiv` which ensures higher precision than naive +/// multiplication and division for extreme values. +impl FixedMulDiv for T +where + T: AtLeast32BitUnsigned + Copy, +{ + fn bmul_bdiv(&self, multiplier: Self, divisor: Self) -> Result { + let adjustment = ZeitgeistBase::::get()?.checked_div_res(&2u8.into())?; + bmul_bdiv_common(self, multiplier, divisor, adjustment) + } + + fn bmul_bdiv_floor(&self, _multiplier: Self, _divisor: Self) -> Result { + // FIXME Untested! + // bmul_bdiv_common(self, multiplier, divisor, Zero::zero()) + Err(DispatchError::Other("not implemented")) + } + + fn bmul_bdiv_ceil(&self, _multiplier: Self, _divisor: Self) -> Result { + // FIXME Untested! + // let adjustment = ZeitgeistBase::::get()?.checked_sub_res(&1u8.into())?; + // bmul_bdiv_common(self, multiplier, divisor, adjustment) + Err(DispatchError::Other("not implemented")) + } +} + /// Converts a fixed point decimal number into another type. pub trait FromFixedDecimal> where @@ -504,6 +577,86 @@ mod tests { ); } + // bmul tests + #[test_case(0, 0, _1, 0)] + #[test_case(0, _1, _1, 0)] + #[test_case(0, _2, _1, 0)] + #[test_case(0, _3, _1, 0)] + #[test_case(_1, 0, _1, 0)] + #[test_case(_1, _1, _1, _1)] + #[test_case(_1, _2, _1, _2)] + #[test_case(_1, _3, _1, _3)] + #[test_case(_2, 0, _1, 0)] + #[test_case(_2, _1, _1, _2)] + #[test_case(_2, _2, _1, _4)] + #[test_case(_2, _3, _1, _6)] + #[test_case(_3, 0, _1, 0)] + #[test_case(_3, _1, _1, _3)] + #[test_case(_3, _2, _1, _6)] + #[test_case(_3, _3, _1, _9)] + #[test_case(_4, _1_2, _1, _2)] + #[test_case(_5, _1 + _1_2, _1, _7 + _1_2)] + #[test_case(_1 + 1, _2, _1, _2 + 2)] + #[test_case(9_999_999_999, _2, _1, 19_999_999_998)] + #[test_case(9_999_999_999, _10, _1, 99_999_999_990)] + // Rounding behavior when multiplying with small numbers + #[test_case(9_999_999_999, _1_2, _1, _1_2)] // 4999999999.5 + #[test_case(9_999_999_997, _1_4, _1, 2_499_999_999)] // 2499999999.25 + #[test_case(9_999_999_996, _1_3, _1, 3_333_333_332)] // 3333333331.666... + #[test_case(10_000_000_001, _1_10, _1, _1_10)] + #[test_case(10_000_000_005, _1_10, _1, _1_10 + 1)] // + + // bdiv tests + #[test_case(0, _1, _3, 0)] + #[test_case(_1, _1, _2, _1_2)] + #[test_case(_2, _1, _2, _1)] + #[test_case(_3,_1, _2, _1 + _1_2)] + #[test_case(_3, _1, _3, _1)] + #[test_case(_3 + _1_2, _1, _1_2, _7)] + #[test_case(99_999_999_999, _1, 1, 99_999_999_999 * _1)] + // Rounding behavior + #[test_case(_2, _1, _3, _2_3 + 1)] + #[test_case(99_999_999_999, _1, _10, _1)] + #[test_case(99_999_999_994, _1, _10, 9_999_999_999)] + #[test_case(5, _1, _10, 1)] + #[test_case(4, _1, _10, 0)] // + + // Normal Cases + #[test_case(_2, _2, _2, _2)] + #[test_case(_1, _2, _3, _2_3 + 1)] + #[test_case(_2, _3, _4, _1 + _1_2)] + #[test_case(_1 + 1, _2, _3, (_2 + 2) / 3)] + #[test_case(_5, _6, _7, _5 * _6 / _7)] + #[test_case(_100, _101, _20, _100 * _101 / _20)] // + + // Boundary cases + #[test_case(u128::MAX / _1, _1, _2, u128::MAX / _2)] + #[test_case(0, _1, _2, 0)] + #[test_case(_1, u128::MAX / _1, u128::MAX / _1, _1)] // + + // Special rounding cases + #[test_case(_1, _1_2, _1, _1_2)] + #[test_case(_1, _1_3, _1, _1_3)] + #[test_case(_1, _2_3, _1, _2_3)] + #[test_case(_9, _1_2, _1, _9 / 2)] + #[test_case(_9, _1_3, _1, 29_999_999_997)] + #[test_case(_9, _2_3, _1, 59_999_999_994)] // + + // Divide-first value + #[test_case(1_000_000 * _1, 1_000_000 * _1, _10, 100000000000 * _1)] + #[test_case(1_234_567 * _1, 9_876_543 * _1, 123_456, 9876599000357212286158412534)] + #[test_case(1_000_000 * _1, 9_876_543 * _1, 1_000_000 * _1, 9_876_543 * _1)] + + fn fixed_mul_div_works(lhs: u128, multiplier: u128, divisor: u128, expected: u128) { + assert_eq!(lhs.bmul_bdiv(multiplier, divisor).unwrap(), expected); + } + + #[test_case(_1, u128::MAX, u128::MAX, DispatchError::Arithmetic(ArithmeticError::Overflow))] + #[test_case(_1, _2, 0, DispatchError::Arithmetic(ArithmeticError::DivisionByZero))] + fn fixed_mul_div_fails(lhs: u128, multiplier: u128, divisor: u128, expected: DispatchError) { + assert_eq!(lhs.bmul_bdiv(multiplier, divisor), Err(expected)); + } + #[test_case(0, 10, 0.0)] #[test_case(1, 10, 0.0000000001)] #[test_case(9, 10, 0.0000000009)] diff --git a/runtime/battery-station/src/parameters.rs b/runtime/battery-station/src/parameters.rs index bf19acc07..945dabebe 100644 --- a/runtime/battery-station/src/parameters.rs +++ b/runtime/battery-station/src/parameters.rs @@ -195,6 +195,7 @@ parameter_types! { // NeoSwaps pub const NeoSwapsMaxSwapFee: Balance = 10 * CENT; pub const NeoSwapsPalletId: PalletId = NS_PALLET_ID; + pub const MaxLiquidityTreeDepth: u32 = 9u32; // ORML pub const GetNativeCurrencyId: CurrencyId = Asset::Ztg; diff --git a/runtime/common/src/lib.rs b/runtime/common/src/lib.rs index 17b57de5a..a0a96428c 100644 --- a/runtime/common/src/lib.rs +++ b/runtime/common/src/lib.rs @@ -55,16 +55,14 @@ macro_rules! decl_common_types { use orml_traits::MultiCurrency; use sp_runtime::{generic, DispatchError, DispatchResult, SaturatedConversion}; use zeitgeist_primitives::traits::{DeployPoolApi, DistributeFees, MarketCommonsPalletApi}; + use zrml_neo_swaps::migration::MigrateToLiquidityTree; + use zrml_orderbook::migrations::TranslateOrderStructure; pub type Block = generic::Block; type Address = sp_runtime::MultiAddress; - #[cfg(feature = "parachain")] - type Migrations = zrml_orderbook::migrations::TranslateOrderStructure; - - #[cfg(not(feature = "parachain"))] - type Migrations = zrml_orderbook::migrations::TranslateOrderStructure; + type Migrations = (MigrateToLiquidityTree, TranslateOrderStructure); pub type Executive = frame_executive::Executive< Runtime, @@ -1264,6 +1262,7 @@ macro_rules! impl_config_traits { type MultiCurrency = AssetManager; type RuntimeEvent = RuntimeEvent; type WeightInfo = zrml_neo_swaps::weights::WeightInfo; + type MaxLiquidityTreeDepth = MaxLiquidityTreeDepth; type MaxSwapFee = NeoSwapsMaxSwapFee; type PalletId = NeoSwapsPalletId; } diff --git a/runtime/zeitgeist/src/parameters.rs b/runtime/zeitgeist/src/parameters.rs index f5bf84b40..569449b09 100644 --- a/runtime/zeitgeist/src/parameters.rs +++ b/runtime/zeitgeist/src/parameters.rs @@ -195,6 +195,7 @@ parameter_types! { // NeoSwaps pub const NeoSwapsMaxSwapFee: Balance = 10 * CENT; pub const NeoSwapsPalletId: PalletId = NS_PALLET_ID; + pub const MaxLiquidityTreeDepth: u32 = 9u32; // ORML pub const GetNativeCurrencyId: CurrencyId = Asset::Ztg; diff --git a/zrml/neo-swaps/Cargo.toml b/zrml/neo-swaps/Cargo.toml index 6d41495cd..6ecd9e15a 100644 --- a/zrml/neo-swaps/Cargo.toml +++ b/zrml/neo-swaps/Cargo.toml @@ -1,4 +1,5 @@ [dependencies] +cfg-if = { workspace = true } fixed = { workspace = true } frame-benchmarking = { workspace = true, optional = true } frame-support = { workspace = true } diff --git a/zrml/neo-swaps/README.md b/zrml/neo-swaps/README.md index e2b057c39..fa3e2f7e0 100644 --- a/zrml/neo-swaps/README.md +++ b/zrml/neo-swaps/README.md @@ -10,38 +10,93 @@ 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. +- _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. +- _Liquidity Tree_: A data structure used to store a pool's liquidity providers' + positions. + +### Liquidity Tree + +The _liquidity tree_ is one implementation of the `LiquiditySharesManager` trait +which the `Pool` struct uses to manage liquidity provider's positions. Liquidity +shares managers in general handles how many pool shares\_ each LP owns (similar +to pallet-balances), as well as the distribution of fees. + +The liquidity tree is a binary segment tree. Each node represents one liquidity +provider and stores their stake in the pool and how much fees they're owed. As +opposed to a naked list, the liquidity tree solves one particular problem: +Naively distributing fees every time a trade is executed requires +`O(liquidity_providers)` operations, which is unacceptable. The problem is +solved by lazily distributing fees using lazy propagation. Whenever changes are +made to a node in the tree, e.g. an LP joins, leaves or withdraws fees, fees are +then lazily propagated to the corresponding node of the tree before any other +changes are enacted. This brings the complexity of distributing fees to constant +time, while lazy propagation only requires `O(log(liquidity_providers))` +operations. + +The design of the liquidity tree is based on +[Azuro-protocol/LiquidityTree](https://github.com/Azuro-protocol/LiquidityTree). + +#### Lazy Propagation + +Fees are propagated up the tree in a "lazy" manner, i.e., the propagation +happens when liquidity providers deposit liquidity, or withdraw liquidity or +fees. The process of lazy propagation at a node `node` is as follows: + +```ignore +If node.descendant_stake == 0 then + node.fees ← node.fees + node.lazy_fees +Else + total_stake ← node.stake + node.descendant_stake + fees ← (node.descendant_stake / total_stake) * node.lazy_fees + node.fees ← node.fees + fees + remaining ← node.lazy_fees - fees + For each child in node.children() do + child.lazy_fees ← child.lazy_fees + (child.descendant_stake / total_stake) * remaining + End For +End If +node.lazy_fees ← 0 +``` + +This means that at every node, the remaining lazy fees are distributed pro rata +between the current node and its two children. With the total stake defined as +the sum of the current node's stake and the stake of its descendants, the +process is as follows: + +- The current node's fees are increased by `node.stake / total_stake` of the + remaining lazy fees. +- Each child's lazy fees are increased by `child.descendant_stake / total_stake` + of the remaining lazy fees. ### 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. +- 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 + manipulate 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 a pool is deployed, the pallet charges the signer an extra fee to the + tune of the collateral's existential deposit. This fee is moved into 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 fail with `ExistentialDeposit`. + This "buffer" is burned 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/src/benchmarking.rs b/zrml/neo-swaps/src/benchmarking.rs index f4612de55..dfde6ec4d 100644 --- a/zrml/neo-swaps/src/benchmarking.rs +++ b/zrml/neo-swaps/src/benchmarking.rs @@ -19,9 +19,12 @@ use super::*; use crate::{ - consts::*, traits::liquidity_shares_manager::LiquiditySharesManager, AssetOf, BalanceOf, - MarketIdOf, Pallet as NeoSwaps, Pools, MIN_SPOT_PRICE, + consts::*, + liquidity_tree::{traits::LiquidityTreeHelper, types::LiquidityTree}, + traits::{liquidity_shares_manager::LiquiditySharesManager, pool_operations::PoolOperations}, + AssetOf, BalanceOf, MarketIdOf, Pallet as NeoSwaps, Pools, MIN_SPOT_PRICE, }; +use core::{cell::Cell, iter, marker::PhantomData}; use frame_benchmarking::v2::*; use frame_support::{ assert_ok, @@ -29,15 +32,18 @@ use frame_support::{ }; use frame_system::RawOrigin; use orml_traits::MultiCurrency; -use sp_runtime::{Perbill, SaturatedConversion}; +use sp_runtime::{traits::Get, Perbill, SaturatedConversion}; use zeitgeist_primitives::{ constants::CENT, - math::fixed::{BaseProvider, ZeitgeistBase}, + math::fixed::{BaseProvider, FixedDiv, FixedMul, ZeitgeistBase}, traits::CompleteSetOperationsApi, types::{Asset, Market, MarketCreation, MarketPeriod, MarketStatus, MarketType, ScoringRule}, }; use zrml_market_commons::MarketCommonsPalletApi; +// Same behavior as `assert_ok!`, except that it wraps the call inside a transaction layer. Required +// when calling into functions marked `require_transactional` to avoid a `Transactional(NoLayer)` +// error. macro_rules! assert_ok_with_transaction { ($expr:expr) => {{ assert_ok!(with_transaction(|| match $expr { @@ -47,11 +53,133 @@ macro_rules! assert_ok_with_transaction { }}; } -fn create_market( +trait LiquidityTreeBenchmarkHelper +where + T: Config, +{ + fn calculate_min_pool_shares_amount(&self) -> BalanceOf; +} + +impl LiquidityTreeBenchmarkHelper for LiquidityTree +where + T: Config, + U: Get, +{ + /// Calculate the minimum amount required to join a liquidity tree without erroring. + fn calculate_min_pool_shares_amount(&self) -> BalanceOf { + self.total_shares() + .unwrap() + .bmul_ceil(MIN_RELATIVE_LP_POSITION_VALUE.saturated_into()) + .unwrap() + } +} + +/// Utilities for setting up benchmarks. +struct BenchmarkHelper { + current_id: Cell, + _marker: PhantomData, +} + +impl BenchmarkHelper +where + T: Config, +{ + fn new() -> Self { + BenchmarkHelper { current_id: Cell::new(0), _marker: PhantomData } + } + + /// Return an iterator which ranges over _unused_ accounts. + fn accounts(&self) -> impl Iterator + '_ { + iter::from_fn(move || { + let id = self.current_id.get(); + self.current_id.set(id + 1); + Some(account("", id, 0)) + }) + } + + /// Populates the market's liquidity tree until almost full with one free leaf remaining. + /// Ensures that the tree has the expected configuration of nodes. + fn populate_liquidity_tree_with_free_leaf(&self, market_id: MarketIdOf) { + let max_node_count = LiquidityTreeOf::::max_node_count(); + let last = (max_node_count - 1) as usize; + for caller in self.accounts().take(last - 1) { + add_liquidity_provider_to_market::(market_id, caller); + } + // Verify that we've got the right number of nodes. + let pool = Pools::::get(market_id).unwrap(); + assert_eq!(pool.liquidity_shares_manager.nodes.len(), last); + } + + /// Populates the market's liquidity tree until full. The `caller` is the owner of the last + /// leaf. Ensures that the tree has the expected configuration of nodes. + fn populate_liquidity_tree_until_full(&self, market_id: MarketIdOf, caller: T::AccountId) { + // Start by populating the entire tree except for one node. `caller` will then join and + // occupy the last node. + self.populate_liquidity_tree_with_free_leaf(market_id); + add_liquidity_provider_to_market::(market_id, caller); + // Verify that we've got the right number of nodes. + let pool = Pools::::get(market_id).unwrap(); + let max_node_count = LiquidityTreeOf::::max_node_count(); + assert_eq!(pool.liquidity_shares_manager.nodes.len(), max_node_count as usize); + } + + /// Populates the market's liquidity tree until almost full with one abandoned node remaining. + fn populate_liquidity_tree_with_abandoned_node(&self, market_id: MarketIdOf) { + // Start by populating the entire tree. `caller` will own one of the leaves, withdraw their + // stake, leaving an abandoned node at a leaf. + let caller = self.accounts().next().unwrap(); + self.populate_liquidity_tree_until_full(market_id, caller.clone()); + let pool = Pools::::get(market_id).unwrap(); + let pool_shares_amount = pool.liquidity_shares_manager.shares_of(&caller).unwrap(); + assert_ok!(NeoSwaps::::exit( + RawOrigin::Signed(caller).into(), + market_id, + pool_shares_amount, + vec![Zero::zero(); pool.assets().len()] + )); + // Verify that we've got the right number of nodes. + let pool = Pools::::get(market_id).unwrap(); + let max_node_count = LiquidityTreeOf::::max_node_count(); + assert_eq!(pool.liquidity_shares_manager.nodes.len(), max_node_count as usize); + let last = max_node_count - 1; + assert_eq!(pool.liquidity_shares_manager.abandoned_nodes, vec![last]); + } + + /// Run the common setup of `join` benchmarks and return the target market's ID and Bob's + /// address (who will execute the call). + /// + /// Parameters: + /// + /// - `market_id`: The ID to set the benchmark up for. + /// - `complete_set_amount`: The amount of complete sets to buy for Bob. + fn set_up_liquidity_benchmark( + &self, + market_id: MarketIdOf, + account: AccountIdOf, + complete_set_amount: Option>, + ) { + let pool = Pools::::get(market_id).unwrap(); + let multiplier = MIN_RELATIVE_LP_POSITION_VALUE + 1_000; + let complete_set_amount = complete_set_amount.unwrap_or_else(|| { + pool.reserves.values().max().unwrap().bmul_ceil(multiplier.saturated_into()).unwrap() + }); + assert_ok!(T::MultiCurrency::deposit(pool.collateral, &account, complete_set_amount)); + assert_ok_with_transaction!(T::CompleteSetOperations::buy_complete_set( + account, + market_id, + complete_set_amount, + )); + } +} + +fn create_market( caller: T::AccountId, base_asset: AssetOf, asset_count: AssetIndexType, -) -> MarketIdOf { +) -> MarketIdOf +where + T: Config, +{ let market = Market { base_asset, creation: MarketCreation::Permissionless, @@ -88,7 +216,10 @@ fn create_market_and_deploy_pool( base_asset: AssetOf, asset_count: AssetIndexType, amount: BalanceOf, -) -> MarketIdOf { +) -> MarketIdOf +where + T: Config, +{ 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)); @@ -107,6 +238,43 @@ fn create_market_and_deploy_pool( market_id } +fn deposit_fees(market_id: MarketIdOf, amount: BalanceOf) +where + T: Config, +{ + let mut pool = Pools::::get(market_id).unwrap(); + assert_ok!(T::MultiCurrency::deposit(pool.collateral, &pool.account_id, amount)); + assert_ok!(pool.liquidity_shares_manager.deposit_fees(amount)); + Pools::::insert(market_id, pool); +} + +// Let `caller` join the pool of `market_id` after adding the required funds to their account. +fn add_liquidity_provider_to_market(market_id: MarketIdOf, caller: AccountIdOf) +where + T: Config, +{ + let pool = Pools::::get(market_id).unwrap(); + // Buy a little more to account for rounding. + let pool_shares_amount = + pool.liquidity_shares_manager.calculate_min_pool_shares_amount() + _1.saturated_into(); + let ratio = + pool_shares_amount.bdiv(pool.liquidity_shares_manager.total_shares().unwrap()).unwrap(); + let complete_set_amount = + pool.reserves.values().max().unwrap().bmul_ceil(ratio).unwrap() * 2u8.into(); + assert_ok!(T::MultiCurrency::deposit(pool.collateral, &caller, complete_set_amount)); + assert_ok_with_transaction!(T::CompleteSetOperations::buy_complete_set( + caller.clone(), + market_id, + complete_set_amount, + )); + assert_ok!(NeoSwaps::::join( + RawOrigin::Signed(caller.clone()).into(), + market_id, + pool_shares_amount, + vec![u128::MAX.saturated_into(); pool.assets().len()] + )); +} + #[benchmarks] mod benchmarks { use super::*; @@ -114,7 +282,7 @@ mod benchmarks { /// FIXME Replace hardcoded variant with `{ MAX_ASSETS as u32 }` as soon as possible. #[benchmark] fn buy(n: Linear<2, 128>) { - let alice: T::AccountId = whitelisted_caller(); + let alice = whitelisted_caller(); let base_asset = Asset::Ztg; let asset_count = n.try_into().unwrap(); let market_id = create_market_and_deploy_pool::( @@ -127,7 +295,8 @@ mod benchmarks { let amount_in = _1.saturated_into(); let min_amount_out = 0u8.saturated_into(); - let bob: T::AccountId = whitelisted_caller(); + let helper = BenchmarkHelper::::new(); + let bob = helper.accounts().next().unwrap(); assert_ok!(T::MultiCurrency::deposit(base_asset, &bob, amount_in)); #[extrinsic_call] @@ -136,7 +305,7 @@ mod benchmarks { #[benchmark] fn sell(n: Linear<2, 128>) { - let alice: T::AccountId = whitelisted_caller(); + let alice = whitelisted_caller(); let base_asset = Asset::Ztg; let asset_count = n.try_into().unwrap(); let market_id = create_market_and_deploy_pool::( @@ -149,15 +318,18 @@ mod benchmarks { let amount_in = _1.saturated_into(); let min_amount_out = 0u8.saturated_into(); - let bob: T::AccountId = whitelisted_caller(); + let helper = BenchmarkHelper::::new(); + let bob = helper.accounts().next().unwrap(); assert_ok!(T::MultiCurrency::deposit(asset_in, &bob, amount_in)); #[extrinsic_call] _(RawOrigin::Signed(bob), market_id, asset_count, asset_in, amount_in, min_amount_out); } + // Bob already owns a leaf at maximum depth in the tree but decides to increase his stake. + // Maximum propagation steps thanks to maximum depth. #[benchmark] - fn join(n: Linear<2, 128>) { + fn join_in_place(n: Linear<2, 128>) { let alice: T::AccountId = whitelisted_caller(); let base_asset = Asset::Ztg; let asset_count = n.try_into().unwrap(); @@ -167,22 +339,97 @@ mod benchmarks { asset_count, _10.saturated_into(), ); + let helper = BenchmarkHelper::::new(); + let bob = helper.accounts().next().unwrap(); + helper.populate_liquidity_tree_until_full(market_id, bob.clone()); let pool_shares_amount = _1.saturated_into(); + // Due to rounding, we need to buy a little more than the pool share amount. + let complete_set_amount = _100.saturated_into(); + helper.set_up_liquidity_benchmark(market_id, bob.clone(), Some(complete_set_amount)); let max_amounts_in = vec![u128::MAX.saturated_into(); asset_count as usize]; - assert_ok!(T::MultiCurrency::deposit(base_asset, &alice, pool_shares_amount)); - assert_ok_with_transaction!(T::CompleteSetOperations::buy_complete_set( + // Double check that there's no abandoned node or free leaf. + let pool = Pools::::get(market_id).unwrap(); + assert_eq!(pool.liquidity_shares_manager.abandoned_nodes.len(), 0); + let max_node_count = LiquidityTreeOf::::max_node_count(); + assert_eq!(pool.liquidity_shares_manager.node_count(), max_node_count); + + #[extrinsic_call] + join(RawOrigin::Signed(bob), market_id, pool_shares_amount, max_amounts_in); + } + + // Bob joins the pool and is assigned an abandoned node at maximum depth in the tree. Maximum + // propagation steps thanks to maximum depth. + #[benchmark] + fn join_reassigned(n: Linear<2, 128>) { + let alice: T::AccountId = whitelisted_caller(); + let base_asset = Asset::Ztg; + let asset_count = n.try_into().unwrap(); + let market_id = create_market_and_deploy_pool::( alice.clone(), - market_id, - pool_shares_amount - )); + base_asset, + asset_count, + _10.saturated_into(), + ); + let helper = BenchmarkHelper::::new(); + helper.populate_liquidity_tree_with_abandoned_node(market_id); + let pool = Pools::::get(market_id).unwrap(); + let pool_shares_amount = pool.liquidity_shares_manager.calculate_min_pool_shares_amount(); + // Due to rounding, we need to buy a little more than the pool share amount. + let bob = helper.accounts().next().unwrap(); + helper.set_up_liquidity_benchmark(market_id, bob.clone(), None); + let max_amounts_in = vec![u128::MAX.saturated_into(); asset_count as usize]; + + // Double check that there's an abandoned node. + assert_eq!(pool.liquidity_shares_manager.abandoned_nodes.len(), 1); + + #[extrinsic_call] + join(RawOrigin::Signed(bob), market_id, pool_shares_amount, max_amounts_in); + + let pool = Pools::::get(market_id).unwrap(); + assert_eq!(pool.liquidity_shares_manager.abandoned_nodes.len(), 0); + } + + // Bob joins the pool and is assigned a leaf at maximum depth in the tree. Maximum propagation + // steps thanks to maximum depth. + #[benchmark] + fn join_leaf(n: Linear<2, 128>) { + let alice: T::AccountId = whitelisted_caller(); + let base_asset = Asset::Ztg; + let asset_count = n.try_into().unwrap(); + let market_id = create_market_and_deploy_pool::( + alice.clone(), + base_asset, + asset_count, + _10.saturated_into(), + ); + let helper = BenchmarkHelper::::new(); + helper.populate_liquidity_tree_with_free_leaf(market_id); + let pool = Pools::::get(market_id).unwrap(); + let pool_shares_amount = pool.liquidity_shares_manager.calculate_min_pool_shares_amount(); + // Due to rounding, we need to buy a little more than the pool share amount. + let bob = helper.accounts().next().unwrap(); + helper.set_up_liquidity_benchmark(market_id, bob.clone(), None); + let max_amounts_in = vec![u128::MAX.saturated_into(); asset_count as usize]; + + // Double-check that there's a free leaf. + let max_node_count = LiquidityTreeOf::::max_node_count(); + assert_eq!(pool.liquidity_shares_manager.node_count(), max_node_count - 1); #[extrinsic_call] - _(RawOrigin::Signed(alice), market_id, pool_shares_amount, max_amounts_in); + join(RawOrigin::Signed(bob), market_id, pool_shares_amount, max_amounts_in); + + // Ensure that the leaf is taken. + let pool = Pools::::get(market_id).unwrap(); + assert_eq!(pool.liquidity_shares_manager.node_count(), max_node_count); } - // There are two execution paths in `exit`: 1) Keep pool alive or 2) destroy it. Clearly 1) is - // heavier. + // Worst-case benchmark of `exit`. A couple of conditions must be met to get the worst-case: + // + // - Caller withdraws their total share (the node is then abandoned, resulting in extra writes). + // - The pool is kept alive (changing the pool struct instead of destroying it is heavier). + // - The caller owns a leaf of maximum depth (equivalent to the second condition unless the tree + // has max depth zero). #[benchmark] fn exit(n: Linear<2, 128>) { let alice: T::AccountId = whitelisted_caller(); @@ -194,35 +441,45 @@ mod benchmarks { asset_count, _10.saturated_into(), ); - let pool_shares_amount = _1.saturated_into(); - let min_amounts_out = vec![0u8.saturated_into(); asset_count as usize]; + let min_amounts_out = vec![0u8.into(); asset_count as usize]; + + let helper = BenchmarkHelper::::new(); + let bob = helper.accounts().next().unwrap(); + helper.populate_liquidity_tree_until_full(market_id, bob.clone()); + let pool = Pools::::get(market_id).unwrap(); + let pool_shares_amount = pool.liquidity_shares_manager.shares_of(&bob).unwrap(); #[extrinsic_call] - _(RawOrigin::Signed(alice), market_id, pool_shares_amount, min_amounts_out); + _(RawOrigin::Signed(bob), market_id, pool_shares_amount, min_amounts_out); assert!(Pools::::contains_key(market_id)); // Ensure we took the right turn. } + // Worst-case benchmark of `withdraw_fees`: Bob, who owns a leaf of maximum depth, withdraws his + // stake. #[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, + Asset::Ztg, 2u16, _10.saturated_into(), ); - let fee_amount = _1.saturated_into(); + let helper = BenchmarkHelper::::new(); + let bob = helper.accounts().next().unwrap(); + helper.populate_liquidity_tree_until_full(market_id, bob.clone()); + helper.set_up_liquidity_benchmark(market_id, bob.clone(), None); - // 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); + // Mock up some fees. Needs to be large enough to ensure that Bob's share is not smaller + // than the existential deposit. + let pool = Pools::::get(market_id).unwrap(); + let max_node_count = LiquidityTreeOf::::max_node_count() as u128; + let fee_amount = (max_node_count * _10).saturated_into(); + deposit_fees::(market_id, fee_amount); #[extrinsic_call] - _(RawOrigin::Signed(alice), market_id); + _(RawOrigin::Signed(bob), market_id); } #[benchmark] diff --git a/zrml/neo-swaps/src/consts.rs b/zrml/neo-swaps/src/consts.rs index 0ba4c0cc9..0a12edf64 100644 --- a/zrml/neo-swaps/src/consts.rs +++ b/zrml/neo-swaps/src/consts.rs @@ -29,14 +29,21 @@ 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 _6: u128 = 6 * _1; pub(crate) const _7: u128 = 7 * _1; pub(crate) const _8: u128 = 8 * _1; pub(crate) const _9: u128 = 9 * _1; pub(crate) const _10: u128 = 10 * _1; pub(crate) const _11: u128 = 11 * _1; +pub(crate) const _12: u128 = 12 * _1; +pub(crate) const _14: u128 = 14 * _1; pub(crate) const _17: u128 = 17 * _1; pub(crate) const _20: u128 = 20 * _1; +pub(crate) const _23: u128 = 23 * _1; +pub(crate) const _24: u128 = 24 * _1; pub(crate) const _30: u128 = 30 * _1; +pub(crate) const _36: u128 = 36 * _1; +pub(crate) const _40: u128 = 40 * _1; pub(crate) const _70: u128 = 70 * _1; pub(crate) const _80: u128 = 80 * _1; pub(crate) const _100: u128 = 100 * _1; diff --git a/zrml/neo-swaps/src/helpers.rs b/zrml/neo-swaps/src/helpers.rs new file mode 100644 index 000000000..06381ac8a --- /dev/null +++ b/zrml/neo-swaps/src/helpers.rs @@ -0,0 +1,34 @@ +// 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))] + +use crate::{BalanceOf, Config, MIN_SPOT_PRICE}; +use sp_runtime::SaturatedConversion; +use zeitgeist_primitives::math::fixed::{BaseProvider, ZeitgeistBase}; + +pub(crate) fn create_spot_prices(asset_count: u16) -> Vec> +where + T: Config, +{ + let mut result = vec![MIN_SPOT_PRICE.saturated_into(); (asset_count - 1) as usize]; + // Price distribution has no bearing on the benchmarks. + let remaining_u128 = + ZeitgeistBase::::get().unwrap() - (asset_count - 1) as u128 * MIN_SPOT_PRICE; + result.push(remaining_u128.saturated_into()); + result +} diff --git a/zrml/neo-swaps/src/lib.rs b/zrml/neo-swaps/src/lib.rs index af316fb33..a87329ae5 100644 --- a/zrml/neo-swaps/src/lib.rs +++ b/zrml/neo-swaps/src/lib.rs @@ -22,7 +22,11 @@ extern crate alloc; mod benchmarking; mod consts; +mod helpers; +mod liquidity_tree; +mod macros; mod math; +pub mod migration; mod mock; mod tests; pub mod traits; @@ -35,9 +39,10 @@ pub use pallet::*; mod pallet { use crate::{ consts::{LN_NUMERICAL_LIMIT, MAX_ASSETS}, + liquidity_tree::types::{BenchmarkInfo, LiquidityTree, LiquidityTreeError}, math::{Math, MathOps}, traits::{pool_operations::PoolOperations, LiquiditySharesManager}, - types::{FeeDistribution, Pool, SoloLp}, + types::{FeeDistribution, Pool}, weights::*, }; use alloc::{collections::BTreeMap, vec, vec::Vec}; @@ -62,15 +67,17 @@ mod pallet { constants::{BASE, CENT}, math::{ checked_ops_res::{CheckedAddRes, CheckedSubRes}, - fixed::{FixedDiv, FixedMul}, + fixed::{BaseProvider, FixedDiv, FixedMul, ZeitgeistBase}, }, traits::{CompleteSetOperationsApi, DeployPoolApi, DistributeFees}, types::{Asset, MarketStatus, MarketType, ScalarPosition, ScoringRule}, }; use zrml_market_commons::MarketCommonsPalletApi; + pub(crate) const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); + // These should not be config parameters to avoid misconfigurations. - pub(crate) const STORAGE_VERSION: StorageVersion = StorageVersion::new(0); + pub(crate) const EXIT_FEE: u128 = CENT / 10; /// The minimum allowed swap fee. Hardcoded to avoid misconfigurations which may lead to /// exploits. pub(crate) const MIN_SWAP_FEE: u128 = BASE / 1_000; // 0.1%. @@ -80,6 +87,9 @@ mod pallet { pub(crate) const MIN_SPOT_PRICE: u128 = CENT / 2; /// The minimum vallowed value of a pool's liquidity parameter. pub(crate) const MIN_LIQUIDITY: u128 = BASE; + /// The minimum percentage each new LP position must increase the liquidity by, represented as + /// fractional (0.0139098411 represents 1.39098411%). + pub(crate) const MIN_RELATIVE_LP_POSITION_VALUE: u128 = 139098411; // 1.39098411% pub(crate) type AccountIdOf = ::AccountId; pub(crate) type AssetOf = Asset>; @@ -88,7 +98,8 @@ mod pallet { pub(crate) type AssetIndexType = u16; pub(crate) type MarketIdOf = <::MarketCommons as MarketCommonsPalletApi>::MarketId; - pub(crate) type PoolOf = Pool>; + pub(crate) type LiquidityTreeOf = LiquidityTree::MaxLiquidityTreeDepth>; + pub(crate) type PoolOf = Pool>; #[pallet::config] pub trait Config: frame_system::Config { @@ -115,6 +126,11 @@ mod pallet { type WeightInfo: WeightInfoZeitgeist; + /// The maximum allowed liquidity tree depth per pool. Each pool can support `2^(depth + 1) + /// - 1` liquidity providers. **Must** be less than 16. + #[pallet::constant] + type MaxLiquidityTreeDepth: Get; + #[pallet::constant] type MaxSwapFee: Get>; @@ -128,8 +144,7 @@ mod pallet { pub struct Pallet(PhantomData); #[pallet::storage] - #[pallet::getter(fn pools)] - pub type Pools = StorageMap<_, Twox64Concat, MarketIdOf, PoolOf>; + pub(crate) type Pools = StorageMap<_, Twox64Concat, MarketIdOf, PoolOf>; #[pallet::event] #[pallet::generate_deposit(fn deposit_event)] @@ -247,6 +262,10 @@ mod pallet { Unexpected, /// Specified monetary amount is zero. ZeroAmount, + /// An error occurred when handling the liquidty tree. + LiquidityTreeError(LiquidityTreeError), + /// The relative value of a new LP position is too low. + MinRelativeLiquidityThresholdViolated, } #[derive(Decode, Encode, Eq, PartialEq, PalletError, RuntimeDebug, TypeInfo)] @@ -371,9 +390,15 @@ mod pallet { /// /// # Complexity /// - /// `O(n)` where `n` is the number of assets in the pool. + /// `O(n + d)` where `n` is the number of assets in the pool and `d` is the depth of the + /// pool's liquidity tree, or, equivalently, `log_2(m)` where `m` is the number of liquidity + /// providers in the pool. #[pallet::call_index(2)] - #[pallet::weight(T::WeightInfo::join(max_amounts_in.len() as u32))] + #[pallet::weight( + T::WeightInfo::join_in_place(max_amounts_in.len() as u32) + .max(T::WeightInfo::join_reassigned(max_amounts_in.len() as u32)) + .max(T::WeightInfo::join_leaf(max_amounts_in.len() as u32)) + )] #[transactional] pub fn join( origin: OriginFor, @@ -384,8 +409,7 @@ mod pallet { 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(asset_count as u32)).into()) + Self::do_join(who, market_id, pool_shares_amount, max_amounts_in) } /// Exit the liquidity pool for the specified market. @@ -400,7 +424,9 @@ mod pallet { /// 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. + /// new pool can be deployed at any time, provided that the market is still open. If there + /// are funds left in the pool account (this can happen due to exit fees), the remaining + /// funds are destroyed. /// /// 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 @@ -415,7 +441,9 @@ mod pallet { /// /// # Complexity /// - /// `O(n)` where `n` is the number of assets in the pool. + /// `O(n + d)` where `n` is the number of assets in the pool and `d` is the depth of the + /// pool's liquidity tree, or, equivalently, `log_2(m)` where `m` is the number of liquidity + /// providers in the pool. #[pallet::call_index(3)] #[pallet::weight(T::WeightInfo::exit(min_amounts_out.len() as u32))] #[transactional] @@ -479,7 +507,7 @@ mod pallet { /// /// # Complexity /// - /// `O(n)` where `n` is the number of outcomes in the specified market. + /// `O(n)` where `n` is the number of assets in the pool. #[pallet::call_index(5)] #[pallet::weight(T::WeightInfo::deploy_pool(spot_prices.len() as u32))] #[transactional] @@ -639,13 +667,23 @@ mod pallet { market_id: MarketIdOf, pool_shares_amount: BalanceOf, max_amounts_in: Vec>, - ) -> DispatchResult { + ) -> DispatchResultWithPostInfo { 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| { + let asset_count = max_amounts_in.len() as u32; + ensure!(asset_count == market.outcomes() as u32, Error::::IncorrectAssetCount); + let benchmark_info = Self::try_mutate_pool(&market_id, |pool| { let ratio = pool_shares_amount.bdiv_ceil(pool.liquidity_shares_manager.total_shares()?)?; + // Ensure that new LPs contribute at least MIN_RELATIVE_LP_POSITION_VALUE. Note that + // this ensures that the ratio can never be zero. + if pool.liquidity_shares_manager.shares_of(&who).is_err() { + ensure!( + ratio >= MIN_RELATIVE_LP_POSITION_VALUE.saturated_into(), + Error::::MinRelativeLiquidityThresholdViolated, + ); + } 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)?; @@ -657,7 +695,8 @@ mod pallet { for ((_, balance), amount_in) in pool.reserves.iter_mut().zip(amounts_in.iter()) { *balance = balance.checked_add_res(amount_in)?; } - pool.liquidity_shares_manager.join(&who, pool_shares_amount)?; + let benchmark_info = + pool.liquidity_shares_manager.join(&who, pool_shares_amount)?; let new_liquidity_parameter = pool .liquidity_parameter .checked_add_res(&ratio.bmul(pool.liquidity_parameter)?)?; @@ -669,8 +708,14 @@ mod pallet { amounts_in, new_liquidity_parameter, }); - Ok(()) - }) + Ok(benchmark_info) + })?; + let weight = match benchmark_info { + BenchmarkInfo::InPlace => T::WeightInfo::join_in_place(asset_count), + BenchmarkInfo::Reassigned => T::WeightInfo::join_reassigned(asset_count), + BenchmarkInfo::Leaf => T::WeightInfo::join_leaf(asset_count), + }; + Ok((Some(weight)).into()) } #[require_transactional] @@ -681,16 +726,20 @@ mod pallet { min_amounts_out: Vec>, ) -> DispatchResult { ensure!(pool_shares_amount != Zero::zero(), Error::::ZeroAmount); - let _ = T::MarketCommons::market(&market_id)?; + let market = 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 = - pool_shares_amount.bdiv_floor(pool.liquidity_shares_manager.total_shares()?)?; + let ratio = { + let mut ratio = pool_shares_amount + .bdiv_floor(pool.liquidity_shares_manager.total_shares()?)?; + if market.status == MarketStatus::Active { + let multiplier = ZeitgeistBase::>::get()? + .checked_sub_res(&EXIT_FEE.saturated_into())?; + ratio = ratio.bmul_floor(multiplier)?; + } + ratio + }; 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)?; @@ -704,11 +753,18 @@ mod pallet { } pool.liquidity_shares_manager.exit(&who, pool_shares_amount)?; if pool.liquidity_shares_manager.total_shares()? == Zero::zero() { + let withdraw_remaining = |&asset| -> DispatchResult { + let remaining = T::MultiCurrency::free_balance(asset, &pool.account_id); + T::MultiCurrency::withdraw(asset, &pool.account_id, remaining)?; + Ok(()) + }; // 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)?; + // hack and frame_system should offer the option to whitelist accounts. + withdraw_remaining(&pool.collateral)?; + // Clear left-over tokens. These naturally occur in the form of exit fees. + for asset in pool.assets().iter() { + withdraw_remaining(asset)?; + } *maybe_pool = None; // Delete the storage map entry. Self::deposit_event(Event::::PoolDestroyed { who: who.clone(), @@ -716,12 +772,26 @@ mod pallet { amounts_out, }); } else { - let liq = pool.liquidity_parameter; - let new_liquidity_parameter = liq.checked_sub_res(&ratio.bmul(liq)?)?; - ensure!( - new_liquidity_parameter >= MIN_LIQUIDITY.saturated_into(), - Error::::LiquidityTooLow - ); + let old_liquidity_parameter = pool.liquidity_parameter; + let new_liquidity_parameter = old_liquidity_parameter + .checked_sub_res(&ratio.bmul(old_liquidity_parameter)?)?; + // If `who` still holds pool shares, check that their position has at least + // minimum size. + if let Ok(remaining_pool_shares_amount) = + pool.liquidity_shares_manager.shares_of(&who) + { + ensure!( + new_liquidity_parameter >= MIN_LIQUIDITY.saturated_into(), + Error::::LiquidityTooLow + ); + let remaining_pool_shares_ratio = remaining_pool_shares_amount + .bdiv_floor(pool.liquidity_shares_manager.total_shares()?)?; + ensure!( + remaining_pool_shares_ratio + >= MIN_RELATIVE_LP_POSITION_VALUE.saturated_into(), + Error::::MinRelativeLiquidityThresholdViolated + ); + } pool.liquidity_parameter = new_liquidity_parameter; Self::deposit_event(Event::::ExitExecuted { who: who.clone(), @@ -759,7 +829,6 @@ mod pallet { ) -> 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(); @@ -803,7 +872,7 @@ mod pallet { reserves: reserves.clone(), collateral, liquidity_parameter, - liquidity_shares_manager: SoloLp::new(who.clone(), amount), + liquidity_shares_manager: LiquidityTree::new(who.clone(), amount)?, swap_fee, }; // FIXME Ensure that the existential deposit doesn't kill fees. This is an ugly hack and @@ -878,9 +947,12 @@ mod pallet { }) } - pub(crate) fn try_mutate_pool(market_id: &MarketIdOf, mutator: F) -> DispatchResult + pub(crate) fn try_mutate_pool( + market_id: &MarketIdOf, + mutator: F, + ) -> Result where - F: FnMut(&mut PoolOf) -> DispatchResult, + F: FnMut(&mut PoolOf) -> Result, { Pools::::try_mutate(market_id, |maybe_pool| { maybe_pool.as_mut().ok_or(Error::::PoolNotFound.into()).and_then(mutator) diff --git a/zrml/neo-swaps/src/liquidity_tree/macros.rs b/zrml/neo-swaps/src/liquidity_tree/macros.rs new file mode 100644 index 000000000..6db3979d1 --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/macros.rs @@ -0,0 +1,70 @@ +// 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 . + +/// Asserts that a liquidity tree has the specified state. +/// +/// Parameters: +/// +/// - `tree`: The `LiquidityTree` to check. +/// - `expected_nodes`: The expected `tree.nodes`. +/// - `expected_accounts_to_index`: The expected `tree.accounts_to_index`. +/// - `expected_abandoned_nodes`: The expected `tree.abandoned_nodes`. +#[macro_export] +macro_rules! assert_liquidity_tree_state { + ( + $tree:expr, + $expected_nodes:expr, + $expected_account_to_index:expr, + $expected_abandoned_nodes:expr + $(,)? + ) => { + let actual_nodes = $tree.nodes.clone().into_inner(); + let max_len = std::cmp::max($expected_nodes.len(), actual_nodes.len()); + let mut error = false; + for index in 0..max_len { + match ($expected_nodes.get(index), actual_nodes.get(index)) { + (Some(exp), Some(act)) => { + if exp != act { + error = true; + eprintln!( + "assert_liquidity_tree_state: Mismatched node at index {}", + index, + ); + eprintln!(" Expected node: {:?}", exp); + eprintln!(" Actual node: {:?}", act); + } + } + (None, Some(act)) => { + error = true; + eprintln!("assert_liquidity_tree_state: Extra node at index {}", index); + eprintln!(" {:?}", act); + } + (Some(exp), None) => { + error = true; + eprintln!("assert_liquidity_tree_state: Missing node at index {}", index); + eprintln!(" {:?}", exp); + } + (None, None) => break, + } + } + if error { + panic!(); + } + assert_eq!($expected_account_to_index, $tree.account_to_index.clone().into_inner()); + assert_eq!($expected_abandoned_nodes, $tree.abandoned_nodes.clone().into_inner()); + }; +} diff --git a/zrml/neo-swaps/src/liquidity_tree/mod.rs b/zrml/neo-swaps/src/liquidity_tree/mod.rs new file mode 100644 index 000000000..05d14b703 --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/mod.rs @@ -0,0 +1,21 @@ +// 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 macros; +mod tests; +pub(crate) mod traits; +pub(crate) mod types; diff --git a/zrml/neo-swaps/src/liquidity_tree/tests/deposit_fees.rs b/zrml/neo-swaps/src/liquidity_tree/tests/deposit_fees.rs new file mode 100644 index 000000000..e1218c9ce --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/tests/deposit_fees.rs @@ -0,0 +1,45 @@ +// 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 deposit_fees_works_root() { + let mut tree = utility::create_test_tree(); + let mut nodes = tree.nodes.clone().into_inner(); + let account_to_index = tree.account_to_index.clone().into_inner(); + let abandoned_nodes = tree.abandoned_nodes.clone().into_inner(); + let amount = _12; + nodes[0].lazy_fees += amount; + tree.deposit_fees(amount).unwrap(); + assert_liquidity_tree_state!(tree, nodes, account_to_index, abandoned_nodes); +} + +#[test] +fn deposit_fees_works_no_root() { + let mut tree = utility::create_test_tree(); + tree.nodes[0].account = None; + tree.nodes[1].stake = Zero::zero(); + tree.nodes[2].fees = Zero::zero(); + let mut nodes = tree.nodes.clone().into_inner(); + let account_to_index = tree.account_to_index.clone().into_inner(); + let abandoned_nodes = tree.abandoned_nodes.clone().into_inner(); + let amount = _12; + nodes[0].lazy_fees += amount; + tree.deposit_fees(amount).unwrap(); + assert_liquidity_tree_state!(tree, nodes, account_to_index, abandoned_nodes); +} diff --git a/zrml/neo-swaps/src/liquidity_tree/tests/exit.rs b/zrml/neo-swaps/src/liquidity_tree/tests/exit.rs new file mode 100644 index 000000000..fc34abbda --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/tests/exit.rs @@ -0,0 +1,173 @@ +// 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 crate::{AccountIdOf, BalanceOf}; +use test_case::test_case; + +#[test_case(false)] +#[test_case(true)] +fn exit_root_works(withdraw_all: bool) { + let mut tree = utility::create_test_tree(); + // Remove lazy fees on the path to the node (and actual fees from the node). + tree.nodes[0].lazy_fees = Zero::zero(); + tree.nodes[0].fees = Zero::zero(); + + let mut nodes = tree.nodes.clone().into_inner(); + let amount = if withdraw_all { _1 } else { _1_2 }; + let account = 3; + nodes[0].stake -= amount; + let mut account_to_index = tree.account_to_index.clone().into_inner(); + let mut abandoned_nodes = tree.abandoned_nodes.clone().into_inner(); + if withdraw_all { + nodes[0].account = None; + account_to_index.remove(&account); + abandoned_nodes.push(0); + } + + tree.exit(&account, amount).unwrap(); + assert_liquidity_tree_state!(tree, nodes, account_to_index, abandoned_nodes); +} + +#[test_case(false)] +#[test_case(true)] +fn exit_middle_works(withdraw_all: bool) { + let mut tree = utility::create_test_tree(); + // Remove lazy fees on the path to the node (and actual fees from the node). + tree.nodes[0].lazy_fees = Zero::zero(); + tree.nodes[1].lazy_fees = Zero::zero(); + tree.nodes[3].lazy_fees = Zero::zero(); + tree.nodes[3].fees = Zero::zero(); + + let mut nodes = tree.nodes.clone().into_inner(); + let amount = if withdraw_all { _3 } else { _1 }; + let account = 5; + nodes[0].descendant_stake -= amount; + nodes[1].descendant_stake -= amount; + nodes[3].stake -= amount; + let mut account_to_index = tree.account_to_index.clone().into_inner(); + let mut abandoned_nodes = tree.abandoned_nodes.clone().into_inner(); + if withdraw_all { + nodes[3].account = None; + account_to_index.remove(&account); + abandoned_nodes.push(3); + } + + tree.exit(&account, amount).unwrap(); + assert_liquidity_tree_state!(tree, nodes, account_to_index, abandoned_nodes); +} + +#[test_case(false)] +#[test_case(true)] +fn exit_leaf_works(withdraw_all: bool) { + let mut tree = utility::create_test_tree(); + // Remove lazy fees on the path to the node (and actual fees from the node). + tree.nodes[0].lazy_fees = Zero::zero(); + tree.nodes[1].lazy_fees = Zero::zero(); + tree.nodes[3].lazy_fees = Zero::zero(); + tree.nodes[7].lazy_fees = Zero::zero(); + tree.nodes[7].fees = Zero::zero(); + + let mut nodes = tree.nodes.clone().into_inner(); + let amount = if withdraw_all { _12 } else { _1 }; + let account = 6; + nodes[0].descendant_stake -= amount; + nodes[1].descendant_stake -= amount; + nodes[3].descendant_stake -= amount; + nodes[7].stake -= amount; + let mut account_to_index = tree.account_to_index.clone().into_inner(); + let mut abandoned_nodes = tree.abandoned_nodes.clone().into_inner(); + if withdraw_all { + nodes[7].account = None; + account_to_index.remove(&account); + abandoned_nodes.push(7); + } + + tree.exit(&account, amount).unwrap(); + assert_liquidity_tree_state!(tree, nodes, account_to_index, abandoned_nodes); +} + +#[test_case(3, _1 + 1)] +#[test_case(9, _3 + 1)] +#[test_case(5, _3 + 1)] +#[test_case(7, _1 + 1)] +#[test_case(6, _12 + 1)] +#[test_case(8, _4 + 1)] +fn exit_fails_on_insufficient_stake(account: AccountIdOf, amount: BalanceOf) { + let mut tree = utility::create_test_tree(); + // Clear unclaimed fees. + for node in tree.nodes.iter_mut() { + node.fees = Zero::zero(); + node.lazy_fees = Zero::zero(); + } + assert_err!( + tree.exit(&account, amount), + LiquidityTreeError::InsufficientStake.into_dispatch_error::(), + ); +} + +#[test] +fn exit_fails_on_unclaimed_fees_at_root() { + let mut tree = utility::create_test_tree(); + // Clear unclaimed fees except for root. + tree.nodes[0].lazy_fees = _1; + tree.nodes[1].lazy_fees = Zero::zero(); + tree.nodes[3].fees = Zero::zero(); + tree.nodes[3].lazy_fees = Zero::zero(); + assert_err!( + tree.exit(&5, 1), + LiquidityTreeError::UnwithdrawnFees.into_dispatch_error::() + ); +} + +#[test] +fn exit_fails_on_unclaimed_fees_on_middle_of_path() { + let mut tree = utility::create_test_tree(); + // Clear unclaimed fees except for the middle node. + tree.nodes[3].fees = Zero::zero(); + tree.nodes[3].lazy_fees = Zero::zero(); + assert_err!( + tree.exit(&5, 1), + LiquidityTreeError::UnwithdrawnFees.into_dispatch_error::() + ); +} + +#[test] +fn exit_fails_on_unclaimed_fees_at_last_node_due_to_lazy_fees() { + let mut tree = utility::create_test_tree(); + // Clear unclaimed fees except for the last node. + tree.nodes[1].lazy_fees = Zero::zero(); + // This ensures that the error is caused by propagated lazy fees sitting in the node. + tree.nodes[3].fees = Zero::zero(); + assert_err!( + tree.exit(&5, 1), + LiquidityTreeError::UnwithdrawnFees.into_dispatch_error::() + ); +} + +#[test] +fn exit_fails_on_unclaimed_fees_at_last_node_due_to_fees() { + let mut tree = utility::create_test_tree(); + // Clear unclaimed fees except for the last node. + tree.nodes[1].lazy_fees = Zero::zero(); + // This ensures that the error is caused by normal fees. + tree.nodes[3].lazy_fees = Zero::zero(); + assert_err!( + tree.exit(&5, 1), + LiquidityTreeError::UnwithdrawnFees.into_dispatch_error::() + ); +} diff --git a/zrml/neo-swaps/src/liquidity_tree/tests/join.rs b/zrml/neo-swaps/src/liquidity_tree/tests/join.rs new file mode 100644 index 000000000..973b0241d --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/tests/join.rs @@ -0,0 +1,196 @@ +// 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 join_in_place_works_root() { + let mut tree = utility::create_test_tree(); + tree.nodes[0].lazy_fees = _36; + let mut nodes = tree.nodes.clone().into_inner(); + let account_to_index = tree.account_to_index.clone().into_inner(); + let abandoned_nodes = tree.abandoned_nodes.clone().into_inner(); + let amount = _2; + nodes[0].stake += amount; + // Distribute lazy fees of node at index 0. + nodes[0].fees += 15_000_000_000; // 1.5 + nodes[0].lazy_fees = Zero::zero(); + nodes[1].lazy_fees += 300_000_000_000; // 30 + nodes[2].lazy_fees += 45_000_000_000; // 4.5 + tree.join(&3, amount).unwrap(); + assert_liquidity_tree_state!(tree, nodes, account_to_index, abandoned_nodes); +} + +#[test] +fn join_in_place_works_leaf() { + let mut tree = utility::create_test_tree(); + let mut nodes = tree.nodes.clone().into_inner(); + let account_to_index = tree.account_to_index.clone().into_inner(); + let abandoned_nodes = tree.abandoned_nodes.clone().into_inner(); + let amount = _2; + nodes[0].descendant_stake += amount; + nodes[1].descendant_stake += amount; + nodes[3].descendant_stake += amount; + nodes[7].stake += amount; + // Distribute lazy fees of node at index 1 and 3. + nodes[1].lazy_fees = Zero::zero(); + nodes[3].fees += 12_000_000_000; // 1.2 + nodes[3].lazy_fees = Zero::zero(); + nodes[4].lazy_fees += _1; + nodes[7].fees += 78_000_000_000; // 7.8 (4.8 propagated and 3 lazy fees in place) + nodes[7].lazy_fees = Zero::zero(); + tree.join(&6, amount).unwrap(); + assert_liquidity_tree_state!(tree, nodes, account_to_index, abandoned_nodes); +} + +#[test] +fn join_in_place_works_middle() { + let mut tree = utility::create_test_tree(); + let mut nodes = tree.nodes.clone().into_inner(); + let account_to_index = tree.account_to_index.clone().into_inner(); + let abandoned_nodes = tree.abandoned_nodes.clone().into_inner(); + let amount = _2; + nodes[0].descendant_stake += amount; + nodes[1].descendant_stake += amount; + nodes[3].stake += amount; + // Distribute lazy fees of node at index 1 and 3. + nodes[1].lazy_fees = Zero::zero(); + nodes[3].fees += 12_000_000_000; // 1.2 + nodes[3].lazy_fees = 0; + nodes[4].lazy_fees += _1; + nodes[7].lazy_fees += 48_000_000_000; // 4.8 + tree.join(&5, amount).unwrap(); + assert_liquidity_tree_state!(tree, nodes, account_to_index, abandoned_nodes); +} + +#[test] +fn join_reassigned_works_middle() { + let mut tree = utility::create_test_tree(); + // Manipulate which node is joined by changing the order of abandoned nodes. + tree.abandoned_nodes[0] = 8; + tree.abandoned_nodes[3] = 1; + let mut nodes = tree.nodes.clone().into_inner(); + let account = 99; + let amount = _2; + + // Add new account. + nodes[0].descendant_stake += amount; + nodes[1].account = Some(account); + nodes[1].stake = amount; + nodes[1].lazy_fees = Zero::zero(); + // Propagate fees of node at index 1. + nodes[3].lazy_fees += _3; + nodes[4].lazy_fees += _1; + let mut account_to_index = tree.account_to_index.clone().into_inner(); + account_to_index.insert(account, 1); + let mut abandoned_nodes = tree.abandoned_nodes.clone().into_inner(); + abandoned_nodes.pop(); + + tree.join(&account, amount).unwrap(); + assert_liquidity_tree_state!(tree, nodes, account_to_index, abandoned_nodes); +} + +#[test] +fn join_reassigned_works_root() { + let mut tree = utility::create_test_tree(); + // Store original test tree. + let mut nodes = tree.nodes.clone().into_inner(); + // Manipulate test tree so that it looks like root was abandoned. + tree.nodes[0].account = None; + tree.nodes[0].stake = Zero::zero(); + tree.nodes[0].fees = Zero::zero(); + tree.nodes[0].lazy_fees = 345_000_000_000; // 34.5 + tree.abandoned_nodes.try_push(0).unwrap(); + tree.account_to_index.remove(&3); + + // Prepare expected data. The only things that have changed are that the 34.5 units of + // collateral are propagated to the nodes of depth 1; and the root. + let account = 99; + let amount = _3; + nodes[0].account = Some(account); + nodes[0].stake = amount; + nodes[0].fees = Zero::zero(); + nodes[0].lazy_fees = Zero::zero(); + nodes[1].lazy_fees += _30; + nodes[2].lazy_fees += 45_000_000_000; // 4.5 + let mut account_to_index = tree.account_to_index.clone().into_inner(); + account_to_index.insert(account, 0); + let mut abandoned_nodes = tree.abandoned_nodes.clone().into_inner(); + abandoned_nodes.pop(); + + tree.join(&account, amount).unwrap(); + assert_liquidity_tree_state!(tree, nodes, account_to_index, abandoned_nodes); +} + +#[test] +fn join_reassigned_works_leaf() { + let mut tree = utility::create_test_tree(); + let mut nodes = tree.nodes.clone().into_inner(); + let account = 99; + let amount = _3; + nodes[0].descendant_stake += amount; + nodes[1].descendant_stake += amount; + nodes[3].descendant_stake += amount; + nodes[8].account = Some(account); + nodes[8].stake = amount; + // Distribute lazy fees of node at index 1, 3 and 7 (same as join_reassigned_works_middle). + nodes[1].lazy_fees = Zero::zero(); + nodes[3].fees += 12_000_000_000; // 1.2 + nodes[3].lazy_fees = 0; + nodes[4].lazy_fees += _1; + nodes[7].lazy_fees += 48_000_000_000; // 4.8 + + let mut account_to_index = tree.account_to_index.clone().into_inner(); + account_to_index.insert(account, 8); + let mut abandoned_nodes = tree.abandoned_nodes.clone().into_inner(); + abandoned_nodes.pop(); + + tree.join(&account, amount).unwrap(); + assert_liquidity_tree_state!(tree, nodes, account_to_index, abandoned_nodes); +} + +#[test] +fn join_in_place_works_if_tree_is_full() { + let mut tree = utility::create_full_tree(); + // Remove one node. + tree.nodes[0].descendant_stake -= tree.nodes[2].stake; + tree.nodes[2].account = None; + tree.nodes[2].stake = Zero::zero(); + tree.account_to_index.remove(&2); + tree.abandoned_nodes.try_push(2).unwrap(); + let mut nodes = tree.nodes.clone().into_inner(); + let account = 99; + let stake = 2; + nodes[2].account = Some(account); + nodes[2].stake = stake; + nodes[0].descendant_stake += stake; + let mut account_to_index = tree.account_to_index.clone().into_inner(); + let mut abandoned_nodes = tree.abandoned_nodes.clone().into_inner(); + account_to_index.insert(account, 2); + abandoned_nodes.pop(); + tree.join(&account, stake).unwrap(); + assert_liquidity_tree_state!(tree, nodes, account_to_index, abandoned_nodes); +} + +#[test] +fn join_new_fails_if_tree_is_full() { + let mut tree = utility::create_full_tree(); + assert_err!( + tree.join(&99, _1), + LiquidityTreeError::TreeIsFull.into_dispatch_error::() + ); +} diff --git a/zrml/neo-swaps/src/liquidity_tree/tests/mod.rs b/zrml/neo-swaps/src/liquidity_tree/tests/mod.rs new file mode 100644 index 000000000..9b23c8e38 --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/tests/mod.rs @@ -0,0 +1,175 @@ +// 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))] + +use crate::{ + assert_liquidity_tree_state, + consts::*, + create_b_tree_map, + liquidity_tree::{ + traits::liquidity_tree_helper::LiquidityTreeHelper, + types::{LiquidityTreeError, Node}, + }, + mock::Runtime, + traits::liquidity_shares_manager::LiquiditySharesManager, + LiquidityTreeOf, +}; +use alloc::collections::BTreeMap; +use frame_support::assert_err; +use sp_runtime::traits::Zero; + +mod deposit_fees; +mod exit; +mod join; +mod shares_of; +mod total_shares; +mod withdraw_fees; + +/// Most tests use the same pattern: +/// +/// - Create a test tree. In some cases the test tree needs to be modified as part of the test +/// setup. +/// - Clone the contents of the tests tree and modify them to obtain the expected state of the tree +/// after executing the test. +/// - Run the test. +/// - Verify state. +mod utility { + use super::*; + + /// Create the following liquidity tree: + /// + /// (3, _1, _2, _23, 0) + /// / \ + /// (None, 0, 0, _20, _4) (9, _3, _5, 0, 0) + /// / \ / \ + /// (5, _3, _1, _12, _3) (7, _1, _1, _4, _3) (None, 0, 0, 0, 0) (None, 0, 0, 0, 0) + /// / \ / + /// (6, _12, _1, 0, _3) (None, 0, 0, 0, 0) (8, _4, _1, 0, 0) + /// + /// This tree is used in most tests, but will sometime have to be modified. + pub(super) fn create_test_tree() -> LiquidityTreeOf { + LiquidityTreeOf:: { + nodes: vec![ + // Root + Node:: { + account: Some(3), + stake: _1, + fees: _2, + descendant_stake: _23, + lazy_fees: Zero::zero(), + }, + // Depth 1 + Node:: { + account: None, + stake: Zero::zero(), + fees: Zero::zero(), + descendant_stake: _20, + lazy_fees: _4, + }, + Node:: { + account: Some(9), + stake: _3, + fees: _5, + descendant_stake: Zero::zero(), + lazy_fees: Zero::zero(), + }, + // Depth 2 + Node:: { + account: Some(5), + stake: _3, + fees: _1, + descendant_stake: _12, + lazy_fees: _3, + }, + Node:: { + account: Some(7), + stake: _1, + fees: _1, + descendant_stake: _4, + lazy_fees: _3, + }, + Node:: { + account: None, + stake: Zero::zero(), + fees: Zero::zero(), + descendant_stake: Zero::zero(), + lazy_fees: Zero::zero(), + }, + Node:: { + account: None, + stake: Zero::zero(), + fees: Zero::zero(), + descendant_stake: Zero::zero(), + lazy_fees: Zero::zero(), + }, + // Depth 3 + Node:: { + account: Some(6), + stake: _12, + fees: _1, + descendant_stake: Zero::zero(), + lazy_fees: _3, + }, + Node:: { + account: None, + stake: Zero::zero(), + fees: Zero::zero(), + descendant_stake: Zero::zero(), + lazy_fees: Zero::zero(), + }, + Node:: { + account: Some(8), + stake: _4, + fees: _1, + descendant_stake: Zero::zero(), + lazy_fees: Zero::zero(), + }, + ] + .try_into() + .unwrap(), + account_to_index: create_b_tree_map!({3 => 0, 9 => 2, 5 => 3, 7 => 4, 6 => 7, 8 => 9}) + .try_into() + .unwrap(), + abandoned_nodes: vec![1, 5, 6, 8].try_into().unwrap(), + } + } + + /// Create a full tree. All nodes have the same stake of 1. + pub(super) fn create_full_tree() -> LiquidityTreeOf { + let max_depth = LiquidityTreeOf::::max_depth(); + let node_count = LiquidityTreeOf::::max_node_count(); + let nodes = (0..node_count) + .map(|a| Node::::new(a as u128, 1)) + .collect::>() + .try_into() + .unwrap(); + let account_to_index = + (0..node_count).map(|a| (a as u128, a)).collect::>().try_into().unwrap(); + let mut tree = LiquidityTreeOf:: { + nodes, + account_to_index, + abandoned_nodes: vec![].try_into().unwrap(), + }; + // Nodes have the wrong descendant stake at this point, so let's fix that. + for (index, node) in tree.nodes.iter_mut().enumerate() { + let exp = max_depth + 1 - (index + 1).checked_ilog2().unwrap(); + node.descendant_stake = 2u128.pow(exp) - 2; + } + tree + } +} diff --git a/zrml/neo-swaps/src/liquidity_tree/tests/shares_of.rs b/zrml/neo-swaps/src/liquidity_tree/tests/shares_of.rs new file mode 100644 index 000000000..816ea02ef --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/tests/shares_of.rs @@ -0,0 +1,29 @@ +// 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 shares_of_works() { + let tree = utility::create_test_tree(); + assert_eq!(tree.shares_of(&3).unwrap(), _1); + assert_eq!(tree.shares_of(&9).unwrap(), _3); + assert_eq!(tree.shares_of(&5).unwrap(), _3); + assert_eq!(tree.shares_of(&7).unwrap(), _1); + assert_eq!(tree.shares_of(&6).unwrap(), _12); + assert_eq!(tree.shares_of(&8).unwrap(), _4); +} diff --git a/zrml/neo-swaps/src/liquidity_tree/tests/total_shares.rs b/zrml/neo-swaps/src/liquidity_tree/tests/total_shares.rs new file mode 100644 index 000000000..94dd61b22 --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/tests/total_shares.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 super::*; + +#[test] +fn total_shares_works() { + let tree = utility::create_test_tree(); + assert_eq!(tree.total_shares().unwrap(), _24); +} diff --git a/zrml/neo-swaps/src/liquidity_tree/tests/withdraw_fees.rs b/zrml/neo-swaps/src/liquidity_tree/tests/withdraw_fees.rs new file mode 100644 index 000000000..ff4a0a77e --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/tests/withdraw_fees.rs @@ -0,0 +1,73 @@ +// 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_root() { + let mut tree = utility::create_test_tree(); + tree.nodes[0].lazy_fees = _36; + let mut nodes = tree.nodes.clone().into_inner(); + let account_to_index = tree.account_to_index.clone().into_inner(); + let abandoned_nodes = tree.abandoned_nodes.clone().into_inner(); + + // Distribute lazy fees of node at index 0. + nodes[0].fees = Zero::zero(); + nodes[0].lazy_fees = Zero::zero(); + nodes[1].lazy_fees += 300_000_000_000; // 30 + nodes[2].lazy_fees += 45_000_000_000; // 4.5 + + assert_eq!(tree.withdraw_fees(&3).unwrap(), 35_000_000_000); // 2 (fees) + 1.5 (lazy) + assert_liquidity_tree_state!(tree, nodes, account_to_index, abandoned_nodes); +} + +#[test] +fn withdraw_fees_works_middle() { + let mut tree = utility::create_test_tree(); + let mut nodes = tree.nodes.clone().into_inner(); + let account_to_index = tree.account_to_index.clone().into_inner(); + let abandoned_nodes = tree.abandoned_nodes.clone().into_inner(); + + // Distribute lazy fees of node at index 1, 3 and 7 (same as join_reassigned_works_middle). + nodes[1].lazy_fees = Zero::zero(); + nodes[3].fees = Zero::zero(); + nodes[3].lazy_fees = Zero::zero(); + nodes[4].lazy_fees += _1; + nodes[7].lazy_fees += 48_000_000_000; // 4.8 + + assert_eq!(tree.withdraw_fees(&5).unwrap(), 22_000_000_000); // 1 (fees) + 1.2 (lazy) + assert_liquidity_tree_state!(tree, nodes, account_to_index, abandoned_nodes); +} + +#[test] +fn withdraw_fees_works_leaf() { + let mut tree = utility::create_test_tree(); + let mut nodes = tree.nodes.clone().into_inner(); + let account_to_index = tree.account_to_index.clone().into_inner(); + let abandoned_nodes = tree.abandoned_nodes.clone().into_inner(); + + // Distribute lazy fees of node at index 1, 3 and 7 (same as join_reassigned_works_middle). + nodes[1].lazy_fees = Zero::zero(); + nodes[3].fees += 12_000_000_000; // 1.2 + nodes[3].lazy_fees = Zero::zero(); + nodes[4].lazy_fees += _1; + nodes[7].fees = Zero::zero(); + nodes[7].lazy_fees = Zero::zero(); + + assert_eq!(tree.withdraw_fees(&6).unwrap(), 88_000_000_000); // 1 (fees) + 7.8 (lazy) + assert_liquidity_tree_state!(tree, nodes, account_to_index, abandoned_nodes); +} diff --git a/zrml/neo-swaps/src/liquidity_tree/traits/liquidity_tree_helper.rs b/zrml/neo-swaps/src/liquidity_tree/traits/liquidity_tree_helper.rs new file mode 100644 index 000000000..e73073d08 --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/traits/liquidity_tree_helper.rs @@ -0,0 +1,114 @@ +// 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::{ + liquidity_tree::types::{LiquidityTreeChildIndices, UpdateDescendantStakeOperation}, + BalanceOf, Config, +}; +use alloc::vec::Vec; +use sp_runtime::{DispatchError, DispatchResult}; + +/// A collection of member functions used in the implementation of `LiquiditySharesManager` for +/// `LiquidityTree`. +pub(crate) trait LiquidityTreeHelper +where + T: Config, +{ + type Node; + + /// Propagate lazy fees from the tree's root to the node at `index`. + /// + /// Propagation includes moving the part of the lazy fees of each node on the path to the node + /// at `index` to the node's fees. + /// + /// Assuming correct state this function can only fail if there is no node at `index`. + fn propagate_fees_to_node(&mut self, index: u32) -> DispatchResult; + + /// Propagate lazy fees from the node at `index` to its children. + /// + /// Propagation includes moving the node's share of the lazy fees to the node's fees. + /// + /// Assuming correct state this function can only fail if there is no node at `index`. + fn propagate_fees(&mut self, index: u32) -> DispatchResult; + + /// Return the indices of the children of the node at `index`. + fn children(&self, index: u32) -> Result; + + /// Return the index of a node's parent; `None` if `index` is `0u32`, i.e. the node is root. + fn parent_index(&self, index: u32) -> Option; + + /// Return a path from the tree's root to the node at `index`. + /// + /// The return value is a vector of the indices of the nodes of the path, starting with the + /// root and including `index`. The parameter `opt_iterations` specifies how many iterations the + /// operation is allowed to take and can be used to terminate if the number of iterations + /// exceeds the expected amount by setting it to `None`. + fn path_to_node( + &self, + index: u32, + opt_iterations: Option, + ) -> Result, DispatchError>; + + /// Pops the most recently abandoned node's index from the stack. Returns `None` if there's no + /// abandoned node. + fn take_last_abandoned_node_index(&mut self) -> Option; + + /// Returns the index of the next free leaf; `None` if the tree is full. + fn peek_next_free_leaf(&self) -> Option; + + /// Mutate a node's ancestor's `descendant_stake` field. + /// + /// # Parameters + /// + /// - `index`: The index of the node. + /// - `delta`: The (absolute) amount by which to modfiy the descendant stake. + /// - `op`: The sign of the delta. + fn update_descendant_stake_of_ancestors( + &mut self, + index: u32, + delta: BalanceOf, + op: UpdateDescendantStakeOperation, + ) -> DispatchResult; + + /// Mutate each child of the node at `index` using `mutator`. + fn mutate_each_child(&mut self, index: u32, mutator: F) -> DispatchResult + where + F: FnMut(&mut Self::Node) -> DispatchResult; + + /// Return the number of nodes in the tree. Note that abandoned nodes are counted. + fn node_count(&self) -> u32; + + /// Get a reference to the node at `index`. + fn get_node(&self, index: u32) -> Result<&Self::Node, DispatchError>; + + /// Get a mutable reference to the node at `index`. + fn get_node_mut(&mut self, index: u32) -> Result<&mut Self::Node, DispatchError>; + + /// Get the node which belongs to `account`. + fn map_account_to_index(&self, account: &T::AccountId) -> Result; + + /// Mutate the node at `index` using `mutator`. + fn mutate_node(&mut self, index: u32, mutator: F) -> DispatchResult + where + F: FnOnce(&mut Self::Node) -> DispatchResult; + + /// Return the maximum allowed depth of the tree. + fn max_depth() -> u32; + + /// Return the maximum allowed amount of nodes in the tree. + fn max_node_count() -> u32; +} diff --git a/zrml/neo-swaps/src/liquidity_tree/traits/mod.rs b/zrml/neo-swaps/src/liquidity_tree/traits/mod.rs new file mode 100644 index 000000000..08dcddd9d --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/traits/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(crate) mod liquidity_tree_helper; + +pub(crate) use liquidity_tree_helper::*; diff --git a/zrml/neo-swaps/src/liquidity_tree/types/liquidity_tree.rs b/zrml/neo-swaps/src/liquidity_tree/types/liquidity_tree.rs new file mode 100644 index 000000000..5986b85f2 --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/types/liquidity_tree.rs @@ -0,0 +1,430 @@ +// 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::{ + liquidity_tree::{ + traits::LiquidityTreeHelper, + types::{ + LiquidityTreeChildIndices, LiquidityTreeError, LiquidityTreeMaxNodes, Node, + StorageOverflowError, UpdateDescendantStakeOperation, + }, + }, + traits::LiquiditySharesManager, + BalanceOf, Config, Error, +}; +use alloc::{vec, vec::Vec}; +use frame_support::{ + ensure, + pallet_prelude::RuntimeDebugNoBound, + storage::{bounded_btree_map::BoundedBTreeMap, bounded_vec::BoundedVec}, + traits::Get, + CloneNoBound, PartialEqNoBound, +}; +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_runtime::{ + traits::{AtLeast32BitUnsigned, CheckedSub, Zero}, + DispatchError, DispatchResult, +}; +use zeitgeist_primitives::math::{ + checked_ops_res::{CheckedAddRes, CheckedMulRes, CheckedSubRes}, + fixed::FixedMulDiv, +}; + +/// A segment tree used to track balances of liquidity shares which allows `O(log(n))` distribution +/// of fees. +/// +/// Each liquidity provider owns exactly one node of the tree which records their stake and fees. +/// When a liquidity provider leaves the tree, the node is not removed from the tree, but marked as +/// _abandoned_ instead. Abandoned nodes are reassigned when new LPs enter the tree. Nodes are added +/// to the leaves of the tree only if there are no abandoned nodes to reassign. +/// +/// Fees are lazily propagated down the tree. This allows fees to be deposited to the tree in `O(1)` +/// (fees deposited at the root and later propagated down). If a particular node requires to know +/// what its fees are, propagating fees to this node takes `O(depth)` operations (or, equivalently, +/// `O(log_2(node_count))`). +/// +/// # Generics +/// +/// - `T`: The pallet configuration. +/// - `U`: A getter for the maximum depth of the tree. Using a depth larger than `31` will result in +/// undefined behavior. +#[derive( + CloneNoBound, Decode, Encode, Eq, MaxEncodedLen, PartialEqNoBound, RuntimeDebugNoBound, TypeInfo, +)] +#[scale_info(skip_type_params(T, U))] +pub(crate) struct LiquidityTree +where + T: Config, + U: Get, +{ + /// A vector which holds the nodes of the tree. The nodes are ordered by depth (the root is the + /// first element of `nodes`) and from left to right. For example, the right-most grandchild of + /// the root is at index `6`. + pub(crate) nodes: BoundedVec, LiquidityTreeMaxNodes>, + /// Maps an account to the node that belongs to it. + pub(crate) account_to_index: BoundedBTreeMap>, + /// A vector that contains the indices of abandoned nodes. Sorted in the order in which the + /// nodes were abandoned, with the last element of the vector being the most recently abandoned + /// node. + pub(crate) abandoned_nodes: BoundedVec>, +} + +impl LiquidityTree +where + T: Config, + U: Get, +{ + /// Create a new liquidity tree. + /// + /// # Parameters + /// + /// - `account`: The account to which the tree's root belongs. + /// - `stake`: The stake of the tree's root. + pub(crate) fn new( + account: T::AccountId, + stake: BalanceOf, + ) -> Result, DispatchError> { + let root = Node::new(account.clone(), stake); + let nodes = vec![root] + .try_into() + .map_err(|_| StorageOverflowError::Nodes.into_dispatch_error::())?; + let mut account_to_index = BoundedBTreeMap::<_, _, _>::new(); + account_to_index + .try_insert(account, 0u32) + .map_err(|_| StorageOverflowError::AccountToIndex.into_dispatch_error::())?; + let abandoned_nodes = vec![] + .try_into() + .map_err(|_| StorageOverflowError::AbandonedNodes.into_dispatch_error::())?; + Ok(LiquidityTree { nodes, account_to_index, abandoned_nodes }) + } +} + +/// Execution path info for `join` calls. +#[derive(Debug, PartialEq)] +pub(crate) enum BenchmarkInfo { + /// The LP already owns a node in the tree. + InPlace, + /// The LP is reassigned an abandoned node. + Reassigned, + /// The LP is assigned a leaf of the tree. + Leaf, +} + +impl LiquiditySharesManager for LiquidityTree +where + T: Config + frame_system::Config, + T::AccountId: PartialEq, + BalanceOf: AtLeast32BitUnsigned + Copy + Zero, + U: Get, +{ + type JoinBenchmarkInfo = BenchmarkInfo; + + fn join( + &mut self, + who: &T::AccountId, + stake: BalanceOf, + ) -> Result { + let opt_index = self.account_to_index.get(who); + let (index, benchmark_info) = if let Some(&index) = opt_index { + // Pile onto existing account. + self.propagate_fees_to_node(index)?; + let node = self.get_node_mut(index)?; + node.stake = node.stake.checked_add_res(&stake)?; + (index, BenchmarkInfo::InPlace) + } else { + // Push onto new node. + let (index, benchmark_info) = if let Some(index) = self.take_last_abandoned_node_index() + { + self.propagate_fees_to_node(index)?; + let node = self.get_node_mut(index)?; + node.account = Some(who.clone()); + node.stake = stake; + node.fees = Zero::zero(); // Not necessary, but better safe than sorry. + // Don't change `descendant_stake`; we're still maintaining it for abandoned + // nodes. + node.lazy_fees = Zero::zero(); + (index, BenchmarkInfo::Reassigned) + } else if let Some(index) = self.peek_next_free_leaf() { + // Add new leaf. Propagate first so we don't propagate fees to the new leaf. + if let Some(parent_index) = self.parent_index(index) { + self.propagate_fees_to_node(parent_index)?; + } + self.nodes + .try_push(Node::new(who.clone(), stake)) + .map_err(|_| StorageOverflowError::Nodes.into_dispatch_error::())?; + (index, BenchmarkInfo::Leaf) + } else { + return Err(LiquidityTreeError::TreeIsFull.into_dispatch_error::()); + }; + self.account_to_index + .try_insert(who.clone(), index) + .map_err(|_| StorageOverflowError::AccountToIndex.into_dispatch_error::())?; + (index, benchmark_info) + }; + if let Some(parent_index) = self.parent_index(index) { + self.update_descendant_stake_of_ancestors( + parent_index, + stake, + UpdateDescendantStakeOperation::Add, + )?; + } + Ok(benchmark_info) + } + + fn exit(&mut self, who: &T::AccountId, stake: BalanceOf) -> DispatchResult { + let index = self.map_account_to_index(who)?; + self.propagate_fees_to_node(index)?; + let node = self.get_node_mut(index)?; + ensure!( + node.fees == Zero::zero(), + LiquidityTreeError::UnwithdrawnFees.into_dispatch_error::() + ); + node.stake = node + .stake + .checked_sub(&stake) + .ok_or(LiquidityTreeError::InsufficientStake.into_dispatch_error::())?; + if node.stake == Zero::zero() { + node.account = None; + self.abandoned_nodes + .try_push(index) + .map_err(|_| StorageOverflowError::AbandonedNodes.into_dispatch_error::())?; + let _ = self.account_to_index.remove(who); + } + if let Some(parent_index) = self.parent_index(index) { + self.update_descendant_stake_of_ancestors( + parent_index, + stake, + UpdateDescendantStakeOperation::Sub, + )?; + } + 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 { + let root = self.get_node_mut(0u32)?; + root.lazy_fees = root.lazy_fees.checked_add_res(&amount)?; + Ok(()) + } + + fn withdraw_fees(&mut self, who: &T::AccountId) -> Result, DispatchError> { + let index = self.map_account_to_index(who)?; + self.propagate_fees_to_node(index)?; + let node = self.get_node_mut(index)?; + let fees = node.fees; + node.fees = Zero::zero(); + Ok(fees) + } + + fn shares_of(&self, who: &T::AccountId) -> Result, DispatchError> { + let index = self.map_account_to_index(who)?; + let node = self.get_node(index)?; + Ok(node.stake) + } + + fn total_shares(&self) -> Result, DispatchError> { + let root = self.get_node(0u32)?; + root.total_stake() + } +} + +impl LiquidityTreeHelper for LiquidityTree +where + T: Config, + U: Get, +{ + type Node = Node; + + fn propagate_fees_to_node(&mut self, index: u32) -> DispatchResult { + let path = self.path_to_node(index, None)?; + for i in path { + self.propagate_fees(i)?; + } + Ok(()) + } + + fn propagate_fees(&mut self, index: u32) -> DispatchResult { + let node = self.get_node(index)?; + if node.total_stake()? == Zero::zero() { + return Ok(()); // Don't propagate if there are no LPs under this node. + } + if node.is_weak_leaf() { + self.mutate_node(index, |node| { + node.fees = node.fees.checked_add_res(&node.lazy_fees)?; + Ok(()) + })?; + } else { + // Temporary storage to ensure that the borrow checker doesn't get upset. + let node_descendant_stake = node.descendant_stake; + // The lazy fees that will be propagated down the tree. + let mut remaining_lazy_fees = + node.descendant_stake.bmul_bdiv(node.lazy_fees, node.total_stake()?)?; + // The fees that stay at this node. + let fees = node.lazy_fees.checked_sub_res(&remaining_lazy_fees)?; + self.mutate_node(index, |node| { + node.fees = node.fees.checked_add_res(&fees)?; + Ok(()) + })?; + let (opt_lhs_index, opt_rhs_index) = self.children(index)?.into(); + if let Some(lhs_index) = opt_lhs_index { + self.mutate_node(lhs_index, |lhs_node| { + // The descendant's share of the stake: + let child_lazy_fees = lhs_node + .total_stake()? + .bmul_bdiv(remaining_lazy_fees, node_descendant_stake)?; + lhs_node.lazy_fees = lhs_node.lazy_fees.checked_add_res(&child_lazy_fees)?; + remaining_lazy_fees = remaining_lazy_fees.checked_sub_res(&child_lazy_fees)?; + Ok(()) + })?; + } + if let Some(rhs_index) = opt_rhs_index { + self.mutate_node(rhs_index, |rhs_node| { + rhs_node.lazy_fees = + rhs_node.lazy_fees.checked_add_res(&remaining_lazy_fees)?; + Ok(()) + })?; + } + } + self.mutate_node(index, |node| { + node.lazy_fees = Zero::zero(); + Ok(()) + })?; + Ok(()) + } + + fn children(&self, index: u32) -> Result { + let calculate_child = + |child_index: u32| Some(child_index).filter(|&i| i < self.node_count()); + let left_child_index = index.checked_mul_res(&2)?.checked_add_res(&1)?; + let lhs = calculate_child(left_child_index); + let right_child_index = left_child_index.checked_add_res(&1)?; + let rhs = calculate_child(right_child_index); + Ok(LiquidityTreeChildIndices { lhs, rhs }) + } + + fn parent_index(&self, index: u32) -> Option { + if index == 0 { None } else { index.checked_sub(1)?.checked_div(2) } + } + + fn path_to_node( + &self, + index: u32, + opt_iterations: Option, + ) -> Result, DispatchError> { + let remaining_iterations = + opt_iterations.unwrap_or(Self::max_depth().checked_add_res(&1)? as usize); + let remaining_iterations = remaining_iterations + .checked_sub(1) + .ok_or(LiquidityTreeError::MaxIterationsReached.into_dispatch_error::())?; + if let Some(parent_index) = self.parent_index(index) { + let mut path = self.path_to_node(parent_index, Some(remaining_iterations))?; + path.push(index); + Ok(path) + } else { + Ok(vec![0]) + } + } + + fn take_last_abandoned_node_index(&mut self) -> Option { + self.abandoned_nodes.pop() + } + + fn peek_next_free_leaf(&self) -> Option { + let node_count = self.node_count(); + if node_count < Self::max_node_count() { Some(node_count) } else { None } + } + + fn update_descendant_stake_of_ancestors( + &mut self, + index: u32, + delta: BalanceOf, + op: UpdateDescendantStakeOperation, + ) -> DispatchResult { + for &i in self.path_to_node(index, None)?.iter() { + let node = self.get_node_mut(i)?; + match op { + UpdateDescendantStakeOperation::Add => { + node.descendant_stake = node.descendant_stake.checked_add_res(&delta)? + } + UpdateDescendantStakeOperation::Sub => { + node.descendant_stake = node.descendant_stake.checked_sub_res(&delta)? + } + } + } + Ok(()) + } + + fn mutate_each_child(&mut self, index: u32, mut mutator: F) -> DispatchResult + where + F: FnMut(&mut Self::Node) -> DispatchResult, + { + let child_indices = self.children(index)?; + child_indices.apply(|index| { + self.mutate_node(index, |node| mutator(node))?; + Ok(()) + })?; + Ok(()) + } + + fn node_count(&self) -> u32 { + self.nodes.len() as u32 + } + + fn get_node(&self, index: u32) -> Result<&Self::Node, DispatchError> { + self.nodes + .get(index as usize) + .ok_or(LiquidityTreeError::NodeNotFound.into_dispatch_error::()) + } + + fn get_node_mut(&mut self, index: u32) -> Result<&mut Self::Node, DispatchError> { + self.nodes + .get_mut(index as usize) + .ok_or(LiquidityTreeError::NodeNotFound.into_dispatch_error::()) + } + + fn map_account_to_index(&self, who: &T::AccountId) -> Result { + self.account_to_index + .get(who) + .ok_or(LiquidityTreeError::AccountNotFound.into_dispatch_error::()) + .copied() + } + + fn mutate_node(&mut self, index: u32, mutator: F) -> DispatchResult + where + F: FnOnce(&mut Self::Node) -> DispatchResult, + { + let node = self.get_node_mut(index)?; + mutator(node) + } + + fn max_depth() -> u32 { + U::get() + } + + fn max_node_count() -> u32 { + LiquidityTreeMaxNodes::::get() + } +} diff --git a/zrml/neo-swaps/src/liquidity_tree/types/liquidity_tree_child_indices.rs b/zrml/neo-swaps/src/liquidity_tree/types/liquidity_tree_child_indices.rs new file mode 100644 index 000000000..4a4d47cad --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/types/liquidity_tree_child_indices.rs @@ -0,0 +1,51 @@ +// Copyright 2023 Forecasting Technologies LTD. +// +// This file is part of Zeitgeist. +// +// Zeitgeist is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at +// your option) any later version. +// +// Zeitgeist is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +use sp_runtime::DispatchError; + +/// Structure for managing children in a liquidity tree. +pub(crate) struct LiquidityTreeChildIndices { + /// Left-hand side child; `None` if there's no left-hand side child (the node is either empty or + /// the parent is a leaf). + pub(crate) lhs: Option, + /// Right-hand side child; `None` if there's no right-hand side child (the node is either empty + /// of the parent is a leaf). + pub(crate) rhs: Option, +} + +impl LiquidityTreeChildIndices { + /// Applies a `mutator` function to each child if it exists. + pub fn apply(&self, mut mutator: F) -> Result<(), DispatchError> + where + F: FnMut(u32) -> Result<(), DispatchError>, + { + if let Some(lhs) = self.lhs { + mutator(lhs)?; + } + if let Some(rhs) = self.rhs { + mutator(rhs)?; + } + Ok(()) + } +} + +// Implement `From` for destructuring +impl From for (Option, Option) { + fn from(child_indices: LiquidityTreeChildIndices) -> (Option, Option) { + (child_indices.lhs, child_indices.rhs) + } +} diff --git a/zrml/neo-swaps/src/liquidity_tree/types/liquidity_tree_error.rs b/zrml/neo-swaps/src/liquidity_tree/types/liquidity_tree_error.rs new file mode 100644 index 000000000..7d25a25f4 --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/types/liquidity_tree_error.rs @@ -0,0 +1,81 @@ +// 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::{Config, Error}; +use frame_support::{PalletError, RuntimeDebugNoBound}; +use parity_scale_codec::{Decode, Encode}; +use scale_info::TypeInfo; +use sp_runtime::DispatchError; + +#[derive(Decode, Encode, Eq, PartialEq, PalletError, RuntimeDebugNoBound, TypeInfo)] +pub enum LiquidityTreeError { + /// There is no node which belongs to this account. + AccountNotFound, + /// There is no node with this index. + NodeNotFound, + /// Operation can't be executed while there are unclaimed fees. + UnwithdrawnFees, + /// The liquidity tree is full and can't accept any new nodes. + TreeIsFull, + /// This node doesn't hold enough stake. + InsufficientStake, + /// A while loop exceeded the expected number of iterations. This is unexpected behavior. + MaxIterationsReached, + /// Unexpected storage overflow. + StorageOverflow(StorageOverflowError), +} + +#[derive(Decode, Encode, Eq, PartialEq, PalletError, RuntimeDebugNoBound, TypeInfo)] +pub enum StorageOverflowError { + /// Encountered a storage overflow when trying to push onto the `nodes` vector. + Nodes, + /// Encountered a storage overflow when trying to push onto the `account_to_index` map. + AccountToIndex, + /// Encountered a storage overflow when trying to push onto the `abandoned_nodes` vector. + AbandonedNodes, +} + +impl From for LiquidityTreeError { + fn from(error: StorageOverflowError) -> LiquidityTreeError { + LiquidityTreeError::StorageOverflow(error) + } +} + +impl StorageOverflowError { + pub(crate) fn into_dispatch_error(self) -> DispatchError + where + T: Config, + { + let liquidity_tree_error: LiquidityTreeError = self.into(); + liquidity_tree_error.into_dispatch_error::() + } +} + +impl From for Error { + fn from(error: LiquidityTreeError) -> Error { + Error::::LiquidityTreeError(error) + } +} + +impl LiquidityTreeError { + pub(crate) fn into_dispatch_error(self) -> DispatchError + where + T: Config, + { + Error::::LiquidityTreeError(self).into() + } +} diff --git a/zrml/neo-swaps/src/liquidity_tree/types/liquidity_tree_max_nodes.rs b/zrml/neo-swaps/src/liquidity_tree/types/liquidity_tree_max_nodes.rs new file mode 100644 index 000000000..fe1252dcc --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/types/liquidity_tree_max_nodes.rs @@ -0,0 +1,37 @@ +// 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 core::marker::PhantomData; +use sp_runtime::traits::Get; + +/// Gets the maximum number of nodes allowed in the liquidity tree as a function of its depth. +/// Saturates at `u32::MAX`, but will warn about this in DEBUG. +/// +/// # Generics +/// +/// - `D`: A getter for the depth of the tree. +pub(crate) struct LiquidityTreeMaxNodes(PhantomData); + +impl Get for LiquidityTreeMaxNodes +where + D: Get, +{ + fn get() -> u32 { + debug_assert!(D::get() < 31, "LiquidityTreeMaxNodes::get(): Integer overflow"); + 2u32.saturating_pow(D::get() + 1).saturating_sub(1) + } +} diff --git a/zrml/neo-swaps/src/liquidity_tree/types/mod.rs b/zrml/neo-swaps/src/liquidity_tree/types/mod.rs new file mode 100644 index 000000000..a6bd45681 --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/types/mod.rs @@ -0,0 +1,30 @@ +// 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(crate) mod liquidity_tree; +pub(crate) mod liquidity_tree_child_indices; +pub(crate) mod liquidity_tree_error; +pub(crate) mod liquidity_tree_max_nodes; +pub(crate) mod node; +pub(crate) mod update_descendant_stake_operation; + +pub(crate) use liquidity_tree::*; +pub(crate) use liquidity_tree_child_indices::*; +pub(crate) use liquidity_tree_error::*; +pub(crate) use liquidity_tree_max_nodes::*; +pub(crate) use node::*; +pub(crate) use update_descendant_stake_operation::*; diff --git a/zrml/neo-swaps/src/liquidity_tree/types/node.rs b/zrml/neo-swaps/src/liquidity_tree/types/node.rs new file mode 100644 index 000000000..a96db72aa --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/types/node.rs @@ -0,0 +1,72 @@ +// 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 frame_support::RuntimeDebugNoBound; +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_runtime::{traits::Zero, DispatchError}; +use zeitgeist_primitives::math::checked_ops_res::CheckedAddRes; + +/// Type for nodes of a liquidity tree. +/// +/// # Notes +/// +/// - `descendant_stake` does not contain the stake of `self`. +/// - `lazy_fees`, when propagated, is distributed not only to the descendants of `self`, but also to +/// `self`. +#[derive(Clone, Decode, Encode, Eq, MaxEncodedLen, PartialEq, RuntimeDebugNoBound, TypeInfo)] +#[scale_info(skip_type_params(T))] +pub(crate) struct Node { + /// The account that the node belongs to. `None` signifies an abandoned node. + pub(crate) account: Option, + /// The stake belonging to the owner. + pub(crate) stake: BalanceOf, + /// The fees owed to the owner. + pub(crate) fees: BalanceOf, + /// The sum of the stake of all descendants of this node. + pub(crate) descendant_stake: BalanceOf, + /// The amount of fees to be lazily propagated down the tree. + pub(crate) lazy_fees: BalanceOf, +} + +impl Node +where + T: Config, +{ + /// Create a new node with `stake` belonging to `account`. + pub(crate) fn new(account: T::AccountId, stake: BalanceOf) -> Node { + Node { + account: Some(account), + stake, + fees: 0u8.into(), + descendant_stake: 0u8.into(), + lazy_fees: 0u8.into(), + } + } + + /// Return the total stake of the node (the node's stake plus the sum of descendant's stakes). + pub(crate) fn total_stake(&self) -> Result, DispatchError> { + self.stake.checked_add_res(&self.descendant_stake) + } + + /// Return `true` is the node is a leaf in the sense that none of its descendants hold any + /// stake. (Strictly speaking, it's not always a leaf, as there might be abandoned nodes!) + pub(crate) fn is_weak_leaf(&self) -> bool { + self.descendant_stake == Zero::zero() + } +} diff --git a/zrml/neo-swaps/src/liquidity_tree/types/update_descendant_stake_operation.rs b/zrml/neo-swaps/src/liquidity_tree/types/update_descendant_stake_operation.rs new file mode 100644 index 000000000..be4785e61 --- /dev/null +++ b/zrml/neo-swaps/src/liquidity_tree/types/update_descendant_stake_operation.rs @@ -0,0 +1,22 @@ +// Copyright 2023 Forecasting Technologies LTD. +// +// This file is part of Zeitgeist. +// +// Zeitgeist is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at +// your option) any later version. +// +// Zeitgeist is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zeitgeist. If not, see . + +/// Type for specifying a sign for `update_descendant_stake_of_ancestors`. +pub(crate) enum UpdateDescendantStakeOperation { + Add, + Sub, +} diff --git a/zrml/neo-swaps/src/macros.rs b/zrml/neo-swaps/src/macros.rs new file mode 100644 index 000000000..e14242861 --- /dev/null +++ b/zrml/neo-swaps/src/macros.rs @@ -0,0 +1,156 @@ +// 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))] + +/// Creates an `alloc::collections::BTreeMap` from the pattern `{ key => value, ... }`. +/// +/// ```rust +/// // Example: +/// let m = create_b_tree_map!({ 0 => 1, 2 => 3 }); +/// assert_eq!(m[2], 3); +/// +/// // Overwriting a key: +/// let m = create_b_tree_map!({ 0 => "foo", 0 => "bar" }); +/// assert_eq!(m[0], "bar"); +/// ``` +#[macro_export] +macro_rules! create_b_tree_map { + ({ $($key:expr => $value:expr),* $(,)? } $(,)?) => { + [$(($key, $value),)*].iter().cloned().collect::>() + } +} + +/// Asserts that a market's LMSR liquidity pool has the specified state. +/// +/// In addition to verifying the specified state, the macro also ensures that the pool's trading +/// function is (approximately) equal to `1`. +/// +/// Parameters: +/// +/// - `market_id`: The ID of the market that the pool belongs to. +/// - `reserves`: The expected reserves of the pool. +/// - `spot_prices`: The expected spot prices of outcomes in the pool. +/// - `liquidity_parameter`: The expected liquidity parameter of the pool. +/// - `liquidity_shares`: An `alloc::collections::BTreeMap` which maps each liquidity provider to +/// their expected stake. +/// - `total_fees`: The sum of all fees (both lazy and distributed) in the pool's liquidity tree. +#[macro_export] +macro_rules! assert_pool_state { + ( + $market_id:expr, + $reserves:expr, + $spot_prices:expr, + $liquidity_parameter:expr, + $liquidity_shares:expr, + $total_fees:expr + $(,)? + ) => { + let pool = Pools::::get($market_id).unwrap(); + assert_eq!( + pool.reserves.values().cloned().collect::>(), + $reserves, + "assert_pool_state: Reserves mismatch" + ); + let actual_spot_prices = pool + .assets() + .iter() + .map(|&a| pool.calculate_spot_price(a).unwrap()) + .collect::>(); + assert_eq!(actual_spot_prices, $spot_prices, "assert_pool_state: Spot price mismatch"); + assert_eq!( + pool.liquidity_parameter, $liquidity_parameter, + "assert_pool_state: Liquidity parameter mismatch" + ); + let actual_liquidity_shares = pool + .liquidity_shares_manager + .account_to_index + .keys() + .map(|&account| { + ( + account, + pool.liquidity_shares_manager.shares_of(&account).expect( + format!("assert_pool_state: No shares found for {:?}", account).as_str(), + ), + ) + }) + .collect::>(); + assert_eq!( + actual_liquidity_shares, $liquidity_shares, + "assert_pool_state: Liquidity shares mismatch" + ); + let actual_total_fees = pool + .liquidity_shares_manager + .nodes + .iter() + .fold(0u128, |acc, node| acc + node.fees + node.lazy_fees); + assert_eq!(actual_total_fees, $total_fees); + let invariant = actual_spot_prices.iter().sum::(); + assert_approx!(invariant, _1, 1); + }; +} + +/// Asserts that `account` has the specified `balances` of `assets`. +#[macro_export] +macro_rules! assert_balances { + ($account:expr, $assets:expr, $balances:expr $(,)?) => { + assert_eq!( + $assets.len(), + $balances.len(), + "assert_balances: Assets and balances length mismatch" + ); + for (&asset, &expected_balance) in $assets.iter().zip($balances.iter()) { + let actual_balance = AssetManager::free_balance(asset, &$account); + assert_eq!( + actual_balance, expected_balance, + "assert_balances: Balance mismatch for asset {:?}", + asset, + ); + } + }; +} + +/// Asserts that `account` has the specified `balance` of `asset`. +#[macro_export] +macro_rules! assert_balance { + ($account:expr, $asset:expr, $balance:expr $(,)?) => { + assert_balances!($account, [$asset], [$balance]); + }; +} + +/// Asserts that `abs(left - right) < precision`. +#[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/migration.rs b/zrml/neo-swaps/src/migration.rs new file mode 100644 index 000000000..51e605c87 --- /dev/null +++ b/zrml/neo-swaps/src/migration.rs @@ -0,0 +1,254 @@ +// 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::{ + liquidity_tree::types::LiquidityTree, + types::{Pool, SoloLp}, + Config, Pallet, +}; +use frame_support::{ + dispatch::Weight, + log, + pallet_prelude::PhantomData, + traits::{Get, OnRuntimeUpgrade, StorageVersion}, +}; +use sp_runtime::traits::Saturating; + +cfg_if::cfg_if! { + if #[cfg(feature = "try-runtime")] { + use crate::{MarketIdOf, Pools}; + use alloc::{collections::BTreeMap, format, vec::Vec}; + use frame_support::{migration::storage_key_iter, pallet_prelude::Twox64Concat}; + use parity_scale_codec::{Decode, Encode}; + use sp_runtime::traits::Zero; + } +} + +cfg_if::cfg_if! { + if #[cfg(any(feature = "try-runtime", test))] { + const NEO_SWAPS: &[u8] = b"NeoSwaps"; + const POOLS: &[u8] = b"Pools"; + } +} + +const NEO_SWAPS_REQUIRED_STORAGE_VERSION: u16 = 0; +const NEO_SWAPS_NEXT_STORAGE_VERSION: u16 = NEO_SWAPS_REQUIRED_STORAGE_VERSION + 1; + +type OldPoolOf = Pool>; + +pub struct MigrateToLiquidityTree(PhantomData); + +impl OnRuntimeUpgrade for MigrateToLiquidityTree { + fn on_runtime_upgrade() -> Weight { + let mut total_weight = T::DbWeight::get().reads(1); + let market_commons_version = StorageVersion::get::>(); + if market_commons_version != NEO_SWAPS_REQUIRED_STORAGE_VERSION { + log::info!( + "MigrateToLiquidityTree: market-commons version is {:?}, but {:?} is required", + market_commons_version, + NEO_SWAPS_REQUIRED_STORAGE_VERSION, + ); + return total_weight; + } + log::info!("MigrateToLiquidityTree: Starting..."); + let mut translated = 0u64; + crate::Pools::::translate::, _>(|_, pool| { + let solo = pool.liquidity_shares_manager; + // This should never fail; if it does, then we just delete the entry. + let mut liquidity_tree = + LiquidityTree::new(solo.owner.clone(), solo.total_shares).ok()?; + liquidity_tree.nodes.get_mut(0)?.fees = solo.fees; // Can't fail. + translated.saturating_inc(); + Some(Pool { + account_id: pool.account_id, + reserves: pool.reserves, + collateral: pool.collateral, + liquidity_parameter: pool.liquidity_parameter, + liquidity_shares_manager: liquidity_tree, + swap_fee: pool.swap_fee, + }) + }); + log::info!("MigrateToLiquidityTree: Upgraded {} pools.", translated); + total_weight = + total_weight.saturating_add(T::DbWeight::get().reads_writes(translated, translated)); + StorageVersion::new(NEO_SWAPS_NEXT_STORAGE_VERSION).put::>(); + total_weight = total_weight.saturating_add(T::DbWeight::get().writes(1)); + log::info!("MigrateToLiquidityTree: Done!"); + total_weight + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, &'static str> { + let old_pools = + storage_key_iter::, OldPoolOf, Twox64Concat>(NEO_SWAPS, POOLS) + .collect::>(); + Ok(old_pools.encode()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(previous_state: Vec) -> Result<(), &'static str> { + let old_pools: BTreeMap, OldPoolOf> = + Decode::decode(&mut &previous_state[..]) + .map_err(|_| "Failed to decode state: Invalid state")?; + let new_pool_count = Pools::::iter().count(); + assert_eq!(old_pools.len(), new_pool_count); + for (market_id, new_pool) in Pools::::iter() { + let old_pool = + old_pools.get(&market_id).expect(&format!("Pool {:?} not found", market_id)[..]); + assert_eq!(new_pool.account_id, old_pool.account_id); + assert_eq!(new_pool.reserves, old_pool.reserves); + assert_eq!(new_pool.collateral, old_pool.collateral); + assert_eq!(new_pool.liquidity_parameter, old_pool.liquidity_parameter); + assert_eq!(new_pool.swap_fee, old_pool.swap_fee); + let tree = new_pool.liquidity_shares_manager; + let solo = &old_pool.liquidity_shares_manager; + assert_eq!(tree.nodes.len(), 1); + assert_eq!(tree.abandoned_nodes.len(), 0); + assert_eq!(tree.account_to_index.len(), 1); + let root = tree.nodes[0].clone(); + let account = root.account.clone(); + assert_eq!(root.account, Some(solo.owner.clone())); + assert_eq!(root.stake, solo.total_shares); + assert_eq!(root.fees, solo.fees); + assert_eq!(root.descendant_stake, Zero::zero()); + assert_eq!(root.lazy_fees, Zero::zero()); + let address = account.unwrap(); + assert_eq!(tree.account_to_index.get(&address), Some(&0)); + } + log::info!("MigrateToLiquidityTree: Post-upgrade pool count is {}!", new_pool_count); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + mock::{ExtBuilder, Runtime}, + MarketIdOf, PoolOf, Pools, + }; + use alloc::collections::BTreeMap; + use frame_support::{ + dispatch::fmt::Debug, migration::put_storage_value, storage_root, StateVersion, + StorageHasher, Twox64Concat, + }; + use parity_scale_codec::Encode; + use zeitgeist_primitives::types::Asset; + + #[test] + fn on_runtime_upgrade_increments_the_storage_version() { + ExtBuilder::default().build().execute_with(|| { + set_up_version(); + MigrateToLiquidityTree::::on_runtime_upgrade(); + assert_eq!(StorageVersion::get::>(), NEO_SWAPS_NEXT_STORAGE_VERSION); + }); + } + + #[test] + fn on_runtime_upgrade_is_noop_if_versions_are_not_correct() { + ExtBuilder::default().build().execute_with(|| { + StorageVersion::new(NEO_SWAPS_NEXT_STORAGE_VERSION).put::>(); + let (_, new_pools) = construct_old_new_tuple(); + populate_test_data::, PoolOf>( + NEO_SWAPS, POOLS, new_pools, + ); + let tmp = storage_root(StateVersion::V1); + MigrateToLiquidityTree::::on_runtime_upgrade(); + assert_eq!(tmp, storage_root(StateVersion::V1)); + }); + } + + #[test] + fn on_runtime_upgrade_correctly_updates_markets() { + ExtBuilder::default().build().execute_with(|| { + set_up_version(); + let (old_pools, new_pools) = construct_old_new_tuple(); + populate_test_data::, OldPoolOf>( + NEO_SWAPS, POOLS, old_pools, + ); + MigrateToLiquidityTree::::on_runtime_upgrade(); + let actual = Pools::get(0u128).unwrap(); + assert_eq!(actual, new_pools[0]); + }); + } + + fn set_up_version() { + StorageVersion::new(NEO_SWAPS_REQUIRED_STORAGE_VERSION).put::>(); + } + + fn construct_old_new_tuple() -> (Vec>, Vec>) { + let account_id = 1; + let mut reserves = BTreeMap::new(); + reserves.insert(Asset::CategoricalOutcome(2, 3), 4); + let collateral = Asset::Ztg; + let liquidity_parameter = 5; + let swap_fee = 6; + let total_shares = 7; + let fees = 8; + + let solo = SoloLp { owner: account_id, total_shares, fees }; + let mut liquidity_tree = LiquidityTree::new(account_id, total_shares).unwrap(); + liquidity_tree.nodes.get_mut(0).unwrap().fees = fees; + + let old_pool = OldPoolOf { + account_id, + reserves: reserves.clone(), + collateral, + liquidity_parameter, + liquidity_shares_manager: solo, + swap_fee, + }; + let new_pool = Pool { + account_id, + reserves, + collateral, + liquidity_parameter, + liquidity_shares_manager: liquidity_tree, + swap_fee, + }; + (vec![old_pool], vec![new_pool]) + } + + #[allow(unused)] + fn populate_test_data(pallet: &[u8], prefix: &[u8], data: Vec) + where + H: StorageHasher, + K: TryFrom + Encode, + V: Encode + Clone, + >::Error: Debug, + { + for (key, value) in data.iter().enumerate() { + let storage_hash = utility::key_to_hash::(K::try_from(key).unwrap()); + put_storage_value::(pallet, prefix, &storage_hash, (*value).clone()); + } + } +} + +mod utility { + use alloc::vec::Vec; + use frame_support::StorageHasher; + use parity_scale_codec::Encode; + + #[allow(unused)] + pub fn key_to_hash(key: K) -> Vec + where + H: StorageHasher, + K: Encode, + { + key.using_encoded(H::hash).as_ref().to_vec() + } +} diff --git a/zrml/neo-swaps/src/mock.rs b/zrml/neo-swaps/src/mock.rs index c505c0fe3..b361d473a 100644 --- a/zrml/neo-swaps/src/mock.rs +++ b/zrml/neo-swaps/src/mock.rs @@ -52,8 +52,8 @@ use zeitgeist_primitives::{ 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, + MaxEditReasonLen, MaxGlobalDisputeVotes, MaxGracePeriod, MaxInRatio, MaxLiquidityTreeDepth, + MaxLocks, MaxMarketLifetime, MaxOracleDuration, MaxOutRatio, MaxOwners, MaxRejectReasonLen, MaxReserves, MaxSelectedDraws, MaxSubsidyPeriod, MaxSwapFee, MaxTotalWeight, MaxWeight, MaxYearlyInflation, MinAssets, MinCategories, MinDisputeDuration, MinJurorStake, MinOracleDuration, MinOutcomeVoteAmount, MinSubsidy, MinSubsidyPeriod, MinWeight, @@ -192,6 +192,7 @@ impl crate::Config for Runtime { type ExternalFees = ExternalFees; type MarketCommons = MarketCommons; type RuntimeEvent = RuntimeEvent; + type MaxLiquidityTreeDepth = MaxLiquidityTreeDepth; type MaxSwapFee = NeoMaxSwapFee; type PalletId = NeoSwapsPalletId; type WeightInfo = zrml_neo_swaps::weights::WeightInfo; @@ -475,7 +476,7 @@ pub struct ExtBuilder { #[allow(unused)] impl Default for ExtBuilder { fn default() -> Self { - Self { balances: vec![(ALICE, _101), (CHARLIE, _1), (DAVE, _1), (EVE, _1)] } + Self { balances: vec![(ALICE, 100_000_000_001 * _1), (CHARLIE, _1), (DAVE, _1), (EVE, _1)] } } } @@ -489,32 +490,35 @@ impl ExtBuilder { .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)] } + { + use frame_support::traits::GenesisBuild; + orml_tokens::GenesisConfig:: { + balances: vec![(ALICE, FOREIGN_ASSET, 100_000_000_001 * _1)], + } + .assimilate_storage(&mut t) + .unwrap(); + let custom_metadata = zeitgeist_primitives::types::CustomMetadata { + allow_as_base_asset: true, + ..Default::default() + }; + orml_asset_registry_mock::GenesisConfig { + metadata: vec![( + FOREIGN_ASSET, + AssetMetadata { + decimals: 18, + name: "MKL".as_bytes().to_vec(), + symbol: "MKL".as_bytes().to_vec(), + existential_deposit: 0, + location: None, + additional: custom_metadata, + }, + )], + } .assimilate_storage(&mut t) .unwrap(); - #[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() + let mut test_ext: sp_io::TestExternalities = t.into(); + test_ext.execute_with(|| System::set_block_number(1)); + test_ext } } diff --git a/zrml/neo-swaps/src/tests/buy.rs b/zrml/neo-swaps/src/tests/buy.rs index 9b72ddba3..a613b5fae 100644 --- a/zrml/neo-swaps/src/tests/buy.rs +++ b/zrml/neo-swaps/src/tests/buy.rs @@ -23,7 +23,6 @@ use test_case::test_case; #[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; @@ -44,7 +43,7 @@ fn buy_works() { 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 liquidity_parameter_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 @@ -59,41 +58,29 @@ fn buy_works() { 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], + let expected_amount_in_minus_fees = _10 + 1; // Note: This is 1 Pennock off of the correct result. + let expected_reserves = vec![ + pool_outcomes_before[0] - expected_swap_amount_out, pool_outcomes_before[0] + expected_amount_in_minus_fees, + ]; + assert_pool_state!( + market_id, + expected_reserves, + vec![_3_4, _1_4], + liquidity_parameter_before, + create_b_tree_map!({ ALICE => liquidity }), + expected_swap_fee_amount, ); - 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 expected_amount_out = expected_swap_amount_out + expected_amount_in_minus_fees; + assert_balance!(BOB, BASE_ASSET, 0); + assert_balance!(BOB, asset_out, expected_amount_out); + assert_balance!( + pool.account_id, + BASE_ASSET, + expected_swap_fee_amount + AssetManager::minimum_balance(pool.collateral) ); - let price_sum = pool - .assets() - .iter() - .map(|&a| pool.calculate_spot_price(a).unwrap()) - .sum::(); - assert_eq!(price_sum, _1); + assert_balance!(FEE_ACCOUNT, BASE_ASSET, expected_external_fee_amount); System::assert_last_event( Event::BuyExecuted { who: BOB, diff --git a/zrml/neo-swaps/src/tests/buy_and_sell.rs b/zrml/neo-swaps/src/tests/buy_and_sell.rs index fbb545171..cce3d02a7 100644 --- a/zrml/neo-swaps/src/tests/buy_and_sell.rs +++ b/zrml/neo-swaps/src/tests/buy_and_sell.rs @@ -18,23 +18,6 @@ use super::*; use zeitgeist_primitives::constants::BASE; -macro_rules! assert_pool_status { - ($market_id:expr, $reserves:expr, $spot_prices:expr, $fees:expr $(,)?) => { - let pool = Pools::::get($market_id).unwrap(); - assert_eq!(pool.reserves.values().cloned().collect::>(), $reserves); - assert_eq!( - pool.assets() - .iter() - .map(|&a| pool.calculate_spot_price(a).unwrap()) - .collect::>(), - $spot_prices, - ); - let invariant = $spot_prices.iter().sum::(); - assert_approx!(invariant, _1, 1); - assert_eq!(pool.liquidity_shares_manager.fees, $fees); - }; -} - #[test] fn buy_and_sell() { ExtBuilder::default().build().execute_with(|| { @@ -59,10 +42,12 @@ fn buy_and_sell() { _10, 0, )); - assert_pool_status!( + assert_pool_state!( market_id, vec![598_000_000_000, 1_098_000_000_000, 767_092_556_931], [4_364_837_956, 2_182_418_978, 3_452_743_066], + 721_347_520_444, + create_b_tree_map!({ ALICE => _100 }), 1_000_000_000, ); @@ -74,10 +59,12 @@ fn buy_and_sell() { 1_234_567_898_765, 0, )); - assert_pool_status!( + assert_pool_state!( market_id, vec![1_807_876_540_789, 113_931_597_104, 1_976_969_097_720], [815_736_444, 8_538_986_828, 645_276_728], + 721_347_520_444, + create_b_tree_map!({ ALICE => _100 }), 13_345_678_988, ); @@ -89,10 +76,12 @@ fn buy_and_sell() { 667 * BASE, 0, )); - assert_pool_status!( + assert_pool_state!( market_id, vec![76_875_275, 6_650_531_597_104, 8_513_569_097_720], [9_998_934_339, 990_789, 74_872], + 721_347_520_444, + create_b_tree_map!({ ALICE => _100 }), 80_045_678_988, ); @@ -117,10 +106,12 @@ fn buy_and_sell() { _1, 0, )); - assert_pool_status!( + assert_pool_state!( market_id, vec![77_948_356, 6_640_532_670_185, 8_503_570_170_801], [9_998_919_465, 1_004_618, 75_917], + 721_347_520_444, + create_b_tree_map!({ ALICE => _100 }), 80_145_668_257, ); @@ -172,10 +163,12 @@ fn buy_and_sell() { _100, 0, )); - assert_pool_status!( + assert_pool_state!( market_id, vec![980_077_948_356, 7_620_532_670_185, 214_308_675_476], [2_570_006_838, 258_215, 7_429_734_946], + 721_347_520_444, + create_b_tree_map!({ ALICE => _100 }), 90_145_668_257, ); }); diff --git a/zrml/neo-swaps/src/tests/deploy_pool.rs b/zrml/neo-swaps/src/tests/deploy_pool.rs index d5764f846..17283f349 100644 --- a/zrml/neo-swaps/src/tests/deploy_pool.rs +++ b/zrml/neo-swaps/src/tests/deploy_pool.rs @@ -16,13 +16,13 @@ // along with Zeitgeist. If not, see . use super::*; +use crate::liquidity_tree::types::Node; use alloc::collections::BTreeMap; 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]; @@ -43,20 +43,29 @@ fn deploy_pool_works_with_binary_markets() { 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_liquidity_tree_state!( + pool.liquidity_shares_manager, + [Node:: { + account: Some(ALICE), + stake: amount, + fees: 0u128, + descendant_stake: 0u128, + lazy_fees: 0u128, + }], + create_b_tree_map!({ ALICE => 0 }), + Vec::::new(), + ); 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_balance!(pool.account_id, pool.collateral, buffer); + assert_balance!(pool.account_id, assets[0], amount); + assert_balance!(pool.account_id, assets[1], 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); + assert_balance!(ALICE, BASE_ASSET, alice_before - amount - buffer); + assert_balance!(ALICE, assets[0], 0); + assert_balance!(ALICE, assets[1], 0); let mut reserves = BTreeMap::new(); reserves.insert(assets[0], amount); reserves.insert(assets[1], amount); @@ -79,7 +88,6 @@ fn deploy_pool_works_with_binary_markets() { #[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]; @@ -112,22 +120,28 @@ fn deploy_pool_works_with_scalar_marktes() { 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_liquidity_tree_state!( + pool.liquidity_shares_manager, + [Node:: { + account: Some(ALICE), + stake: amount, + fees: 0u128, + descendant_stake: 0u128, + lazy_fees: 0u128, + }], + create_b_tree_map!({ ALICE => 0 }), + Vec::::new(), ); - assert_eq!(AssetManager::free_balance(assets[1], &pool.account_id), expected_amounts[1]); + assert_eq!(pool.swap_fee, swap_fee); + assert_balance!(pool.account_id, assets[0], expected_amounts[0] + rogue_funds); + assert_balance!(pool.account_id, assets[1], 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]); + assert_balance!(ALICE, BASE_ASSET, alice_before - amount - buffer); + assert_balance!(ALICE, assets[0], 0); + assert_balance!(ALICE, assets[1], amount - expected_amounts[1]); let price_sum = pool.assets().iter().map(|&a| pool.calculate_spot_price(a).unwrap()).sum::(); assert_eq!(price_sum, _1); @@ -226,24 +240,6 @@ fn deploy_pool_fails_on_duplicate_pool() { }); } -#[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(|| { diff --git a/zrml/neo-swaps/src/tests/exit.rs b/zrml/neo-swaps/src/tests/exit.rs index f528a7b54..1d8162a49 100644 --- a/zrml/neo-swaps/src/tests/exit.rs +++ b/zrml/neo-swaps/src/tests/exit.rs @@ -16,12 +16,18 @@ // along with Zeitgeist. If not, see . use super::*; +use crate::liquidity_tree::types::LiquidityTreeError; +use test_case::test_case; -#[test] -fn exit_works() { +#[test_case(MarketStatus::Active, vec![39_960_000_000, 4_066_153_704], 33_508_962_010)] +#[test_case(MarketStatus::Resolved, vec![40_000_000_000, 4_070_223_928], 33_486_637_585)] +fn exit_works( + market_status: MarketStatus, + amounts_out: Vec>, + new_liquidity_parameter: BalanceOf, +) { ExtBuilder::default().build().execute_with(|| { - frame_system::Pallet::::set_block_number(1); - let liquidity = _10; + let liquidity = _5; let spot_prices = vec![_1_6, _5_6 + 1]; let swap_fee = CENT; let market_id = create_market_and_deploy_pool( @@ -32,98 +38,153 @@ fn exit_works() { spot_prices.clone(), swap_fee, ); + // Add a second LP to create a more generic situation, bringing the total of shares to _10. + deposit_complete_set(market_id, BOB, liquidity); + assert_ok!(NeoSwaps::join( + RuntimeOrigin::signed(BOB), + market_id, + liquidity, + vec![u128::MAX, u128::MAX], + )); + MarketCommons::mutate_market(&market_id, |market| { + market.status = market_status; + Ok(()) + }) + .unwrap(); + let pool = Pools::::get(market_id).unwrap(); + let outcomes = pool.assets(); + let alice_balances = [0, 44_912_220_089]; + assert_balances!(ALICE, outcomes, alice_balances); + let pool_balances = vec![100_000_000_000, 10_175_559_822]; + assert_pool_state!( + market_id, + pool_balances, + spot_prices, + 55_811_062_642, + create_b_tree_map!({ ALICE => _5, BOB => _5 }), + 0, + ); 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 = pool_shares_amount.bdiv(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![ - ratio.bmul(pool_outcomes_before[0]).unwrap(), - ratio.bmul(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, - (_1 - ratio).bmul(pool_before.liquidity_parameter).unwrap() - ); - assert_eq!( - pool_after.liquidity_shares_manager.shares_of(&ALICE).unwrap(), - liquidity - pool_shares_amount + let new_pool_balances = + pool_balances.iter().zip(amounts_out.iter()).map(|(b, a)| b - a).collect::>(); + let new_alice_balances = + alice_balances.iter().zip(amounts_out.iter()).map(|(b, a)| b + a).collect::>(); + assert_balances!(ALICE, outcomes, new_alice_balances); + assert_pool_state!( + market_id, + new_pool_balances, + spot_prices, + new_liquidity_parameter, + create_b_tree_map!({ ALICE => _1, BOB => _5 }), + 0, ); 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, + amounts_out, + new_liquidity_parameter, } .into(), ); }); } -#[test] -fn exit_destroys_pool() { +#[test_case(MarketStatus::Active, vec![39_960_000_000, 4_066_153_705])] +#[test_case(MarketStatus::Resolved, vec![40_000_000_000, 4_070_223_929])] +fn last_exit_destroys_pool(market_status: MarketStatus, amounts_out: Vec>) { ExtBuilder::default().build().execute_with(|| { - frame_system::Pallet::::set_block_number(1); - let liquidity = _10; + let liquidity = _4; + let spot_prices = vec![_1_6, _5_6 + 1]; let market_id = create_market_and_deploy_pool( ALICE, BASE_ASSET, MarketType::Scalar(0..=1), liquidity, - vec![_1_6, _5_6 + 1], + spot_prices.clone(), CENT, ); + MarketCommons::mutate_market(&market_id, |market| { + market.status = market_status; + Ok(()) + }) + .unwrap(); 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), - ]; + let pool_account = pool.account_id; + let outcomes = pool.assets(); + let alice_balances = [0, 35_929_776_071]; + assert_balances!(ALICE, outcomes, alice_balances); + let pool_balances = vec![40_000_000_000, 4_070_223_929]; + assert_pool_state!( + market_id, + pool_balances, + spot_prices, + 22_324_425_057, + create_b_tree_map!({ ALICE => _4 }), + 0, + ); assert_ok!(NeoSwaps::exit(RuntimeOrigin::signed(ALICE), market_id, liquidity, vec![0, 0])); + let new_alice_balances = + alice_balances.iter().zip(amounts_out.iter()).map(|(b, a)| b + a).collect::>(); + assert_balances!(ALICE, outcomes, new_alice_balances); + // Pool doesn't exist anymore and exit fees are cleared. 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] - ); + assert_balances!(pool_account, outcomes, [0, 0]); System::assert_last_event( Event::PoolDestroyed { who: ALICE, market_id, amounts_out }.into(), ); }); } +#[test] +fn removing_second_to_last_lp_does_not_destroy_pool_and_removes_node_from_liquidity_tree() { + ExtBuilder::default().build().execute_with(|| { + let liquidity = _5; + 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, + ); + // Add a second LP, bringing the total of shares to _10. + deposit_complete_set(market_id, BOB, liquidity); + assert_ok!(NeoSwaps::join( + RuntimeOrigin::signed(BOB), + market_id, + liquidity, + vec![u128::MAX, u128::MAX], + )); + assert_pool_state!( + market_id, + [100_000_000_000, 10_175_559_822], + spot_prices, + 55_811_062_642, + create_b_tree_map!({ ALICE => _5, BOB => _5 }), + 0, + ); + assert_ok!(NeoSwaps::exit(RuntimeOrigin::signed(BOB), market_id, liquidity, vec![0, 0])); + assert_pool_state!( + market_id, + [50_050_000_000, 5_092_867_691], + spot_prices, + 27_933_436_852, + create_b_tree_map!({ ALICE => _5 }), + 0, + ); + }); +} + #[test] fn exit_fails_on_incorrect_vec_len() { ExtBuilder::default().build().execute_with(|| { @@ -192,7 +253,7 @@ fn exit_fails_on_insufficient_funds() { liquidity + 1, // One more than Alice has. vec![0, 0] ), - Error::::InsufficientPoolShares, + LiquidityTreeError::InsufficientStake.into_dispatch_error::(), ); }); } @@ -227,7 +288,7 @@ fn exit_fails_on_amount_out_below_min() { } #[test] -fn exit_fails_if_not_allowed() { +fn exit_fails_on_outstanding_fees() { ExtBuilder::default().build().execute_with(|| { let market_id = create_market_and_deploy_pool( ALICE, @@ -237,75 +298,61 @@ fn exit_fails_if_not_allowed() { 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_ok!(Pools::::try_mutate(market_id, |pool| pool + .as_mut() + .unwrap() + .liquidity_shares_manager + .deposit_fees(_10))); assert_noop!( - NeoSwaps::exit( - RuntimeOrigin::signed(BOB), - market_id, - pool_shares_amount, - vec![pool_shares_amount, pool_shares_amount] - ), - Error::::NotAllowed + NeoSwaps::exit(RuntimeOrigin::signed(ALICE), market_id, _1, vec![0, 0]), + LiquidityTreeError::UnwithdrawnFees.into_dispatch_error::(), ); }); } #[test] -fn exit_fails_on_outstanding_fees() { +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), - _20, + _10, 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))); + // Will result in liquidity of about 0.7213475204444817. assert_noop!( - NeoSwaps::exit( - RuntimeOrigin::signed(BOB), - market_id, - pool_shares_amount, - vec![pool_shares_amount, pool_shares_amount] - ), - Error::::OutstandingFees + NeoSwaps::exit(RuntimeOrigin::signed(ALICE), market_id, _10 - _1_2, vec![0, 0]), + Error::::LiquidityTooLow ); }); } #[test] -fn exit_pool_fails_on_liquidity_too_low() { +fn exit_pool_fails_on_relative_liquidity_threshold_violated() { ExtBuilder::default().build().execute_with(|| { let market_id = create_market_and_deploy_pool( ALICE, BASE_ASSET, MarketType::Scalar(0..=1), - _10, + _100, vec![_1_2, _1_2], CENT, ); - // Will result in liquidity of about 0.7213475204444817. + // Bob contributes only 1.390...% of liquidity. Any removal (no matter how small the amount) + // should fail. + let amount = 13_910_041_100; + deposit_complete_set(market_id, BOB, amount); + assert_ok!(NeoSwaps::join( + RuntimeOrigin::signed(BOB), + market_id, + amount, + vec![u128::MAX, u128::MAX], + )); assert_noop!( - NeoSwaps::exit(RuntimeOrigin::signed(ALICE), market_id, _10 - _1_2, vec![0, 0]), - Error::::LiquidityTooLow + NeoSwaps::exit(RuntimeOrigin::signed(BOB), market_id, CENT, vec![0, 0]), + Error::::MinRelativeLiquidityThresholdViolated ); }); } diff --git a/zrml/neo-swaps/src/tests/join.rs b/zrml/neo-swaps/src/tests/join.rs index d3d0daeb0..cc0912264 100644 --- a/zrml/neo-swaps/src/tests/join.rs +++ b/zrml/neo-swaps/src/tests/join.rs @@ -16,12 +16,22 @@ // along with Zeitgeist. If not, see . use super::*; +use crate::{ + helpers::create_spot_prices, + liquidity_tree::{ + traits::liquidity_tree_helper::LiquidityTreeHelper, types::LiquidityTreeError, + }, +}; +use alloc::collections::BTreeMap; use test_case::test_case; -#[test] -fn join_works() { +#[test_case(ALICE, create_b_tree_map!({ ALICE => _14 }))] +#[test_case(BOB, create_b_tree_map!({ ALICE => _10, BOB => _4 }))] +fn join_works( + who: AccountIdOf, + expected_pool_shares: BTreeMap, BalanceOf>, +) { 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; @@ -34,55 +44,79 @@ fn join_works() { 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(); + deposit_complete_set(market_id, who, pool_shares_amount); assert_ok!(NeoSwaps::join( - RuntimeOrigin::signed(ALICE), + RuntimeOrigin::signed(who), market_id, pool_shares_amount, - vec![u128::MAX, u128::MAX], + vec![u128::MAX; 2], )); - let pool_after = Pools::::get(market_id).unwrap(); - let ratio = (liquidity + pool_shares_amount).bdiv(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], ratio.bmul(pool_outcomes_before[0]).unwrap()); - assert_eq!(pool_outcomes_after[1], 14_245_783_753); - 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, - ratio.bmul(pool_before.liquidity_parameter).unwrap() - ); - assert_eq!( - pool_after.liquidity_shares_manager.shares_of(&ALICE).unwrap(), - liquidity + pool_shares_amount + let expected_pool_balances = vec![140_000_000_000, 14_245_783_753]; + let new_liquidity_parameter = 78_135_487_700; + assert_pool_state!( + market_id, + expected_pool_balances, + spot_prices, + new_liquidity_parameter, + expected_pool_shares, + 0, ); + let amounts_in = vec![40_000_000_000, 4_070_223_930]; System::assert_last_event( Event::JoinExecuted { - who: ALICE, + who, market_id, pool_shares_amount, - amounts_in: vec![pool_shares_amount, long_diff], - new_liquidity_parameter: pool_after.liquidity_parameter, + amounts_in, + new_liquidity_parameter, } .into(), ); }); } +#[test] +fn join_fails_on_max_liquidity_providers() { + ExtBuilder::default().build().execute_with(|| { + let category_count = 2; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(category_count), + _100, + create_spot_prices::(category_count), + CENT, + ); + // Populate the tree with the maximum allowed number of LPs. + let offset = 100; + let max_node_count = LiquidityTreeOf::::max_node_count() as u128; + let amount = _10; + for index in 1..max_node_count { + let account = offset + index; + // Adding a little more because ceil rounding may cause slightly higher prices for + // joining. + deposit_complete_set(market_id, account, amount + CENT); + assert_ok!(NeoSwaps::join( + RuntimeOrigin::signed(account), + market_id, + amount, + vec![u128::MAX; category_count as usize], + )); + } + let account = offset + max_node_count; + deposit_complete_set(market_id, account, amount + CENT); + assert_noop!( + NeoSwaps::join( + RuntimeOrigin::signed(account), + market_id, + amount, + vec![u128::MAX; category_count as usize] + ), + LiquidityTreeError::TreeIsFull.into_dispatch_error::(), + ); + }); +} + #[test] fn join_fails_on_incorrect_vec_len() { ExtBuilder::default().build().execute_with(|| { @@ -219,31 +253,48 @@ fn join_fails_on_amount_in_above_max() { } #[test] -fn join_fails_if_not_allowed() { +fn join_pool_fails_on_relative_liquidity_threshold_violated() { ExtBuilder::default().build().execute_with(|| { let market_id = create_market_and_deploy_pool( ALICE, BASE_ASSET, MarketType::Scalar(0..=1), - _20, + _100, 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, - )); + // Bob contributes slightly less than 1.39098411% additional liquidity; this should fail. + let amount = 139098411 - 100; + deposit_complete_set(market_id, BOB, amount + CENT); assert_noop!( NeoSwaps::join( RuntimeOrigin::signed(BOB), market_id, - pool_shares_amount, - vec![pool_shares_amount, pool_shares_amount] + amount, + vec![u128::MAX, u128::MAX], ), - Error::::NotAllowed + Error::::MinRelativeLiquidityThresholdViolated + ); + }); +} + +#[test] +fn join_pool_fails_on_small_amounts() { + // This tests verifies that joining with miniscule amounts of pool shares can't be exploited to + // funnel money from the pool. + ExtBuilder::default().build().execute_with(|| { + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Scalar(0..=1), + 100_000_000_000 * _1, + vec![_1_2, _1_2], + CENT, + ); + deposit_complete_set(market_id, BOB, CENT); + assert_noop!( + NeoSwaps::join(RuntimeOrigin::signed(BOB), market_id, 1, vec![u128::MAX, u128::MAX],), + Error::::MinRelativeLiquidityThresholdViolated ); }); } diff --git a/zrml/neo-swaps/src/tests/liquidity_tree_interactions.rs b/zrml/neo-swaps/src/tests/liquidity_tree_interactions.rs new file mode 100644 index 000000000..b2a0c6920 --- /dev/null +++ b/zrml/neo-swaps/src/tests/liquidity_tree_interactions.rs @@ -0,0 +1,58 @@ +// 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_interacts_correctly_with_join() { + ExtBuilder::default().build().execute_with(|| { + let category_count = 2; + let spot_prices = vec![_3_4, _1_4]; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(category_count), + _10, + spot_prices.clone(), + CENT, + ); + + // Mock up some fees. + let mut pool = Pools::::get(market_id).unwrap(); + let fee_amount = _1; + assert_ok!(AssetManager::deposit(pool.collateral, &pool.account_id, fee_amount)); + assert_ok!(pool.liquidity_shares_manager.deposit_fees(fee_amount)); + Pools::::insert(market_id, pool.clone()); + + // Bob joins the pool after fees are distributed. + let join_amount = _10; + deposit_complete_set(market_id, BOB, join_amount + CENT); + assert_ok!(NeoSwaps::join( + RuntimeOrigin::signed(BOB), + market_id, + join_amount, + vec![u128::MAX; category_count as usize], + )); + + // Alice withdraws and should receive all fees. + let old_balance = ::MultiCurrency::free_balance(BASE_ASSET, &ALICE); + assert_ok!(NeoSwaps::withdraw_fees(RuntimeOrigin::signed(ALICE), market_id)); + assert_balance!(ALICE, BASE_ASSET, old_balance + fee_amount); + assert_ok!(NeoSwaps::withdraw_fees(RuntimeOrigin::signed(BOB), market_id)); + assert_balance!(BOB, BASE_ASSET, 0); + }); +} diff --git a/zrml/neo-swaps/src/tests/mod.rs b/zrml/neo-swaps/src/tests/mod.rs index 32d320610..efbf95964 100644 --- a/zrml/neo-swaps/src/tests/mod.rs +++ b/zrml/neo-swaps/src/tests/mod.rs @@ -22,6 +22,7 @@ mod buy_and_sell; mod deploy_pool; mod exit; mod join; +mod liquidity_tree_interactions; mod sell; mod withdraw_fees; @@ -74,7 +75,7 @@ fn create_market( } fn create_market_and_deploy_pool( - creator: AccountIdTest, + creator: AccountIdOf, base_asset: Asset, market_type: MarketType, amount: BalanceOf, @@ -87,7 +88,6 @@ fn create_market_and_deploy_pool( market_id, amount, )); - println!("{:?}", AssetManager::free_balance(base_asset, &ALICE)); assert_ok!(NeoSwaps::deploy_pool( RuntimeOrigin::signed(ALICE), market_id, @@ -98,24 +98,16 @@ fn create_market_and_deploy_pool( 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 - ); - } - } - } - }; +fn deposit_complete_set( + market_id: MarketId, + account: AccountIdOf, + amount: BalanceOf, +) { + let market = MarketCommons::market(&market_id).unwrap(); + assert_ok!(AssetManager::deposit(market.base_asset, &account, amount)); + assert_ok!(::CompleteSetOperations::buy_complete_set( + RuntimeOrigin::signed(account), + market_id, + amount, + )); } diff --git a/zrml/neo-swaps/src/tests/sell.rs b/zrml/neo-swaps/src/tests/sell.rs index 43ea93549..6c0bd9fc7 100644 --- a/zrml/neo-swaps/src/tests/sell.rs +++ b/zrml/neo-swaps/src/tests/sell.rs @@ -21,7 +21,6 @@ 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; @@ -35,15 +34,8 @@ fn sell_works() { ); 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 liquidity_parameter_before = pool.liquidity_parameter; + deposit_complete_set(market_id, BOB, amount_in); let asset_in = pool.assets()[1]; assert_ok!(NeoSwaps::sell( RuntimeOrigin::signed(BOB), @@ -54,35 +46,27 @@ fn sell_works() { 0, )); let total_fee_percentage = swap_fee + EXTERNAL_FEES; - let expected_amount_out = 59632253897u128; + let expected_amount_out = 59632253897; let expected_fees = total_fee_percentage.bmul(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_balance!(BOB, BASE_ASSET, expected_amount_out_minus_fees); + assert_balance!(BOB, asset_in, 0); + assert_pool_state!( + market_id, + vec![40367746103, 61119621067], + [5_714_285_714, 4_285_714_286], + liquidity_parameter_before, + create_b_tree_map!({ ALICE => liquidity }), + expected_swap_fee_amount, ); - assert_eq!( - AssetManager::free_balance(BASE_ASSET, &FEE_ACCOUNT), - expected_external_fee_amount + assert_balance!( + pool.account_id, + BASE_ASSET, + expected_swap_fee_amount + AssetManager::minimum_balance(pool.collateral) ); + assert_balance!(FEE_ACCOUNT, BASE_ASSET, expected_external_fee_amount); assert_eq!( AssetManager::total_issuance(pool.assets()[0]), liquidity + amount_in - expected_amount_out @@ -91,9 +75,6 @@ fn sell_works() { 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, diff --git a/zrml/neo-swaps/src/tests/withdraw_fees.rs b/zrml/neo-swaps/src/tests/withdraw_fees.rs index 3fc71d6d2..64aea9079 100644 --- a/zrml/neo-swaps/src/tests/withdraw_fees.rs +++ b/zrml/neo-swaps/src/tests/withdraw_fees.rs @@ -19,38 +19,67 @@ use super::*; #[test] fn withdraw_fees_works() { + // Verify that fees are correctly distributed among LPs. 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 category_count = 2; + let spot_prices = vec![_3_4, _1_4]; let market_id = create_market_and_deploy_pool( ALICE, BASE_ASSET, - MarketType::Scalar(0..=1), - liquidity, + MarketType::Categorical(category_count), + _10, spot_prices.clone(), - swap_fee, + CENT, ); - // Mock up some fees for Alice to withdraw. + let join = |who: AccountIdOf, amount: BalanceOf| { + // Adding a little more to ensure that rounding doesn't cause issues. + deposit_complete_set(market_id, who, amount + CENT); + assert_ok!(NeoSwaps::join( + RuntimeOrigin::signed(who), + market_id, + amount, + vec![u128::MAX; category_count as usize], + )); + }; + join(BOB, _10); + join(CHARLIE, _20); + + // Mock up some fees. 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; + let fee_amount = _1; + assert_ok!(AssetManager::deposit(pool.collateral, &pool.account_id, fee_amount)); + assert_ok!(pool.liquidity_shares_manager.deposit_fees(fee_amount)); 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(), - ); + let liquidity_parameter = 288_539_008_176; + let pool_balances = [83_007_499_856, 400_000_000_000]; + + let test_withdraw = |who: AccountIdOf, + fees_withdrawn: BalanceOf, + fees_remaining: BalanceOf| { + // Make sure everybody's got at least the minimum deposit. + assert_ok!(::MultiCurrency::deposit( + BASE_ASSET, + &who, + ::MultiCurrency::minimum_balance(BASE_ASSET) + )); + let old_balance = ::MultiCurrency::free_balance(BASE_ASSET, &who); + assert_ok!(NeoSwaps::withdraw_fees(RuntimeOrigin::signed(who), market_id)); + assert_balance!(who, BASE_ASSET, old_balance + fees_withdrawn); + assert_pool_state!( + market_id, + pool_balances, + spot_prices, + liquidity_parameter, + create_b_tree_map!({ ALICE => _10, BOB => _10, CHARLIE => _20 }), + fees_remaining, + ); + System::assert_last_event( + Event::FeesWithdrawn { who, market_id, amount: fees_withdrawn }.into(), + ); + }; + test_withdraw(ALICE, _1_4, _3_4); + test_withdraw(BOB, _1_4, _1_2); + test_withdraw(CHARLIE, _1_2, 0); }); } @@ -65,3 +94,38 @@ fn withdraw_fees_fails_on_pool_not_found() { ); }); } + +#[test] +fn withdraw_fees_is_noop_if_there_are_no_fees() { + ExtBuilder::default().build().execute_with(|| { + let spot_prices = vec![_3_4, _1_4]; + let amount = _40; + let market_id = create_market_and_deploy_pool( + ALICE, + BASE_ASSET, + MarketType::Categorical(2), + amount, + spot_prices.clone(), + CENT, + ); + let pool_balances = [83_007_499_856, 400_000_000_000]; + let liquidity_parameter = 288_539_008_178; + assert_pool_state!( + market_id, + pool_balances, + spot_prices, + liquidity_parameter, + create_b_tree_map!({ ALICE => amount }), + 0, + ); + assert_ok!(NeoSwaps::withdraw_fees(RuntimeOrigin::signed(ALICE), market_id)); + assert_pool_state!( + market_id, + pool_balances, + spot_prices, + liquidity_parameter, + create_b_tree_map!({ ALICE => amount }), + 0, + ); + }); +} diff --git a/zrml/neo-swaps/src/traits/liquidity_shares_manager.rs b/zrml/neo-swaps/src/traits/liquidity_shares_manager.rs index 97ad3f333..b99302fb1 100644 --- a/zrml/neo-swaps/src/traits/liquidity_shares_manager.rs +++ b/zrml/neo-swaps/src/traits/liquidity_shares_manager.rs @@ -21,8 +21,14 @@ 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 { + type JoinBenchmarkInfo; + /// Add `amount` units of pool shares to the account of `who`. - fn join(&mut self, who: &T::AccountId, amount: BalanceOf) -> DispatchResult; + fn join( + &mut self, + who: &T::AccountId, + amount: BalanceOf, + ) -> Result; /// Remove `amount` units of pool shares from the account of `who`. fn exit(&mut self, who: &T::AccountId, amount: BalanceOf) -> DispatchResult; diff --git a/zrml/neo-swaps/src/types/pool.rs b/zrml/neo-swaps/src/types/pool.rs index d76264cae..598edc1b4 100644 --- a/zrml/neo-swaps/src/types/pool.rs +++ b/zrml/neo-swaps/src/types/pool.rs @@ -30,7 +30,7 @@ use sp_runtime::{ DispatchError, DispatchResult, RuntimeDebug, SaturatedConversion, Saturating, }; -#[derive(TypeInfo, Clone, Encode, Eq, Decode, PartialEq, RuntimeDebug)] +#[derive(Clone, Decode, Encode, Eq, PartialEq, RuntimeDebug, TypeInfo)] #[scale_info(skip_type_params(T))] pub struct Pool where diff --git a/zrml/neo-swaps/src/types/solo_lp.rs b/zrml/neo-swaps/src/types/solo_lp.rs index 02cbed618..13f9c4cde 100644 --- a/zrml/neo-swaps/src/types/solo_lp.rs +++ b/zrml/neo-swaps/src/types/solo_lp.rs @@ -24,6 +24,7 @@ use sp_runtime::{ DispatchError, DispatchResult, RuntimeDebug, }; +// Deprecated as of v0.5.0. TODO Remove in 0.5.1! #[derive(TypeInfo, MaxEncodedLen, Clone, Encode, Eq, Decode, PartialEq, RuntimeDebug)] #[scale_info(skip_type_params(T))] pub struct SoloLp { @@ -32,6 +33,7 @@ pub struct SoloLp { pub fees: BalanceOf, } +#[allow(dead_code)] impl SoloLp { pub(crate) fn new(owner: T::AccountId, total_shares: BalanceOf) -> SoloLp { SoloLp { owner, total_shares, fees: Zero::zero() } @@ -43,7 +45,9 @@ where T::AccountId: PartialEq, BalanceOf: AtLeast32BitUnsigned + Copy + Zero, { - fn join(&mut self, who: &T::AccountId, shares: BalanceOf) -> DispatchResult { + type JoinBenchmarkInfo = (); + + fn join(&mut self, who: &T::AccountId, shares: BalanceOf) -> Result<(), DispatchError> { ensure!(*who == self.owner, Error::::NotAllowed); self.total_shares = self.total_shares.checked_add(&shares).ok_or(Error::::MathError)?; Ok(()) diff --git a/zrml/neo-swaps/src/weights.rs b/zrml/neo-swaps/src/weights.rs index 8ee41b44a..c7114792f 100644 --- a/zrml/neo-swaps/src/weights.rs +++ b/zrml/neo-swaps/src/weights.rs @@ -19,18 +19,18 @@ //! Autogenerated weights for zrml_neo_swaps //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev -//! DATE: `2023-10-26`, STEPS: `10`, REPEAT: `1000`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: `2023-12-20`, STEPS: `10`, REPEAT: `2`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `zeitgeist-benchmark`, CPU: `AMD EPYC 7601 32-Core Processor` +//! HOSTNAME: `mkl-mac`, CPU: `` //! EXECUTION: `Some(Wasm)`, WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` // Executed Command: -// ./target/production/zeitgeist +// ./target/release/zeitgeist // benchmark // pallet // --chain=dev // --steps=10 -// --repeat=1000 +// --repeat=2 // --pallet=zrml_neo_swaps // --extrinsic=* // --execution=wasm @@ -51,7 +51,9 @@ use frame_support::{traits::Get, weights::Weight}; pub trait WeightInfoZeitgeist { fn buy(n: u32) -> Weight; fn sell(n: u32) -> Weight; - fn join(n: u32) -> Weight; + fn join_in_place(n: u32) -> Weight; + fn join_reassigned(n: u32) -> Weight; + fn join_leaf(n: u32) -> Weight; fn exit(n: u32) -> Weight; fn withdraw_fees() -> Weight; fn deploy_pool(n: u32) -> Weight; @@ -63,101 +65,179 @@ impl WeightInfoZeitgeist for WeightInfo { /// Storage: MarketCommons Markets (r:1 w:0) /// Proof: MarketCommons Markets (max_values: None, max_size: Some(678), added: 3153, 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: NeoSwaps Pools (max_values: None, max_size: Some(144745), added: 147220, mode: MaxEncodedLen) + /// Storage: System Account (r:3 w:3) /// Proof: System Account (max_values: None, max_size: Some(132), added: 2607, mode: MaxEncodedLen) - /// Storage: Tokens Accounts (r:3 w:3) + /// Storage: Tokens Accounts (r:129 w:129) /// Proof: Tokens Accounts (max_values: None, max_size: Some(123), added: 2598, mode: MaxEncodedLen) - /// Storage: Tokens TotalIssuance (r:2 w:2) + /// Storage: Tokens TotalIssuance (r:128 w:128) /// Proof: Tokens TotalIssuance (max_values: None, max_size: Some(43), added: 2518, mode: MaxEncodedLen) - fn buy(_n: u32) -> Weight { + /// The range of component `n` is `[2, 128]`. + fn buy(n: u32) -> Weight { // Proof Size summary in bytes: - // Measured: `2905` - // Estimated: `28324` - // Minimum execution time: 389_381 nanoseconds. - Weight::from_parts(456_541_000, 28324) - .saturating_add(T::DbWeight::get().reads(9)) - .saturating_add(T::DbWeight::get().writes(8)) + // Measured: `2790 + n * (195 ±0)` + // Estimated: `160792 + n * (5116 ±0)` + // Minimum execution time: 510_000 nanoseconds. + Weight::from_parts(480_304_329, 160792) + // Standard Error: 593_647 + .saturating_add(Weight::from_parts(23_811_471, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(6)) + .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(5)) + .saturating_add(T::DbWeight::get().writes((2_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 5116).saturating_mul(n.into())) } /// Storage: MarketCommons Markets (r:1 w:0) /// Proof: MarketCommons Markets (max_values: None, max_size: Some(678), added: 3153, 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: NeoSwaps Pools (max_values: None, max_size: Some(144745), added: 147220, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:129 w:129) /// Proof: Tokens Accounts (max_values: None, max_size: Some(123), added: 2598, mode: MaxEncodedLen) - /// Storage: System Account (r:2 w:2) + /// Storage: System Account (r:3 w:3) /// Proof: System Account (max_values: None, max_size: Some(132), added: 2607, mode: MaxEncodedLen) - /// Storage: Tokens TotalIssuance (r:2 w:2) + /// Storage: Tokens TotalIssuance (r:128 w:128) /// Proof: Tokens TotalIssuance (max_values: None, max_size: Some(43), added: 2518, mode: MaxEncodedLen) - fn sell(_n: u32) -> Weight { + /// The range of component `n` is `[2, 128]`. + fn sell(n: u32) -> Weight { // Proof Size summary in bytes: - // Measured: `3071` - // Estimated: `28324` - // Minimum execution time: 404_471 nanoseconds. - Weight::from_parts(473_241_000, 28324) - .saturating_add(T::DbWeight::get().reads(9)) - .saturating_add(T::DbWeight::get().writes(8)) + // Measured: `2952 + n * (195 ±0)` + // Estimated: `160792 + n * (5116 ±0)` + // Minimum execution time: 380_000 nanoseconds. + Weight::from_parts(404_645_021, 160792) + // Standard Error: 514_733 + .saturating_add(Weight::from_parts(33_359_307, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(6)) + .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(5)) + .saturating_add(T::DbWeight::get().writes((2_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 5116).saturating_mul(n.into())) } /// Storage: MarketCommons Markets (r:1 w:0) /// Proof: MarketCommons Markets (max_values: None, max_size: Some(678), added: 3153, 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: NeoSwaps Pools (max_values: None, max_size: Some(144745), added: 147220, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:256 w:256) /// Proof: Tokens Accounts (max_values: None, max_size: Some(123), added: 2598, mode: MaxEncodedLen) - fn join(_n: u32) -> Weight { + /// Storage: System Account (r:1 w:0) + /// Proof: System Account (max_values: None, max_size: Some(132), added: 2607, mode: MaxEncodedLen) + /// The range of component `n` is `[2, 128]`. + fn join_in_place(n: u32) -> Weight { // Proof Size summary in bytes: - // Measured: `2793` - // Estimated: `20672` - // Minimum execution time: 122_321 nanoseconds. - Weight::from_parts(140_940_000, 20672) - .saturating_add(T::DbWeight::get().reads(6)) - .saturating_add(T::DbWeight::get().writes(5)) + // Measured: `140919 + n * (261 ±0)` + // Estimated: `152980 + n * (5196 ±0)` + // Minimum execution time: 507_000 nanoseconds. + Weight::from_parts(381_461_038, 152980) + // Standard Error: 6_222_921 + .saturating_add(Weight::from_parts(43_846_753, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(1)) + .saturating_add(T::DbWeight::get().writes((2_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 5196).saturating_mul(n.into())) } /// Storage: MarketCommons Markets (r:1 w:0) /// Proof: MarketCommons Markets (max_values: None, max_size: Some(678), added: 3153, 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: NeoSwaps Pools (max_values: None, max_size: Some(144745), added: 147220, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:256 w:256) /// 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(_n: u32) -> Weight { + /// The range of component `n` is `[2, 128]`. + fn join_reassigned(n: u32) -> Weight { // Proof Size summary in bytes: - // Measured: `2561` - // Estimated: `23279` - // Minimum execution time: 124_770 nanoseconds. - Weight::from_parts(151_910_000, 23279) - .saturating_add(T::DbWeight::get().reads(7)) - .saturating_add(T::DbWeight::get().writes(5)) + // Measured: `140715 + n * (261 ±0)` + // Estimated: `152980 + n * (5196 ±0)` + // Minimum execution time: 445_000 nanoseconds. + Weight::from_parts(295_293_073, 152980) + // Standard Error: 2_105_968 + .saturating_add(Weight::from_parts(41_721_645, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(1)) + .saturating_add(T::DbWeight::get().writes((2_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 5196).saturating_mul(n.into())) } + /// Storage: MarketCommons Markets (r:1 w:0) + /// Proof: MarketCommons Markets (max_values: None, max_size: Some(678), added: 3153, 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:1 w:1) + /// Proof: NeoSwaps Pools (max_values: None, max_size: Some(144745), added: 147220, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:256 w:256) + /// 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) + /// The range of component `n` is `[2, 128]`. + fn join_leaf(n: u32) -> Weight { + // Proof Size summary in bytes: + // Measured: `140720 + n * (261 ±0)` + // Estimated: `152980 + n * (5196 ±0)` + // Minimum execution time: 489_000 nanoseconds. + Weight::from_parts(426_883_549, 152980) + // Standard Error: 1_583_212 + .saturating_add(Weight::from_parts(41_776_406, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(1)) + .saturating_add(T::DbWeight::get().writes((2_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 5196).saturating_mul(n.into())) + } + /// Storage: MarketCommons Markets (r:1 w:0) + /// Proof: MarketCommons Markets (max_values: None, max_size: Some(678), added: 3153, mode: MaxEncodedLen) + /// Storage: NeoSwaps Pools (r:1 w:1) + /// Proof: NeoSwaps Pools (max_values: None, max_size: Some(144745), added: 147220, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:256 w:256) + /// 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) + /// The range of component `n` is `[2, 128]`. + fn exit(n: u32) -> Weight { + // Proof Size summary in bytes: + // Measured: `140816 + n * (261 ±0)` + // Estimated: `152980 + n * (5196 ±0)` + // Minimum execution time: 449_000 nanoseconds. + Weight::from_parts(555_190_909, 152980) + // Standard Error: 2_997_117 + .saturating_add(Weight::from_parts(39_540_909, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(1)) + .saturating_add(T::DbWeight::get().writes((2_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 5196).saturating_mul(n.into())) + } + /// Storage: NeoSwaps Pools (r:1 w:1) + /// Proof: NeoSwaps Pools (max_values: None, max_size: Some(144745), added: 147220, mode: MaxEncodedLen) + /// Storage: System Account (r:2 w:2) /// 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: 76_910 nanoseconds. - Weight::from_parts(94_141_000, 9734) - .saturating_add(T::DbWeight::get().reads(2)) - .saturating_add(T::DbWeight::get().writes(2)) + // Measured: `139382` + // Estimated: `152434` + // Minimum execution time: 431_000 nanoseconds. + Weight::from_parts(452_000_000, 152434) + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().writes(3)) } /// Storage: MarketCommons Markets (r:1 w:0) /// Proof: MarketCommons Markets (max_values: None, max_size: Some(678), added: 3153, 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: NeoSwaps Pools (max_values: None, max_size: Some(144745), added: 147220, mode: MaxEncodedLen) + /// Storage: Tokens Accounts (r:256 w:256) /// 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(_n: u32) -> Weight { + /// The range of component `n` is `[2, 128]`. + fn deploy_pool(n: u32) -> Weight { // Proof Size summary in bytes: - // Measured: `2278` - // Estimated: `23279` - // Minimum execution time: 167_210 nanoseconds. - Weight::from_parts(189_090_000, 23279) - .saturating_add(T::DbWeight::get().reads(7)) - .saturating_add(T::DbWeight::get().writes(6)) + // Measured: `2167 + n * (113 ±0)` + // Estimated: `152980 + n * (5196 ±0)` + // Minimum execution time: 233_000 nanoseconds. + Weight::from_parts(553_764_069, 152980) + // Standard Error: 3_614_297 + .saturating_add(Weight::from_parts(46_549_783, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(2)) + .saturating_add(T::DbWeight::get().writes((2_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 5196).saturating_mul(n.into())) } }