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::