From 4d3519c31b52e89dcbff2f992fdc478cc7a3f054 Mon Sep 17 00:00:00 2001 From: Chralt Date: Wed, 27 Mar 2024 15:06:55 +0100 Subject: [PATCH] Add previous stake information after rejoin (#1285) * update joined_at after rejoin * Add additional fields to CourtPoolItem struct, refactor * complete tests * add migration * revert benchmark verify check * remove migration comment * satisfy clippy * implement different approach * adopt uneligble stake and index info * update benchmark * apply review comments * correct current period index calculation * apply review suggestions * Update zrml/court/src/tests.rs Co-authored-by: Harald Heckmann * shorten test * add try-runtime checks --------- Co-authored-by: Harald Heckmann --- Cargo.lock | 1 + macros/src/lib.rs | 2 + runtime/common/src/lib.rs | 3 +- zrml/court/Cargo.toml | 1 + zrml/court/src/benchmarks.rs | 12 +- zrml/court/src/lib.rs | 204 ++++++++++++++-------- zrml/court/src/migrations.rs | 239 +++++++++++++++++++++++++- zrml/court/src/tests.rs | 316 +++++++++++++++++++++++++++++++++-- zrml/court/src/types.rs | 9 +- 9 files changed, 696 insertions(+), 91 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8804f7d18..2fee7efbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14559,6 +14559,7 @@ dependencies = [ "sp-io", "sp-runtime", "test-case", + "zeitgeist-macros", "zeitgeist-primitives", "zrml-market-commons", ] diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 2534ff00f..b6ee80362 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -15,6 +15,8 @@ // You should have received a copy of the GNU General Public License // along with Zeitgeist. If not, see . +#![cfg_attr(not(feature = "std"), no_std)] + /// Creates an `alloc::collections::BTreeMap` from the pattern `{ key => value, ... }`. /// /// ```ignore diff --git a/runtime/common/src/lib.rs b/runtime/common/src/lib.rs index 42e58e15a..3084e4a71 100644 --- a/runtime/common/src/lib.rs +++ b/runtime/common/src/lib.rs @@ -55,12 +55,13 @@ 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_court::migrations::MigrateCourtPoolItems; pub type Block = generic::Block; type Address = sp_runtime::MultiAddress; - type Migrations = (); + type Migrations = (MigrateCourtPoolItems,); pub type Executive = frame_executive::Executive< Runtime, diff --git a/zrml/court/Cargo.toml b/zrml/court/Cargo.toml index 5bad41489..6e1071d7a 100644 --- a/zrml/court/Cargo.toml +++ b/zrml/court/Cargo.toml @@ -9,6 +9,7 @@ rand_chacha = { workspace = true } scale-info = { workspace = true, features = ["derive"] } sp-arithmetic = { workspace = true } sp-runtime = { workspace = true } +zeitgeist-macros = { workspace = true } zeitgeist-primitives = { workspace = true } zrml-market-commons = { workspace = true } diff --git a/zrml/court/src/benchmarks.rs b/zrml/court/src/benchmarks.rs index 39a810659..d58c9a610 100644 --- a/zrml/court/src/benchmarks.rs +++ b/zrml/court/src/benchmarks.rs @@ -122,8 +122,14 @@ where }, ); let consumed_stake = BalanceOf::::zero(); - let pool_item = - CourtPoolItem { stake, court_participant: juror.clone(), consumed_stake, joined_at }; + let pool_item = CourtPoolItem { + stake, + court_participant: juror.clone(), + consumed_stake, + joined_at, + uneligible_index: 0u64.saturated_into::(), + uneligible_stake: BalanceOf::::zero(), + }; match pool.binary_search_by_key(&(stake, &juror), |pool_item| { (pool_item.stake, &pool_item.court_participant) }) { @@ -670,7 +676,7 @@ benchmarks! { let j in 1..T::MaxCourtParticipants::get(); fill_pool::(j)?; - >::set_block_number(T::InflationPeriod::get()); + >::set_block_number(T::InflationPeriod::get().saturating_mul(2u32.into())); let now = >::block_number(); YearlyInflation::::put(Perbill::from_percent(2)); }: { diff --git a/zrml/court/src/lib.rs b/zrml/court/src/lib.rs index 3d292596d..70810ac77 100644 --- a/zrml/court/src/lib.rs +++ b/zrml/court/src/lib.rs @@ -29,6 +29,7 @@ use crate::{ }; use alloc::{ collections::{BTreeMap, BTreeSet}, + format, vec::Vec, }; use core::marker::PhantomData; @@ -57,11 +58,12 @@ use sp_arithmetic::{ traits::{CheckedRem, One}, }; use sp_runtime::{ - traits::{AccountIdConversion, Hash, Saturating, StaticLookup, Zero}, + traits::{AccountIdConversion, CheckedDiv, Hash, Saturating, StaticLookup, Zero}, DispatchError, Perbill, SaturatedConversion, }; +use zeitgeist_macros::unreachable_non_terminating; use zeitgeist_primitives::{ - math::checked_ops_res::CheckedRemRes, + math::checked_ops_res::{CheckedAddRes, CheckedRemRes, CheckedSubRes}, traits::{DisputeApi, DisputeMaxWeightApi, DisputeResolutionApi}, types::{ Asset, GlobalDisputeItem, Market, MarketDisputeMechanism, MarketStatus, OutcomeReport, @@ -203,7 +205,7 @@ mod pallet { /// Number of draws for the initial court round. const INITIAL_DRAWS_NUM: usize = 31; /// The current storage version. - const STORAGE_VERSION: StorageVersion = StorageVersion::new(2); + const STORAGE_VERSION: StorageVersion = StorageVersion::new(3); const LOG_TARGET: &str = "runtime::zrml-court"; /// Weight used to increase the number of jurors for subsequent appeals /// of the same court. @@ -212,6 +214,7 @@ mod pallet { const APPEAL_BOND_BASIS: u32 = 2; pub(crate) type AccountIdOf = ::AccountId; + pub(crate) type BlockNumberOf = ::BlockNumber; pub(crate) type BalanceOf = <::Currency as Currency>>::Balance; pub(crate) type NegativeImbalanceOf = <::Currency as Currency>>::NegativeImbalance; @@ -482,6 +485,8 @@ mod pallet { pub enum UnexpectedError { /// The binary search by key functionality failed to find an element, although expected. BinarySearchByKeyFailed, + /// The inflation period is zero. + InflationPeriodIsZero, } #[pallet::hooks] @@ -1188,6 +1193,88 @@ mod pallet { where T: Config, { + fn get_uneligible_stake( + pool_item_opt: Option<&CourtPoolItemOf>, + amount: BalanceOf, + current_period_index: BlockNumberFor, + ) -> Result, DispatchError> { + let pool_item = match pool_item_opt { + Some(pool_item) => pool_item, + None => return Ok(amount), + }; + + if current_period_index != pool_item.uneligible_index { + amount.checked_sub_res(&pool_item.stake) + } else { + let additional_uneligible_stake = amount.checked_sub_res(&pool_item.stake)?; + pool_item.uneligible_stake.checked_add_res(&additional_uneligible_stake) + } + } + + fn get_initial_joined_at( + prev_pool_item: Option<&CourtPoolItemOf>, + now: BlockNumberFor, + ) -> BlockNumberFor { + match prev_pool_item { + Some(i) => i.joined_at, + None => now, + } + } + + #[inline] + fn is_sorted_and_all_greater_than_lowest( + p: &CourtPoolOf, + lowest_stake: BalanceOf, + ) -> bool { + p.windows(2).all(|w| w[0].stake <= w[1].stake && lowest_stake <= w[0].stake) + } + + fn remove_weakest_if_full( + mut p: CourtPoolOf, + amount: BalanceOf, + ) -> Result, DispatchError> { + if p.is_full() { + let lowest_item = p.first(); + let lowest_stake = lowest_item + .map(|pool_item| pool_item.stake) + .unwrap_or_else(>::zero); + unreachable_non_terminating!( + Self::is_sorted_and_all_greater_than_lowest(&p, lowest_stake), + LOG_TARGET, + "Pool is not sorted or not all stakes are greater than the lowest stake.", + ); + ensure!(amount > lowest_stake, Error::::AmountBelowLowestJuror); + p.remove(0); + } + + Ok(p) + } + + fn handle_existing_participant( + who: &T::AccountId, + amount: BalanceOf, + mut pool: CourtPoolOf, + prev_p_info: &CourtParticipantInfoOf, + ) -> Result<(CourtPoolOf, BalanceOf, Option>), DispatchError> + { + ensure!(amount >= prev_p_info.stake, Error::::AmountBelowLastJoin); + + if let Some((index, pool_item)) = Self::get_pool_item(&pool, prev_p_info.stake, who)? { + let consumed_stake = pool_item.consumed_stake; + let prev_pool_item = Some(pool_item.clone()); + + pool.remove(index); + + Ok((pool, consumed_stake, prev_pool_item)) + } else { + let consumed_stake = prev_p_info.active_lock; + + let pool = Self::remove_weakest_if_full(pool, amount)?; + + Ok((pool, consumed_stake, None)) + } + } + fn do_join_court( who: &T::AccountId, amount: BalanceOf, @@ -1201,57 +1288,31 @@ mod pallet { let now = >::block_number(); - let remove_weakest_if_full = - |mut p: CourtPoolOf| -> Result, DispatchError> { - if p.is_full() { - let lowest_item = p.first(); - let lowest_stake = lowest_item - .map(|pool_item| pool_item.stake) - .unwrap_or_else(>::zero); - debug_assert!({ - let mut sorted = p.clone(); - sorted.sort_by_key(|pool_item| { - (pool_item.stake, pool_item.court_participant.clone()) - }); - p.len() == sorted.len() - && p.iter() - .zip(sorted.iter()) - .all(|(a, b)| lowest_stake <= a.stake && a == b) - }); - ensure!(amount > lowest_stake, Error::::AmountBelowLowestJuror); - // remove the lowest staked court participant - p.remove(0); + let (prev_pool_item_opt, active_lock, consumed_stake) = + match >::get(who) { + Some(prev_p_info) => { + let (pruned_pool, old_consumed_stake, prev_pool_item_opt) = + Self::handle_existing_participant(who, amount, pool, &prev_p_info)?; + pool = pruned_pool; + (prev_pool_item_opt, prev_p_info.active_lock, old_consumed_stake) + } + None => { + pool = Self::remove_weakest_if_full(pool, amount)?; + (None, BalanceOf::::zero(), BalanceOf::::zero()) } - - Ok(p) }; - let mut active_lock = BalanceOf::::zero(); - let mut consumed_stake = BalanceOf::::zero(); - let mut joined_at = now; - - if let Some(prev_p_info) = >::get(who) { - ensure!(amount >= prev_p_info.stake, Error::::AmountBelowLastJoin); - - if let Some((index, pool_item)) = - Self::get_pool_item(&pool, prev_p_info.stake, who)? - { - active_lock = prev_p_info.active_lock; - consumed_stake = pool_item.consumed_stake; - joined_at = pool_item.joined_at; - - pool.remove(index); - } else { - active_lock = prev_p_info.active_lock; - consumed_stake = prev_p_info.active_lock; - - pool = remove_weakest_if_full(pool)?; - } - } else { - pool = remove_weakest_if_full(pool)?; - } + let inflation_period = T::InflationPeriod::get(); + let current_period_index = now + .checked_div(&inflation_period) + .ok_or(Error::::Unexpected(UnexpectedError::InflationPeriodIsZero))?; - let (active_lock, consumed_stake, joined_at) = (active_lock, consumed_stake, joined_at); + let uneligible_stake = Self::get_uneligible_stake( + prev_pool_item_opt.as_ref(), + amount, + current_period_index, + )?; + let joined_at = Self::get_initial_joined_at(prev_pool_item_opt.as_ref(), now); match pool.binary_search_by_key(&(amount, who), |pool_item| { (pool_item.stake, &pool_item.court_participant) @@ -1272,6 +1333,8 @@ mod pallet { court_participant: who.clone(), consumed_stake, joined_at, + uneligible_index: current_period_index, + uneligible_stake, }, ) .map_err(|_| { @@ -1325,11 +1388,11 @@ mod pallet { let yearly_inflation_amount = yearly_inflation_amount.saturated_into::>(); let issue_per_block = issue_per_block.saturated_into::>(); - let inflation_period = + let inflation_period_balance = inflation_period.saturated_into::().saturated_into::>(); // example: 7979867607 * 7200 * 30 = 1723651403112000 - let inflation_period_mint = issue_per_block.saturating_mul(inflation_period); + let inflation_period_mint = issue_per_block.saturating_mul(inflation_period_balance); // inflation_period_mint shouldn't exceed 0.5% of the total issuance let log_threshold = Perbill::from_perthousand(5u32) @@ -1355,34 +1418,41 @@ mod pallet { let pool = >::get(); let pool_len = pool.len() as u32; - let at_least_one_inflation_period = - |joined_at| now.saturating_sub(joined_at) >= T::InflationPeriod::get(); - let total_stake = pool - .iter() - .filter(|pool_item| at_least_one_inflation_period(pool_item.joined_at)) - .fold(0u128, |acc, pool_item| { - acc.saturating_add(pool_item.stake.saturated_into::()) + debug_assert!(!inflation_period.is_zero()); + let current_period_index = + now.checked_div(&inflation_period).map(|x| x.saturating_sub(One::one())); + let eligible_stake = |pool_item: &CourtPoolItemOf| match current_period_index { + Some(index) if index != pool_item.uneligible_index => pool_item.stake, + _ => pool_item.stake.saturating_sub(pool_item.uneligible_stake), + }; + let total_eligible_stake = + pool.iter().fold(BalanceOf::::zero(), |acc, pool_item| { + eligible_stake(pool_item).saturating_add(acc) }); - if total_stake.is_zero() { + if total_eligible_stake.is_zero() { return T::WeightInfo::handle_inflation(0u32); } let mut total_mint = T::Currency::issue(inflation_period_mint); - for CourtPoolItem { stake, court_participant, joined_at, .. } in pool { - if !at_least_one_inflation_period(joined_at) { - // participants who joined and didn't wait - // at least one full inflation period won't get a reward + for pool_item in pool { + let eligible_stake = eligible_stake(&pool_item); + if eligible_stake.is_zero() { continue; } - let share = Perquintill::from_rational(stake.saturated_into::(), total_stake); + let share = Perquintill::from_rational( + eligible_stake.saturated_into::(), + total_eligible_stake.saturated_into::(), + ); let mint = share.mul_floor(inflation_period_mint.saturated_into::()); let (mint_imb, remainder) = total_mint.split(mint.saturated_into::>()); let mint_amount = mint_imb.peek(); total_mint = remainder; - if let Ok(()) = T::Currency::resolve_into_existing(&court_participant, mint_imb) { + if let Ok(()) = + T::Currency::resolve_into_existing(&pool_item.court_participant, mint_imb) + { Self::deposit_event(Event::MintedInCourt { - court_participant: court_participant.clone(), + court_participant: pool_item.court_participant.clone(), amount: mint_amount, }); } diff --git a/zrml/court/src/migrations.rs b/zrml/court/src/migrations.rs index 16887b73b..b5e2f8630 100644 --- a/zrml/court/src/migrations.rs +++ b/zrml/court/src/migrations.rs @@ -1,4 +1,4 @@ -// Copyright 2021-2022 Zeitgeist PM LLC. +// Copyright 2024 Forecasting Technologies LTD. // // This file is part of Zeitgeist. // @@ -14,3 +14,240 @@ // // You should have received a copy of the GNU General Public License // along with Zeitgeist. If not, see . + +use crate::{ + AccountIdOf, BalanceOf, BlockNumberOf, Config, CourtPoolItemOf, CourtPoolOf, Pallet as Court, +}; +use alloc::vec::Vec; +use core::marker::PhantomData; +use frame_support::{ + log, + pallet_prelude::{StorageVersion, ValueQuery, Weight}, + traits::{Get, OnRuntimeUpgrade}, + BoundedVec, +}; +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_runtime::{traits::CheckedDiv, SaturatedConversion}; + +#[derive(Decode, Encode, MaxEncodedLen, TypeInfo, Clone, Debug, PartialEq, Eq)] +pub struct OldCourtPoolItem { + pub stake: Balance, + pub court_participant: AccountId, + pub consumed_stake: Balance, + pub joined_at: BlockNumber, +} + +type OldCourtPoolItemOf = OldCourtPoolItem, BalanceOf, BlockNumberOf>; +type OldCourtPoolOf = BoundedVec, ::MaxCourtParticipants>; + +#[frame_support::storage_alias] +pub(crate) type CourtPool = StorageValue, OldCourtPoolOf, ValueQuery>; + +const COURT_REQUIRED_STORAGE_VERSION: u16 = 2; +const COURT_NEXT_STORAGE_VERSION: u16 = 3; + +pub struct MigrateCourtPoolItems(PhantomData); + +impl OnRuntimeUpgrade for MigrateCourtPoolItems +where + T: Config, +{ + fn on_runtime_upgrade() -> Weight { + let mut total_weight = T::DbWeight::get().reads(1); + let market_commons_version = StorageVersion::get::>(); + if market_commons_version != COURT_REQUIRED_STORAGE_VERSION { + log::info!( + "MigrateCourtPoolItems: court storage version is {:?}, but {:?} is required", + market_commons_version, + COURT_REQUIRED_STORAGE_VERSION, + ); + return total_weight; + } + log::info!("MigrateCourtPoolItems: Starting..."); + + let res = crate::CourtPool::::translate::, _>(|old_pool_opt| { + old_pool_opt.map(|mut old_pool| { + >::truncate_from( + old_pool + .iter_mut() + .map(|old_pool_item: &mut OldCourtPoolItemOf| CourtPoolItemOf:: { + stake: old_pool_item.stake, + court_participant: old_pool_item.court_participant.clone(), + consumed_stake: old_pool_item.consumed_stake, + joined_at: old_pool_item.joined_at, + uneligible_index: old_pool_item + .joined_at + .checked_div(&T::InflationPeriod::get()) + // because inflation period is not zero checked_div is safe + .unwrap_or(0u64.saturated_into::>()), + // using old_pool_item.stake leads to all joins in period 24 + // to be uneligible, which is exactly what we want + // if using zero, all joins of period 24 would be eligible, + // but only joins in 23 waited the full period yet + // to understand the calculation of the eligible stake look into handle_inflation + uneligible_stake: old_pool_item.stake, + }) + .collect::>(), + ) + }) + }); + match res { + Ok(_) => log::info!("MigrateCourtPoolItems: Success!"), + Err(e) => log::error!("MigrateCourtPoolItems: Error: {:?}", e), + } + + total_weight = total_weight.saturating_add(T::DbWeight::get().reads_writes(1, 1)); + + StorageVersion::new(COURT_NEXT_STORAGE_VERSION).put::>(); + total_weight = total_weight.saturating_add(T::DbWeight::get().writes(1)); + log::info!("MigrateCourtPoolItems: Done!"); + total_weight + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, &'static str> { + log::info!("MigrateCourtPoolItems: Preparing to migrate old pool items..."); + let court_pool = CourtPool::::get(); + log::info!( + "MigrateCourtPoolItems: pre-upgrade executed. Migrating {:?} pool items", + court_pool.len() + ); + Ok(court_pool.encode()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(previous_state: Vec) -> Result<(), &'static str> { + let old_court_pool: OldCourtPoolOf = + OldCourtPoolOf::::decode(&mut previous_state.as_slice()) + .map_err(|_| "MigrateCourtPoolItems: failed to decode old court pool")?; + let new_court_pool = crate::CourtPool::::get(); + assert_eq!(old_court_pool.len(), new_court_pool.len()); + old_court_pool.iter().zip(new_court_pool.iter()).try_for_each( + |(old, new)| -> Result<(), &'static str> { + assert_eq!(old.stake, new.stake); + assert_eq!(old.court_participant, new.court_participant); + assert_eq!(old.consumed_stake, new.consumed_stake); + assert_eq!(old.joined_at, new.joined_at); + let uneligible_index = old + .joined_at + .checked_div(&T::InflationPeriod::get()) + .ok_or("MigrateCourtPoolItems: failed to divide by inflation period")?; + assert_eq!(new.uneligible_index, uneligible_index); + assert_eq!(new.uneligible_stake, old.stake); + + Ok(()) + }, + )?; + log::info!( + "MigrateCourtPoolItems: post-upgrade executed. Migrated {:?} pool items", + new_court_pool.len() + ); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mock::{ExtBuilder, Runtime}; + use frame_support::storage_root; + use sp_runtime::StateVersion; + + #[test] + fn on_runtime_upgrade_increments_the_storage_version() { + ExtBuilder::default().build().execute_with(|| { + set_up_version(); + MigrateCourtPoolItems::::on_runtime_upgrade(); + assert_eq!(StorageVersion::get::>(), COURT_NEXT_STORAGE_VERSION); + }); + } + + #[test] + fn on_runtime_upgrade_works_as_expected() { + ExtBuilder::default().build().execute_with(|| { + set_up_version(); + let stake_0 = 100; + let stake_1 = 101; + let court_participant = 200; + let consumed_stake = 300; + let inflation_period = ::InflationPeriod::get(); + assert_eq!(inflation_period, 20u64); + let joined_at_0 = 461; + let joined_at_1 = 481; + let old_court_pool = OldCourtPoolOf::::truncate_from(vec![ + OldCourtPoolItemOf:: { + stake: stake_0, + court_participant, + consumed_stake, + joined_at: joined_at_0, + }, + OldCourtPoolItemOf:: { + stake: stake_1, + court_participant, + consumed_stake, + joined_at: joined_at_1, + }, + ]); + let new_court_pool = CourtPoolOf::::truncate_from(vec![ + CourtPoolItemOf:: { + stake: stake_0, + court_participant, + consumed_stake, + joined_at: joined_at_0, + uneligible_index: 23, + uneligible_stake: stake_0, + }, + CourtPoolItemOf:: { + stake: stake_1, + court_participant, + consumed_stake, + joined_at: joined_at_1, + uneligible_index: 24, + uneligible_stake: stake_1, + }, + ]); + CourtPool::::put::>(old_court_pool); + // notice we use the old storage item here to find out if we added the old elements + // decoding of the values would fail + assert_eq!(crate::CourtPool::::decode_len().unwrap(), 2usize); + MigrateCourtPoolItems::::on_runtime_upgrade(); + + let actual = crate::CourtPool::::get(); + assert_eq!(actual, new_court_pool); + }); + } + + #[test] + fn on_runtime_upgrade_is_noop_if_versions_are_not_correct() { + ExtBuilder::default().build().execute_with(|| { + StorageVersion::new(COURT_NEXT_STORAGE_VERSION).put::>(); + let court_pool = >::truncate_from(vec![ + CourtPoolItemOf:: { + stake: 1, + court_participant: 2, + consumed_stake: 3, + joined_at: 4, + uneligible_index: 23, + uneligible_stake: 1, + }, + CourtPoolItemOf:: { + stake: 8, + court_participant: 9, + consumed_stake: 10, + joined_at: 11, + uneligible_index: 24, + uneligible_stake: 8, + }, + ]); + crate::CourtPool::::put(court_pool); + let tmp = storage_root(StateVersion::V1); + MigrateCourtPoolItems::::on_runtime_upgrade(); + assert_eq!(tmp, storage_root(StateVersion::V1)); + }); + } + + fn set_up_version() { + StorageVersion::new(COURT_REQUIRED_STORAGE_VERSION).put::>(); + } +} diff --git a/zrml/court/src/tests.rs b/zrml/court/src/tests.rs index 71a6561f1..eafb1d38e 100644 --- a/zrml/court/src/tests.rs +++ b/zrml/court/src/tests.rs @@ -26,7 +26,7 @@ use crate::{ }, mock_storage::pallet::MarketIdsPerDisputeBlock, types::{CourtStatus, Draw, Vote, VoteItem}, - AppealInfo, BalanceOf, CourtId, CourtIdToMarketId, CourtParticipantInfo, + AppealInfo, BalanceOf, BlockNumberOf, CourtId, CourtIdToMarketId, CourtParticipantInfo, CourtParticipantInfoOf, CourtPool, CourtPoolItem, CourtPoolOf, Courts, Error, Event, MarketIdToCourtId, MarketOf, NegativeImbalanceOf, Participants, RequestBlock, SelectedDraws, YearlyInflation, @@ -225,7 +225,9 @@ fn join_court_successfully_stores_required_data() { stake: amount, court_participant: ALICE, consumed_stake: 0, - joined_at + joined_at, + uneligible_index: 0, + uneligible_stake: amount, }] ); }); @@ -245,7 +247,9 @@ fn join_court_works_multiple_joins() { stake: amount, court_participant: ALICE, consumed_stake: 0, - joined_at: joined_at_0 + joined_at: joined_at_0, + uneligible_index: 0, + uneligible_stake: amount, }] ); assert_eq!( @@ -262,6 +266,8 @@ fn join_court_works_multiple_joins() { )] ); + run_blocks(InflationPeriod::get()); + let joined_at_1 = >::block_number(); assert_ok!(Court::join_court(RuntimeOrigin::signed(BOB), amount)); assert_eq!(Balances::locks(BOB), vec![the_lock(amount)]); @@ -272,13 +278,17 @@ fn join_court_works_multiple_joins() { stake: amount, court_participant: ALICE, consumed_stake: 0, - joined_at: joined_at_0 + joined_at: joined_at_0, + uneligible_index: 0, + uneligible_stake: amount, }, CourtPoolItem { stake: amount, court_participant: BOB, consumed_stake: 0, - joined_at: joined_at_1 + joined_at: joined_at_1, + uneligible_index: 1, + uneligible_stake: amount, } ] ); @@ -302,6 +312,8 @@ fn join_court_works_multiple_joins() { } ); + run_blocks(InflationPeriod::get()); + let higher_amount = amount + 1; assert_ok!(Court::join_court(RuntimeOrigin::signed(ALICE), higher_amount)); assert_eq!(Balances::locks(BOB), vec![the_lock(amount)]); @@ -313,13 +325,17 @@ fn join_court_works_multiple_joins() { stake: amount, court_participant: BOB, consumed_stake: 0, - joined_at: joined_at_1 + joined_at: joined_at_1, + uneligible_index: 1, + uneligible_stake: amount, }, CourtPoolItem { stake: higher_amount, court_participant: ALICE, consumed_stake: 0, - joined_at: joined_at_0 + joined_at: joined_at_0, + uneligible_index: 2, + uneligible_stake: higher_amount - amount, }, ] ); @@ -368,6 +384,8 @@ fn join_court_saves_consumed_stake_and_active_lock_for_double_join() { court_participant: ALICE, consumed_stake, joined_at, + uneligible_index: 0, + uneligible_stake: amount, }]; CourtPool::::put::>(juror_pool.try_into().unwrap()); @@ -471,7 +489,9 @@ fn prepare_exit_court_works() { stake: amount, court_participant: ALICE, consumed_stake: 0, - joined_at + joined_at, + uneligible_index: 0, + uneligible_stake: amount, }] ); @@ -607,7 +627,9 @@ fn prepare_exit_court_fails_juror_already_prepared_to_exit() { stake: amount, court_participant: ALICE, consumed_stake: 0, - joined_at + joined_at, + uneligible_index: 0, + uneligible_stake: amount, }] ); @@ -3089,30 +3111,288 @@ fn handle_inflation_works() { court_participant: juror, consumed_stake: 0, joined_at, + uneligible_index: 1, + uneligible_stake: 0, }) .unwrap(); } >::put(jurors.clone()); let inflation_period = InflationPeriod::get(); - run_blocks(inflation_period); - let now = >::block_number(); - Court::handle_inflation(now); + run_blocks(inflation_period + 1); let free_balance_after_0 = Balances::free_balance(jurors_list[0]); - assert_eq!(free_balance_after_0 - free_balances_before[&jurors_list[0]], 432_012); + assert_eq!(free_balance_after_0 - free_balances_before[&jurors_list[0]], 216_002); let free_balance_after_1 = Balances::free_balance(jurors_list[1]); - assert_eq!(free_balance_after_1 - free_balances_before[&jurors_list[1]], 4_320_129); + assert_eq!(free_balance_after_1 - free_balances_before[&jurors_list[1]], 2_160_021); let free_balance_after_2 = Balances::free_balance(jurors_list[2]); - assert_eq!(free_balance_after_2 - free_balances_before[&jurors_list[2]], 43_201_302); + assert_eq!(free_balance_after_2 - free_balances_before[&jurors_list[2]], 21_600_219); let free_balance_after_3 = Balances::free_balance(jurors_list[3]); - assert_eq!(free_balance_after_3 - free_balances_before[&jurors_list[3]], 432_013_038); + assert_eq!(free_balance_after_3 - free_balances_before[&jurors_list[3]], 216_002_199); let free_balance_after_4 = Balances::free_balance(jurors_list[4]); - assert_eq!(free_balance_after_4 - free_balances_before[&jurors_list[4]], 4_320_130_393); + assert_eq!(free_balance_after_4 - free_balances_before[&jurors_list[4]], 2_160_021_996); + }); +} + +#[test] +fn block_inflation_reward_after_higher_rejoin() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(Court::set_inflation(RuntimeOrigin::root(), Perbill::from_percent(2u32))); + + run_to_block(InflationPeriod::get() - 1); + + let low_risk_stake = MinJurorStake::get(); + assert_ok!(Court::join_court(RuntimeOrigin::signed(BOB), low_risk_stake)); + let charlie_stake = MinJurorStake::get() * 9; + assert_ok!(Court::join_court(RuntimeOrigin::signed(CHARLIE), charlie_stake)); + + let free_bob_before = Balances::free_balance(BOB); + let free_charlie_before = Balances::free_balance(CHARLIE); + + run_blocks(1); + + let free_bob_after = Balances::free_balance(BOB); + assert_eq!(free_bob_after - free_bob_before, 0); + let free_charlie_after = Balances::free_balance(CHARLIE); + assert_eq!(free_charlie_after - free_charlie_before, 0); + + let high_risk_stake = MinJurorStake::get() * 27; + assert_ok!(Court::join_court(RuntimeOrigin::signed(BOB), high_risk_stake)); + + let free_bob_before = Balances::free_balance(BOB); + let free_charlie_before = Balances::free_balance(CHARLIE); + + let inflation_period = InflationPeriod::get(); + run_blocks(inflation_period); + + let free_bob_after = Balances::free_balance(BOB); + let bob_reward = 240000000; + assert_eq!(free_bob_after - free_bob_before, bob_reward); + + let free_charlie_after = Balances::free_balance(CHARLIE); + let charlie_reward = 2160000000; + assert_eq!(free_charlie_after - free_charlie_before, charlie_reward); + + assert_eq!(bob_reward * 9, charlie_reward); + + let free_bob_before = Balances::free_balance(BOB); + let free_charlie_before = Balances::free_balance(CHARLIE); + + run_blocks(inflation_period); + + let free_bob_after = Balances::free_balance(BOB); + let bob_reward = 1800072000; + assert_eq!(free_bob_after - free_bob_before, bob_reward); + + let free_charlie_after = Balances::free_balance(CHARLIE); + let charlie_reward = 600024000; + assert_eq!(free_charlie_after - free_charlie_before, charlie_reward); + + assert_eq!(bob_reward, charlie_reward * 3); + }); +} + +fn gain_equal( + account: AccountIdTest, + free_before: BalanceOf, + gain: BalanceOf, +) -> BalanceOf { + let free_after = Balances::free_balance(account); + assert_eq!(free_after - free_before, gain); + gain +} + +struct Params { + stake: BalanceOf, + uneligible_index: BlockNumberOf, + uneligible_stake: BalanceOf, +} + +fn check_pool_item_bob(params: Params) { + let jurors = >::get(); + let pool_item_bob = jurors.iter().find(|juror| juror.court_participant == BOB).unwrap().clone(); + + assert_eq!(pool_item_bob.stake, params.stake); + assert_eq!(pool_item_bob.uneligible_index, params.uneligible_index); + assert_eq!(pool_item_bob.uneligible_stake, params.uneligible_stake); +} + +#[test] +fn reward_inflation_correct_for_multiple_rejoins() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(Court::set_inflation(RuntimeOrigin::root(), Perbill::from_percent(2u32))); + + let alice_amount = MinJurorStake::get(); + assert_ok!(Court::join_court(RuntimeOrigin::signed(ALICE), alice_amount)); + + let amount_0 = MinJurorStake::get(); + assert_ok!(Court::join_court(RuntimeOrigin::signed(BOB), amount_0)); + + check_pool_item_bob(Params { + stake: amount_0, + uneligible_index: 0, + uneligible_stake: amount_0, + }); + + let free_alice_before = Balances::free_balance(ALICE); + let free_bob_before = Balances::free_balance(BOB); + + // period 0 over + run_to_block(InflationPeriod::get()); + + gain_equal(ALICE, free_alice_before, 0); + gain_equal(BOB, free_bob_before, 0); + + let inflation_period = InflationPeriod::get(); + run_blocks(inflation_period - 1); + + let amount_1 = MinJurorStake::get() * 2; + assert_ok!(Court::join_court(RuntimeOrigin::signed(BOB), amount_1)); + + check_pool_item_bob(Params { + stake: amount_1, + uneligible_index: 1, + uneligible_stake: amount_1 - amount_0, + }); + + let free_alice_before = Balances::free_balance(ALICE); + let free_bob_before = Balances::free_balance(BOB); + + // period 1 over + run_blocks(1); + + let gain_alice = gain_equal(ALICE, free_alice_before, 1200000000); + let gain_bob = gain_equal(BOB, free_bob_before, 1200000000); + // Alice and Bob gain the same reward, although Bob joined with a higher stake + // but Bob's higher stake is not present for more than one inflation period + // so his stake is not considered for the reward + assert_eq!(gain_alice, gain_bob); + + let free_alice_before = Balances::free_balance(ALICE); + let free_bob_before = Balances::free_balance(BOB); + + // period 2 over + run_blocks(InflationPeriod::get()); + + let amount_2 = MinJurorStake::get() * 3; + assert_ok!(Court::join_court(RuntimeOrigin::signed(BOB), amount_2)); + + check_pool_item_bob(Params { + stake: amount_2, + uneligible_index: 3, + uneligible_stake: amount_2 - amount_1, + }); + + let gain_alice = gain_equal(ALICE, free_alice_before, 800031999); + let gain_bob = gain_equal(BOB, free_bob_before, 1600063999); + // reward for period 2: + // Bob's higher stake is now present, because of a full inflation period waiting time + assert_eq!(gain_alice * 2 + 1, gain_bob); + + let free_alice_before = Balances::free_balance(ALICE); + let free_bob_before = Balances::free_balance(BOB); + + // period 3 over + run_blocks(InflationPeriod::get()); + + check_pool_item_bob(Params { + stake: amount_2, + uneligible_index: 3, + uneligible_stake: amount_2 - amount_1, + }); + + let gain_alice = gain_equal(ALICE, free_alice_before, 800063999); + let gain_bob = gain_equal(BOB, free_bob_before, 1600127999); + // reward for period 3: + // inflation reward is based on the join with amount_1 + // no join in period 2, so use amount_1 + assert_eq!(gain_alice * 2 + 1, gain_bob); + + let free_alice_before = Balances::free_balance(ALICE); + let free_bob_before = Balances::free_balance(BOB); + + // period 4 over + run_blocks(InflationPeriod::get()); + + check_pool_item_bob(Params { + stake: amount_2, + uneligible_index: 3, + uneligible_stake: amount_2 - amount_1, + }); + + let gain_alice = gain_equal(ALICE, free_alice_before, 600072000); + let gain_bob = gain_equal(BOB, free_bob_before, 1800216000); + // reward for period 4: + // inflation reward is based on the last join (amount_2) + assert_eq!(gain_alice * 3, gain_bob); + }); +} + +#[test] +fn stake_information_is_properly_stored() { + ExtBuilder::default().build().execute_with(|| { + let joined_at_0 = >::block_number(); + let amount_0 = MinJurorStake::get(); + assert_ok!(Court::join_court(RuntimeOrigin::signed(BOB), amount_0)); + + let jurors = >::get(); + let pool_item_bob = + jurors.iter().find(|juror| juror.court_participant == BOB).unwrap().clone(); + assert_eq!( + pool_item_bob, + CourtPoolItem { + stake: amount_0, + court_participant: BOB, + consumed_stake: BalanceOf::::zero(), + joined_at: joined_at_0, + uneligible_index: 0, + uneligible_stake: amount_0, + } + ); + + run_blocks(InflationPeriod::get()); + + let amount_1 = MinJurorStake::get() * 2; + assert_ok!(Court::join_court(RuntimeOrigin::signed(BOB), amount_1)); + + let jurors = >::get(); + let pool_item_bob = + jurors.iter().find(|juror| juror.court_participant == BOB).unwrap().clone(); + + assert_eq!( + pool_item_bob, + CourtPoolItem { + stake: amount_1, + court_participant: BOB, + consumed_stake: BalanceOf::::zero(), + joined_at: joined_at_0, + uneligible_index: 1, + uneligible_stake: amount_1 - amount_0, + } + ); + + let amount_2 = MinJurorStake::get() * 3; + assert_ok!(Court::join_court(RuntimeOrigin::signed(BOB), amount_2)); + + let jurors = >::get(); + let pool_item_bob = + jurors.iter().find(|juror| juror.court_participant == BOB).unwrap().clone(); + + assert_eq!( + pool_item_bob, + CourtPoolItem { + stake: amount_2, + court_participant: BOB, + consumed_stake: BalanceOf::::zero(), + joined_at: joined_at_0, + uneligible_index: 1, + uneligible_stake: amount_2 - amount_0, + } + ); }); } @@ -3149,6 +3429,8 @@ fn handle_inflation_without_waiting_one_inflation_period() { court_participant: juror, consumed_stake: 0, joined_at, + uneligible_index: 0, + uneligible_stake: stake, }) .unwrap(); } diff --git a/zrml/court/src/types.rs b/zrml/court/src/types.rs index 5c297ef36..7e8aede81 100644 --- a/zrml/court/src/types.rs +++ b/zrml/court/src/types.rs @@ -1,4 +1,4 @@ -// Copyright 2022-2023 Forecasting Technologies LTD. +// Copyright 2022-2024 Forecasting Technologies LTD. // Copyright 2021-2022 Zeitgeist PM LLC. // // This file is part of Zeitgeist. @@ -283,8 +283,13 @@ pub struct CourtPoolItem { /// The consumed amount of the stake for all draws. This is useful to reduce the probability /// of a court participant to be selected again. pub consumed_stake: Balance, - /// The block number at which the participant joined. + /// The block number at which the participant initially joined. pub joined_at: BlockNumber, + /// The index of the inflation period in which the court participant increased the stake lastly. + /// The court participant can increase the stake by joining the court with a higher stake. + pub uneligible_index: BlockNumber, + /// The additional stake added in the inflation period of the uneligible index. + pub uneligible_stake: Balance, } /// The information about an internal selected draw of a juror or delegator.