diff --git a/primitives/src/outcome_report.rs b/primitives/src/outcome_report.rs index f261621a81..1a8decb8f6 100644 --- a/primitives/src/outcome_report.rs +++ b/primitives/src/outcome_report.rs @@ -1,7 +1,16 @@ use crate::types::CategoryIndex; /// The reported outcome of a market -#[derive(Clone, Debug, Eq, PartialEq, parity_scale_codec::Decode, parity_scale_codec::Encode)] +#[derive( + Clone, + Debug, + Eq, + Ord, + PartialEq, + PartialOrd, + parity_scale_codec::Decode, + parity_scale_codec::Encode, +)] pub enum OutcomeReport { Categorical(CategoryIndex), Scalar(u128), diff --git a/primitives/src/traits/dispute_api.rs b/primitives/src/traits/dispute_api.rs index bd09116853..69239b7761 100644 --- a/primitives/src/traits/dispute_api.rs +++ b/primitives/src/traits/dispute_api.rs @@ -12,8 +12,10 @@ pub trait DisputeApi { /// Disputes a reported outcome. fn on_dispute( + dispute_bound: Self::Balance, disputes: &[MarketDispute], - market_id: Self::MarketId, + market_id: &Self::MarketId, + who: &Self::AccountId, ) -> DispatchResult; /// Manages markets resolutions moving all reported markets to resolved. diff --git a/zrml/court/src/lib.rs b/zrml/court/src/lib.rs index f08ac487ce..893b526e32 100644 --- a/zrml/court/src/lib.rs +++ b/zrml/court/src/lib.rs @@ -16,12 +16,13 @@ pub use pallet::*; #[frame_support::pallet] mod pallet { use crate::{Juror, JurorStatus}; + use alloc::collections::BTreeMap; use arrayvec::ArrayVec; use core::marker::PhantomData; use frame_support::{ dispatch::DispatchResult, pallet_prelude::{StorageDoubleMap, StorageMap, StorageValue, ValueQuery}, - traits::{Currency, Get, Hooks, IsType, Randomness, ReservableCurrency}, + traits::{BalanceStatus, Currency, Get, Hooks, IsType, Randomness, ReservableCurrency}, Blake2_128Concat, }; use frame_system::{ensure_signed, pallet_prelude::OriginFor}; @@ -87,8 +88,8 @@ mod pallet { return Err(Error::::OnlyJurorsCanVote.into()); } Votes::::insert( - who, market_id, + who, (>::block_number(), outcome), ); Ok(()) @@ -114,6 +115,9 @@ mod pallet { /// Weight used to calculate the necessary staking amount to become a juror type StakeWeight: Get>; + + /// Slashed funds are send to the treasury + type TreasuryAccount: Get; } #[pallet::error] @@ -122,6 +126,8 @@ mod pallet { JurorAlreadyExists, /// An account id does not exist on the jurors storage. JurorDoesNotExists, + /// No-one voted on an outcome to resolve a market + NoVotes, /// Forbids voting of unknown accounts OnlyJurorsCanVote, } @@ -173,6 +179,13 @@ mod pallet { StdRng::from_seed(seed) } + pub(crate) fn set_juror_as_tardy(account_id: &T::AccountId) -> DispatchResult { + Self::mutate_juror(account_id, |juror| { + juror.status = JurorStatus::Tardy; + Ok(()) + }) + } + // No-one can stake more than BalanceOf::::max(), therefore, this function saturates // arithmetic operations. fn current_required_stake(jurors_num: usize) -> BalanceOf { @@ -180,6 +193,11 @@ mod pallet { T::StakeWeight::get().saturating_mul(jurors_len) } + // Retrieves a juror from the storage + fn juror(account_id: &T::AccountId) -> Result>, DispatchError> { + Jurors::::get(account_id).ok_or_else(|| Error::::JurorDoesNotExists.into()) + } + // Calculates the necessary number of jurors depending on the number of market disputes. // // Result is capped to `usize::MAX` or in other words, capped to a very, very, very @@ -190,8 +208,130 @@ mod pallet { } // Retrieves a juror from the storage - fn juror(account_id: &T::AccountId) -> Result>, DispatchError> { - Jurors::::get(account_id).ok_or_else(|| Error::::JurorDoesNotExists.into()) + fn mutate_juror(account_id: &T::AccountId, mut cb: F) -> DispatchResult + where + F: FnMut(&mut Juror>) -> DispatchResult, + { + Jurors::::try_mutate(account_id, |opt| { + if let Some(el) = opt { + cb(el)?; + } else { + return Err(Error::::JurorDoesNotExists.into()); + } + Ok(()) + }) + } + + // Slashes 20% of staked funds and removes them from the list of jurors + fn punish_tardy_jurors( + requested_jurors: &mut Vec<(T::AccountId, T::BlockNumber)>, + ) -> DispatchResult { + let mut i = 0; + while i < requested_jurors.len() { + let should_remove = { + // `get` will never panic + let (ai, _) = requested_jurors.get(i).unwrap(); + let juror = Self::juror(ai)?; + matches!(juror.status, JurorStatus::Tardy) + }; + if should_remove { + // `remove` will never panic + let (ai, _) = requested_jurors.remove(i); + let reserved = CurrencyOf::::reserved_balance(&ai); + // Division will never overflow + let slash = reserved / BalanceOf::::from(5u8); + CurrencyOf::::repatriate_reserved( + &ai, + &T::TreasuryAccount::get(), + slash, + BalanceStatus::Free, + )?; + Jurors::::remove(ai); + } else { + // `i` will never overflow + i += 1; + } + } + Ok(()) + } + + // Jurors are only rewarded if sided on the most voted outcome but jurors that voted + // second most voted outcome (winner of the losing majority) are placed as tardy instead + // of being slashed + fn set_jurors_that_sided_on_the_second_most_voted_outcome_as_tardy( + second_most_voted_outcome: &Option, + votes: &[(T::AccountId, (T::BlockNumber, OutcomeReport))], + ) -> DispatchResult { + if let Some(el) = second_most_voted_outcome { + for (ai, (_, outcome_report)) in votes { + if outcome_report == el { + Self::set_juror_as_tardy(ai)?; + } + } + } + Ok(()) + } + + // Jurors that didn't vote within `CourtCaseDuration` or didn't vote at all. + fn set_late_jurors_as_tardy( + requested_jurors: &[(T::AccountId, T::BlockNumber)], + votes: &[(T::AccountId, (T::BlockNumber, OutcomeReport))], + ) -> DispatchResult { + for (ai, max_block) in requested_jurors { + if let Some((_, (block, _))) = votes.iter().find(|el| &el.0 == ai) { + if block > max_block { + Self::set_juror_as_tardy(ai)?; + } + } else { + Self::set_juror_as_tardy(ai)?; + } + } + Ok(()) + } + + // For market resolution based on the votes of a market + fn two_best_outcomes( + votes: &[(T::AccountId, (T::BlockNumber, OutcomeReport))], + ) -> Result<(OutcomeReport, Option), DispatchError> { + let mut scores = BTreeMap::::new(); + for (_, (_, outcome_report)) in votes { + if let Some(el) = scores.get_mut(outcome_report) { + *el = el.saturating_add(1); + } else { + scores.insert(outcome_report.clone(), 1); + } + } + + let mut best_score; + let mut iter = scores.iter(); + if let Some(el) = iter.next() { + best_score = el; + } else { + return Err(Error::::NoVotes.into()); + } + for el in iter { + if el.1 > best_score.1 { + best_score = el; + } + } + let best_outcome = best_score.0.clone(); + + let _ = scores.remove(&best_outcome); + + let mut iter = scores.iter(); + let mut second_best_score_opt = None; + if let Some(el) = iter.next() { + let mut second_best_score = el; + for el in iter { + if el.1 > second_best_score.1 { + second_best_score = el; + } + } + second_best_score_opt = Some(second_best_score); + } + let second_best_outcome = second_best_score_opt.map(|el| el.0.clone()); + + Ok((best_outcome, second_best_outcome)) } } @@ -206,9 +346,12 @@ mod pallet { type MarketId = MarketIdOf; fn on_dispute( + dispute_bound: Self::Balance, disputes: &[MarketDispute], - market_id: Self::MarketId, + market_id: &Self::MarketId, + who: &Self::AccountId, ) -> DispatchResult { + CurrencyOf::::reserve(who, dispute_bound)?; let jurors: Vec<_> = Jurors::::iter().collect(); let necessary_jurors_num = Self::necessary_jurors_num(disputes); let mut rng = Self::rng(); @@ -216,7 +359,7 @@ mod pallet { let curr_block_num = >::block_number(); let block_limit = curr_block_num.saturating_add(T::CourtCaseDuration::get()); for (ai, _) in random_jurors { - RequestedJurors::::insert(ai, market_id, block_limit); + RequestedJurors::::insert(market_id, ai, block_limit); } Ok(()) } @@ -224,13 +367,22 @@ mod pallet { fn on_resolution( _: &D, _: &[MarketDispute], - _: &Self::MarketId, + market_id: &Self::MarketId, _: &Market, ) -> Result where D: Fn(usize) -> Self::Balance, { - Ok(OutcomeReport::Scalar(Default::default())) + let mut requested_jurors: Vec<_> = + RequestedJurors::::iter_prefix(market_id).collect(); + let votes: Vec<_> = Votes::::iter_prefix(market_id).collect(); + Self::punish_tardy_jurors(&mut requested_jurors)?; + let (first, second) = Self::two_best_outcomes(&votes)?; + Self::set_jurors_that_sided_on_the_second_most_voted_outcome_as_tardy(&second, &votes)?; + Self::set_late_jurors_as_tardy(&requested_jurors, &votes)?; + Votes::::remove_prefix(market_id, None); + RequestedJurors::::remove_prefix(market_id, None); + Ok(first) } } @@ -247,9 +399,9 @@ mod pallet { pub type RequestedJurors = StorageDoubleMap< _, Blake2_128Concat, - T::AccountId, - Blake2_128Concat, MarketIdOf, + Blake2_128Concat, + T::AccountId, T::BlockNumber, >; @@ -258,9 +410,9 @@ mod pallet { pub type Votes = StorageDoubleMap< _, Blake2_128Concat, - T::AccountId, - Blake2_128Concat, MarketIdOf, + Blake2_128Concat, + T::AccountId, (T::BlockNumber, OutcomeReport), >; } diff --git a/zrml/court/src/mock.rs b/zrml/court/src/mock.rs index 1eb1da0b44..27e89fd5d9 100644 --- a/zrml/court/src/mock.rs +++ b/zrml/court/src/mock.rs @@ -16,12 +16,15 @@ use zeitgeist_primitives::{ pub const ALICE: AccountIdTest = 0; pub const BOB: AccountIdTest = 1; +pub const CHARLIE: AccountIdTest = 2; +pub const TREASURY: AccountIdTest = 99; type Block = BlockTest; type UncheckedExtrinsic = UncheckedExtrinsicTest; parameter_types! { pub const LmPalletId: PalletId = PalletId(*b"test/lmg"); + pub const TreasuryAccount: u128 = TREASURY; } construct_runtime!( @@ -45,6 +48,7 @@ impl crate::Config for Runtime { type MarketCommons = MarketCommons; type Random = RandomnessCollectiveFlip; type StakeWeight = StakeWeight; + type TreasuryAccount = TreasuryAccount; } impl frame_system::Config for Runtime { @@ -98,7 +102,14 @@ pub struct ExtBuilder { impl Default for ExtBuilder { fn default() -> Self { - Self { balances: vec![(ALICE, 1_000 * BASE), (BOB, 1_000 * BASE)] } + Self { + balances: vec![ + (ALICE, 1_000 * BASE), + (BOB, 1_000 * BASE), + (CHARLIE, 1_000 * BASE), + (TREASURY, 1_000 * BASE), + ], + } } } diff --git a/zrml/court/src/tests.rs b/zrml/court/src/tests.rs index 0b6a303761..94971f36e5 100644 --- a/zrml/court/src/tests.rs +++ b/zrml/court/src/tests.rs @@ -3,14 +3,31 @@ use crate::{ mock::{ Balances, Court, ExtBuilder, Origin, RandomnessCollectiveFlip, Runtime, System, ALICE, BOB, + CHARLIE, TREASURY, }, Error, Juror, JurorStatus, Jurors, RequestedJurors, Votes, }; use core::ops::Range; use frame_support::{assert_noop, assert_ok, traits::Hooks}; use sp_runtime::traits::Header; -use zeitgeist_primitives::{constants::BASE, traits::DisputeApi, types::OutcomeReport}; +use zeitgeist_primitives::{ + constants::BASE, + traits::DisputeApi, + types::{Market, MarketCreation, MarketEnd, MarketStatus, MarketType, OutcomeReport}, +}; +const DEFAULT_MARKET: Market = Market { + creation: MarketCreation::Permissionless, + creator_fee: 0, + creator: 0, + end: MarketEnd::Block(0), + market_type: MarketType::Scalar((0, 100)), + metadata: vec![], + oracle: 0, + report: None, + resolved_outcome: None, + status: MarketStatus::Closed, +}; const DEFAULT_SET_OF_JURORS: &[(u128, Juror)] = &[ (7, Juror { staked: 1, status: JurorStatus::Ok }), (6, Juror { staked: 2, status: JurorStatus::Tardy }), @@ -84,7 +101,7 @@ fn on_dispute_stores_jurors_that_should_vote() { setup_blocks(1..123); let _ = Court::join_court(Origin::signed(ALICE)); let _ = Court::join_court(Origin::signed(BOB)); - let _ = Court::on_dispute(&[], 0); + Court::on_dispute(BASE, &[], &0, &ALICE).unwrap(); assert_noop!( Court::join_court(Origin::signed(ALICE)), Error::::JurorAlreadyExists @@ -93,6 +110,70 @@ fn on_dispute_stores_jurors_that_should_vote() { }); } +#[test] +fn on_resolution_decides_market_outcome_based_on_the_majority() { + ExtBuilder::default().build().execute_with(|| { + setup_blocks(1..2); + Court::join_court(Origin::signed(ALICE)).unwrap(); + Court::join_court(Origin::signed(BOB)).unwrap(); + Court::join_court(Origin::signed(CHARLIE)).unwrap(); + Court::on_dispute(BASE, &[], &0, &ALICE).unwrap(); + Court::vote(Origin::signed(ALICE), 0, OutcomeReport::Scalar(1)).unwrap(); + Court::vote(Origin::signed(BOB), 0, OutcomeReport::Scalar(1)).unwrap(); + Court::vote(Origin::signed(CHARLIE), 0, OutcomeReport::Scalar(2)).unwrap(); + let outcome = Court::on_resolution(&|_| 0, &[], &0, &DEFAULT_MARKET).unwrap(); + assert_eq!(outcome, OutcomeReport::Scalar(1)) + }); +} + +#[test] +fn on_resolution_sets_late_jurors_as_tardy() { + ExtBuilder::default().build().execute_with(|| { + setup_blocks(1..2); + Court::join_court(Origin::signed(ALICE)).unwrap(); + Court::join_court(Origin::signed(BOB)).unwrap(); + Court::vote(Origin::signed(ALICE), 0, OutcomeReport::Scalar(1)).unwrap(); + Court::on_dispute(BASE, &[], &0, &ALICE).unwrap(); + let _ = Court::on_resolution(&|_| 0, &[], &0, &DEFAULT_MARKET).unwrap(); + assert_eq!(Jurors::::get(ALICE).unwrap().status, JurorStatus::Ok); + assert_eq!(Jurors::::get(BOB).unwrap().status, JurorStatus::Tardy); + }); +} + +#[test] +fn on_resolution_punishes_tardy_jurors() { + ExtBuilder::default().build().execute_with(|| { + setup_blocks(1..2); + Court::join_court(Origin::signed(ALICE)).unwrap(); + Court::join_court(Origin::signed(BOB)).unwrap(); + Court::set_juror_as_tardy(&BOB).unwrap(); + Court::vote(Origin::signed(ALICE), 0, OutcomeReport::Scalar(1)).unwrap(); + Court::on_dispute(BASE, &[], &0, &ALICE).unwrap(); + let _ = Court::on_resolution(&|_| 0, &[], &0, &DEFAULT_MARKET).unwrap(); + let slash = 8000000000; + assert_eq!(Balances::free_balance(TREASURY), 1_000 * BASE + slash); + let join_court_reserved = 40000000000; + assert_eq!(Balances::reserved_balance(BOB), join_court_reserved - slash); + }); +} + +#[test] +fn on_resolution_removes_requested_jurors_and_votes() { + ExtBuilder::default().build().execute_with(|| { + setup_blocks(1..2); + Court::join_court(Origin::signed(ALICE)).unwrap(); + Court::join_court(Origin::signed(BOB)).unwrap(); + Court::join_court(Origin::signed(CHARLIE)).unwrap(); + Court::on_dispute(BASE, &[], &0, &ALICE).unwrap(); + Court::vote(Origin::signed(ALICE), 0, OutcomeReport::Scalar(1)).unwrap(); + Court::vote(Origin::signed(BOB), 0, OutcomeReport::Scalar(1)).unwrap(); + Court::vote(Origin::signed(CHARLIE), 0, OutcomeReport::Scalar(2)).unwrap(); + let _ = Court::on_resolution(&|_| 0, &[], &0, &DEFAULT_MARKET).unwrap(); + assert_eq!(RequestedJurors::::iter().count(), 0); + assert_eq!(Votes::::iter().count(), 0); + }); +} + #[test] fn random_jurors_returns_an_unique_different_subset_of_jurors() { ExtBuilder::default().build().execute_with(|| { @@ -115,7 +196,6 @@ fn random_jurors_returns_an_unique_different_subset_of_jurors() { if let Some(juror) = iter.next() { at_least_one_set_is_different = random_jurors.iter().all(|el| el != juror); } else { - at_least_one_set_is_different = false; continue; } for juror in iter { @@ -126,6 +206,7 @@ fn random_jurors_returns_an_unique_different_subset_of_jurors() { break; } } + assert_eq!(at_least_one_set_is_different, true); }); } diff --git a/zrml/prediction-markets/fuzz/pm_full_workflow.rs b/zrml/prediction-markets/fuzz/pm_full_workflow.rs index 5e568070b1..42c2ed80d0 100644 --- a/zrml/prediction-markets/fuzz/pm_full_workflow.rs +++ b/zrml/prediction-markets/fuzz/pm_full_workflow.rs @@ -48,8 +48,10 @@ fuzz_target!(|data: Data| { let dispute_market_id = data.dispute_market_id.into(); let _ = SimpleDisputes::on_dispute( + data.dispute_bound.into(), &zrml_prediction_markets::Disputes::::get(&dispute_market_id), - dispute_market_id, + &dispute_market_id, + &data.dispute_origin.into(), ); let _ = PredictionMarkets::on_initialize(5); @@ -82,6 +84,7 @@ struct Data { report_market_id: u8, report_outcome: u128, + dispute_bound: u8, dispute_origin: u8, dispute_market_id: u8, dispute_outcome: u128, diff --git a/zrml/prediction-markets/src/benchmarks.rs b/zrml/prediction-markets/src/benchmarks.rs index 18bc0ce675..419ffbd08d 100644 --- a/zrml/prediction-markets/src/benchmarks.rs +++ b/zrml/prediction-markets/src/benchmarks.rs @@ -191,7 +191,7 @@ benchmarks! { for i in 0..c.min(T::MaxDisputes::get() as u32) { let origin = caller.clone(); let disputes = crate::Disputes::::get(&marketid); - let _ = T::SimpleDisputes::on_dispute(&disputes, marketid)?; + let _ = T::SimpleDisputes::on_dispute(Default::default(), &disputes, &marketid, &origin)?; } let approval_origin = T::ApprovalOrigin::successful_origin(); @@ -295,7 +295,7 @@ benchmarks! { }: { let origin = caller.clone(); let disputes = crate::Disputes::::get(&marketid); - let _ = T::SimpleDisputes::on_dispute(&disputes, marketid)?; + let _ = T::SimpleDisputes::on_dispute(Default::default(), &disputes, &marketid, &origin)?; } internal_resolve_categorical_reported { @@ -330,7 +330,7 @@ benchmarks! { for i in 0..c.min(d) { let origin = caller.clone(); let disputes = crate::Disputes::::get(&marketid); - let _ = T::SimpleDisputes::on_dispute(&disputes, marketid)?; + let _ = T::SimpleDisputes::on_dispute(Default::default(), &disputes, &marketid, &origin)?; } }: { let market = T::MarketCommons::market(&marketid)?; @@ -358,7 +358,7 @@ benchmarks! { for i in 0..d { let disputes = crate::Disputes::::get(&marketid); let origin = caller.clone(); - let _ = T::SimpleDisputes::on_dispute(&disputes, marketid)?; + let _ = T::SimpleDisputes::on_dispute(Default::default(), &disputes, &marketid, &origin)?; } }: { let market = T::MarketCommons::market(&marketid)?; diff --git a/zrml/prediction-markets/src/lib.rs b/zrml/prediction-markets/src/lib.rs index 16d2e55bf8..1542b6139c 100644 --- a/zrml/prediction-markets/src/lib.rs +++ b/zrml/prediction-markets/src/lib.rs @@ -291,8 +291,12 @@ mod pallet { let num_disputes: u32 = disputes.len().saturated_into(); let outcome_clone = outcome.clone(); Self::validate_dispute(&disputes, &market, num_disputes, &outcome)?; - CurrencyOf::::reserve(&who, default_dispute_bound::(disputes.len()))?; - T::SimpleDisputes::on_dispute(&disputes, market_id)?; + T::SimpleDisputes::on_dispute( + default_dispute_bound::(disputes.len()), + &disputes, + &market_id, + &who, + )?; Self::remove_last_dispute_from_market_ids_per_dispute_block(&disputes, &market_id)?; Self::set_market_as_disputed(&market, &market_id)?; >::mutate(market_id, |disputes| { diff --git a/zrml/simple-disputes/src/lib.rs b/zrml/simple-disputes/src/lib.rs index c96f9ccb77..edbc6433b5 100644 --- a/zrml/simple-disputes/src/lib.rs +++ b/zrml/simple-disputes/src/lib.rs @@ -106,9 +106,12 @@ mod pallet { type MarketId = MarketIdOf; fn on_dispute( + dispute_bound: Self::Balance, _: &[MarketDispute], - _: Self::MarketId, + _: &Self::MarketId, + who: &Self::AccountId, ) -> DispatchResult { + CurrencyOf::::reserve(who, dispute_bound)?; Ok(()) }