diff --git a/bin/node/runtime/Cargo.toml b/bin/node/runtime/Cargo.toml index 3844fa336fd69..f5c4d5855bbbe 100644 --- a/bin/node/runtime/Cargo.toml +++ b/bin/node/runtime/Cargo.toml @@ -50,7 +50,7 @@ nicks = { package = "pallet-nicks", path = "../../../frame/nicks", default-featu offences = { package = "pallet-offences", path = "../../../frame/offences", default-features = false } randomness-collective-flip = { package = "pallet-randomness-collective-flip", path = "../../../frame/randomness-collective-flip", default-features = false } session = { package = "pallet-session", path = "../../../frame/session", default-features = false, features = ["historical"] } -staking = { package = "pallet-staking", path = "../../../frame/staking", default-features = false } +staking = { package = "pallet-staking", path = "../../../frame/staking", default-features = false, features = ["migrate"] } pallet-staking-reward-curve = { path = "../../../frame/staking/reward-curve"} sudo = { package = "pallet-sudo", path = "../../../frame/sudo", default-features = false } support = { package = "frame-support", path = "../../../frame/support", default-features = false } diff --git a/bin/node/runtime/src/lib.rs b/bin/node/runtime/src/lib.rs index 8e405739167ff..6fb9406e569d1 100644 --- a/bin/node/runtime/src/lib.rs +++ b/bin/node/runtime/src/lib.rs @@ -250,6 +250,7 @@ pallet_staking_reward_curve::build! { parameter_types! { pub const SessionsPerEra: sr_staking_primitives::SessionIndex = 6; pub const BondingDuration: staking::EraIndex = 24 * 28; + pub const SlashDeferDuration: staking::EraIndex = 24 * 7; // 1/4 the bonding duration. pub const RewardCurve: &'static PiecewiseLinear<'static> = &REWARD_CURVE; } @@ -263,6 +264,9 @@ impl staking::Trait for Runtime { type Reward = (); // rewards are minted from the void type SessionsPerEra = SessionsPerEra; type BondingDuration = BondingDuration; + type SlashDeferDuration = SlashDeferDuration; + /// A super-majority of the council can cancel the slash. + type SlashCancelOrigin = collective::EnsureProportionAtLeast<_3, _4, AccountId, CouncilCollective>; type SessionInterface = Self; type RewardCurve = RewardCurve; } diff --git a/frame/balances/src/lib.rs b/frame/balances/src/lib.rs index fd6ec99c0779e..cee4f9dbe9361 100644 --- a/frame/balances/src/lib.rs +++ b/frame/balances/src/lib.rs @@ -986,6 +986,7 @@ where ) -> (Self::NegativeImbalance, Self::Balance) { let free_balance = Self::free_balance(who); let free_slash = cmp::min(free_balance, value); + Self::set_free_balance(who, free_balance - free_slash); let remaining_slash = value - free_slash; // NOTE: `slash()` prefers free balance, but assumes that reserve balance can be drawn diff --git a/frame/offences/src/lib.rs b/frame/offences/src/lib.rs index a6cf4d7956467..ce9a1a0a41bae 100644 --- a/frame/offences/src/lib.rs +++ b/frame/offences/src/lib.rs @@ -24,17 +24,11 @@ mod mock; mod tests; -use rstd::{ - vec::Vec, - collections::btree_set::BTreeSet, -}; +use rstd::vec::Vec; use support::{ decl_module, decl_event, decl_storage, Parameter, }; -use sr_primitives::{ - Perbill, - traits::{Hash, Saturating}, -}; +use sr_primitives::traits::Hash; use sr_staking_primitives::{ offence::{Offence, ReportOffence, Kind, OnOffenceHandler, OffenceDetails}, }; @@ -100,10 +94,11 @@ where // Go through all offenders in the offence report and find all offenders that was spotted // in unique reports. - let TriageOutcome { - new_offenders, - concurrent_offenders, - } = match Self::triage_offence_report::(reporters, &time_slot, offenders) { + let TriageOutcome { concurrent_offenders } = match Self::triage_offence_report::( + reporters, + &time_slot, + offenders, + ) { Some(triage) => triage, // The report contained only duplicates, so there is no need to slash again. None => return, @@ -113,44 +108,18 @@ where Self::deposit_event(Event::Offence(O::ID, time_slot.encode())); let offenders_count = concurrent_offenders.len() as u32; - let previous_offenders_count = offenders_count - new_offenders.len() as u32; // The amount new offenders are slashed let new_fraction = O::slash_fraction(offenders_count, validator_set_count); - // The amount previous offenders are slashed additionally. - // - // Since they were slashed in the past, we slash by: - // x = (new - prev) / (1 - prev) - // because: - // Y = X * (1 - prev) - // Z = Y * (1 - x) - // Z = X * (1 - new) - let old_fraction = if previous_offenders_count > 0 { - let previous_fraction = O::slash_fraction( - offenders_count.saturating_sub(previous_offenders_count), - validator_set_count, - ); - let numerator = new_fraction.saturating_sub(previous_fraction); - let denominator = Perbill::one().saturating_sub(previous_fraction); - denominator.saturating_mul(numerator) - } else { - new_fraction.clone() - }; + let slash_perbill: Vec<_> = (0..concurrent_offenders.len()) + .map(|_| new_fraction.clone()).collect(); - // calculate how much to slash - let slash_perbill = concurrent_offenders - .iter() - .map(|details| { - if previous_offenders_count > 0 && new_offenders.contains(&details.offender) { - new_fraction.clone() - } else { - old_fraction.clone() - } - }) - .collect::>(); - - T::OnOffenceHandler::on_offence(&concurrent_offenders, &slash_perbill); + T::OnOffenceHandler::on_offence( + &concurrent_offenders, + &slash_perbill, + offence.session_index(), + ); } } @@ -173,13 +142,13 @@ impl Module { offenders: Vec, ) -> Option> { let mut storage = ReportIndexStorage::::load(time_slot); - let mut new_offenders = BTreeSet::new(); + let mut any_new = false; for offender in offenders { let report_id = Self::report_id::(time_slot, &offender); if !>::exists(&report_id) { - new_offenders.insert(offender.clone()); + any_new = true; >::insert( &report_id, OffenceDetails { @@ -192,7 +161,7 @@ impl Module { } } - if !new_offenders.is_empty() { + if any_new { // Load report details for the all reports happened at the same time. let concurrent_offenders = storage.concurrent_reports .iter() @@ -202,7 +171,6 @@ impl Module { storage.save(); Some(TriageOutcome { - new_offenders, concurrent_offenders, }) } else { @@ -212,8 +180,6 @@ impl Module { } struct TriageOutcome { - /// Offenders that was spotted in the unique reports. - new_offenders: BTreeSet, /// Other reports for the same report kinds. concurrent_offenders: Vec>, } diff --git a/frame/offences/src/mock.rs b/frame/offences/src/mock.rs index e8e4c864d1f7a..491c9681b1cd5 100644 --- a/frame/offences/src/mock.rs +++ b/frame/offences/src/mock.rs @@ -46,6 +46,7 @@ impl offence::OnOffenceHandler for OnOff fn on_offence( _offenders: &[OffenceDetails], slash_fraction: &[Perbill], + _offence_session: SessionIndex, ) { ON_OFFENCE_PERBILL.with(|f| { *f.borrow_mut() = slash_fraction.to_vec(); @@ -148,9 +149,7 @@ impl offence::Offence for Offence { } fn session_index(&self) -> SessionIndex { - // session index is not used by the pallet-offences directly, but rather it exists only for - // filtering historical reports. - unimplemented!() + 1 } fn slash_fraction( diff --git a/frame/offences/src/tests.rs b/frame/offences/src/tests.rs index 28e655d16bfdd..aa71d1d6206a7 100644 --- a/frame/offences/src/tests.rs +++ b/frame/offences/src/tests.rs @@ -23,6 +23,7 @@ use crate::mock::{ Offences, System, Offence, TestEvent, KIND, new_test_ext, with_on_offence_fractions, offence_reports, }; +use sr_primitives::Perbill; use system::{EventRecord, Phase}; #[test] @@ -48,38 +49,6 @@ fn should_report_an_authority_and_trigger_on_offence() { }); } -#[test] -fn should_calculate_the_fraction_correctly() { - new_test_ext().execute_with(|| { - // given - let time_slot = 42; - assert_eq!(offence_reports(KIND, time_slot), vec![]); - let offence1 = Offence { - validator_set_count: 5, - time_slot, - offenders: vec![5], - }; - let offence2 = Offence { - validator_set_count: 5, - time_slot, - offenders: vec![4], - }; - - // when - Offences::report_offence(vec![], offence1); - with_on_offence_fractions(|f| { - assert_eq!(f.clone(), vec![Perbill::from_percent(25)]); - }); - - Offences::report_offence(vec![], offence2); - - // then - with_on_offence_fractions(|f| { - assert_eq!(f.clone(), vec![Perbill::from_percent(15), Perbill::from_percent(45)]); - }); - }); -} - #[test] fn should_not_report_the_same_authority_twice_in_the_same_slot() { new_test_ext().execute_with(|| { diff --git a/frame/staking/Cargo.toml b/frame/staking/Cargo.toml index 005b45da764bf..110c3d868c223 100644 --- a/frame/staking/Cargo.toml +++ b/frame/staking/Cargo.toml @@ -28,6 +28,7 @@ substrate-test-utils = { path = "../../test/utils" } [features] equalize = [] +migrate = [] default = ["std", "equalize"] std = [ "serde", diff --git a/frame/staking/src/lib.rs b/frame/staking/src/lib.rs index ae9e53c851032..aeeda48593b9b 100644 --- a/frame/staking/src/lib.rs +++ b/frame/staking/src/lib.rs @@ -108,6 +108,8 @@ //! determined, a value is deducted from the balance of the validator and all the nominators who //! voted for this validator (values are deducted from the _stash_ account of the slashed entity). //! +//! Slashing logic is further described in the documentation of the `slashing` module. +//! //! Similar to slashing, rewards are also shared among a validator and its associated nominators. //! Yet, the reward funds are not always transferred to the stash account and can be configured. //! See [Reward Calculation](#reward-calculation) for more details. @@ -248,6 +250,8 @@ mod mock; #[cfg(test)] mod tests; +mod migration; +mod slashing; pub mod inflation; @@ -268,6 +272,7 @@ use sr_primitives::{ curve::PiecewiseLinear, traits::{ Convert, Zero, One, StaticLookup, CheckedSub, Saturating, Bounded, SaturatedConversion, + SimpleArithmetic, EnsureOrigin, } }; use sr_staking_primitives::{ @@ -278,7 +283,7 @@ use sr_staking_primitives::{ use sr_primitives::{Serialize, Deserialize}; use system::{ensure_signed, ensure_root}; -use phragmen::{elect, equalize, build_support_map, ExtendedBalance, PhragmenStakedAssignment}; +use phragmen::{ExtendedBalance, PhragmenStakedAssignment}; const DEFAULT_MINIMUM_VALIDATOR_COUNT: u32 = 4; const MAX_NOMINATIONS: usize = 16; @@ -406,6 +411,74 @@ impl< .collect(); Self { total, active: self.active, stash: self.stash, unlocking } } + +} + +impl StakingLedger where + Balance: SimpleArithmetic + Saturating + Copy, +{ + /// Slash the validator for a given amount of balance. This can grow the value + /// of the slash in the case that the validator has less than `minimum_balance` + /// active funds. Returns the amount of funds actually slashed. + /// + /// Slashes from `active` funds first, and then `unlocking`, starting with the + /// chunks that are closest to unlocking. + fn slash( + &mut self, + mut value: Balance, + minimum_balance: Balance, + ) -> Balance { + let pre_total = self.total; + let total = &mut self.total; + let active = &mut self.active; + + let slash_out_of = | + total_remaining: &mut Balance, + target: &mut Balance, + value: &mut Balance, + | { + let mut slash_from_target = (*value).min(*target); + + if !slash_from_target.is_zero() { + *target -= slash_from_target; + + // don't leave a dust balance in the staking system. + if *target <= minimum_balance { + slash_from_target += *target; + *value += rstd::mem::replace(target, Zero::zero()); + } + + *total_remaining = total_remaining.saturating_sub(slash_from_target); + *value -= slash_from_target; + } + }; + + slash_out_of(total, active, &mut value); + + let i = self.unlocking.iter_mut() + .map(|chunk| { + slash_out_of(total, &mut chunk.value, &mut value); + chunk.value + }) + .take_while(|value| value.is_zero()) // take all fully-consumed chunks out. + .count(); + + // kill all drained chunks. + let _ = self.unlocking.drain(..i); + + pre_total.saturating_sub(*total) + } +} + +/// A record of the nominations made by a specific account. +#[derive(PartialEq, Eq, Clone, Encode, Decode, RuntimeDebug)] +pub struct Nominations { + /// The targets of nomination. + pub targets: Vec, + /// The era the nominations were submitted. + pub submitted_in: EraIndex, + /// Whether the nominations have been suppressed. + pub suppressed: bool, } /// The amount of exposure (to slashing) than an individual nominator has. @@ -431,12 +504,20 @@ pub struct Exposure { pub others: Vec>, } -/// A slashing event occurred, slashing a validator for a given amount of balance. -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Encode, Decode, Default, RuntimeDebug)] -pub struct SlashJournalEntry { - who: AccountId, - amount: Balance, - own_slash: Balance, // the amount of `who`'s own exposure that was slashed +/// A pending slash record. The value of the slash has been computed but not applied yet, +/// rather deferred for several eras. +#[derive(Encode, Decode, Default, RuntimeDebug)] +pub struct UnappliedSlash { + /// The stash ID of the offending validator. + validator: AccountId, + /// The validator's own slash. + own: Balance, + /// All other slashed stakers and amounts. + others: Vec<(AccountId, Balance)>, + /// Reporters of the offence; bounty payout recipients. + reporters: Vec, + /// The amount of payout. + payout: Balance, } pub type BalanceOf = @@ -519,6 +600,14 @@ pub trait Trait: system::Trait { /// Number of eras that staked funds must remain bonded for. type BondingDuration: Get; + /// Number of eras that slashes are deferred by, after computation. This + /// should be less than the bonding duration. Set to 0 if slashes should be + /// applied immediately, without opportunity for intervention. + type SlashDeferDuration: Get; + + /// The origin which can cancel a deferred slash. Root can always do this. + type SlashCancelOrigin: EnsureOrigin; + /// Interface for interacting with a session module. type SessionInterface: self::SessionInterface; @@ -571,7 +660,10 @@ decl_storage! { pub Validators get(fn validators): linked_map T::AccountId => ValidatorPrefs>; /// The map from nominator stash key to the set of stash keys of all validators to nominate. - pub Nominators get(fn nominators): linked_map T::AccountId => Vec; + /// + /// NOTE: is private so that we can ensure upgraded before all typical accesses. + /// Direct storage APIs can still bypass this protection. + Nominators get(fn nominators): linked_map T::AccountId => Option>; /// Nominators for a particular account that is in action right now. You can't iterate /// through validators here, but you can find them in the Session module. @@ -609,12 +701,38 @@ decl_storage! { /// The rest of the slashed value is handled by the `Slash`. pub SlashRewardFraction get(fn slash_reward_fraction) config(): Perbill; + /// The amount of currency given to reporters of a slash event which was + /// canceled by extraordinary circumstances (e.g. governance). + pub CanceledSlashPayout get(fn canceled_payout) config(): BalanceOf; + + /// All unapplied slashes that are queued for later. + pub UnappliedSlashes: map EraIndex => Vec>>; + /// A mapping from still-bonded eras to the first session index of that era. BondedEras: Vec<(EraIndex, SessionIndex)>; - /// All slashes that have occurred in a given era. - EraSlashJournal get(fn era_slash_journal): - map EraIndex => Vec>>; + /// All slashing events on validators, mapped by era to the highest slash proportion + /// and slash value of the era. + ValidatorSlashInEra: + double_map EraIndex, twox_128(T::AccountId) => Option<(Perbill, BalanceOf)>; + + /// All slashing events on nominators, mapped by era to the highest slash value of the era. + NominatorSlashInEra: + double_map EraIndex, twox_128(T::AccountId) => Option>; + + /// Slashing spans for stash accounts. + SlashingSpans: map T::AccountId => Option; + + /// Records information about the maximum slash of a stash within a slashing span, + /// as well as how much reward has been paid out. + SpanSlash: + map (T::AccountId, slashing::SpanIndex) => slashing::SpanRecord>; + + /// The earliest era for which we have a pending, unapplied slash. + EarliestUnappliedSlash: Option; + + /// The version of storage for upgrade. + StorageVersion: u32; } add_extra_genesis { config(stakers): @@ -646,6 +764,8 @@ decl_storage! { }, _ => Ok(()) }; } + + StorageVersion::put(migration::CURRENT_VERSION); }); } } @@ -673,6 +793,10 @@ decl_module! { fn deposit_event() = default; + fn on_initialize() { + Self::ensure_storage_upgraded(); + } + fn on_finalize() { // Set the start of the first era. if !>::exists() { @@ -859,6 +983,8 @@ decl_module! { /// # #[weight = SimpleDispatchInfo::FixedNormal(750_000)] fn validate(origin, prefs: ValidatorPrefs>) { + Self::ensure_storage_upgraded(); + let controller = ensure_signed(origin)?; let ledger = Self::ledger(&controller).ok_or("not a controller")?; let stash = &ledger.stash; @@ -879,6 +1005,8 @@ decl_module! { /// # #[weight = SimpleDispatchInfo::FixedNormal(750_000)] fn nominate(origin, targets: Vec<::Source>) { + Self::ensure_storage_upgraded(); + let controller = ensure_signed(origin)?; let ledger = Self::ledger(&controller).ok_or("not a controller")?; let stash = &ledger.stash; @@ -888,8 +1016,14 @@ decl_module! { .map(|t| T::Lookup::lookup(t)) .collect::, _>>()?; + let nominations = Nominations { + targets, + submitted_in: Self::current_era(), + suppressed: false, + }; + >::remove(stash); - >::insert(stash, targets); + >::insert(stash, &nominations); } /// Declare no desire to either validate or nominate. @@ -907,9 +1041,7 @@ decl_module! { fn chill(origin) { let controller = ensure_signed(origin)?; let ledger = Self::ledger(&controller).ok_or("not a controller")?; - let stash = &ledger.stash; - >::remove(stash); - >::remove(stash); + Self::chill_stash(&ledger.stash); } /// (Re-)set the payment target for a controller. @@ -1018,14 +1150,48 @@ decl_module! { ensure_root(origin)?; ForceEra::put(Forcing::ForceAlways); } + + /// Cancel enactment of a deferred slash. Can be called by either the root origin or + /// the `T::SlashCancelOrigin`. + /// passing the era and indices of the slashes for that era to kill. + /// + /// # + /// - One storage write. + /// # + #[weight = SimpleDispatchInfo::FreeOperational] + fn cancel_deferred_slash(origin, era: EraIndex, slash_indices: Vec) { + T::SlashCancelOrigin::try_origin(origin) + .map(|_| ()) + .or_else(ensure_root) + .map_err(|_| "bad origin")?; + + let mut slash_indices = slash_indices; + slash_indices.sort_unstable(); + let mut unapplied = ::UnappliedSlashes::get(&era); + + for (removed, index) in slash_indices.into_iter().enumerate() { + let index = index as usize; + + // if `index` is not duplicate, `removed` must be <= index. + ensure!(removed <= index, "duplicate index"); + + // all prior removals were from before this index, since the + // list is sorted. + let index = index - removed; + ensure!(index < unapplied.len(), "slash record index out of bounds"); + + unapplied.remove(index); + } + + ::UnappliedSlashes::insert(&era, &unapplied); + } } } impl Module { // PUBLIC IMMUTABLES - /// The total balance that can be slashed from a validator controller account as of - /// right now. + /// The total balance that can be slashed from a stash account as of right now. pub fn slashable_balance_of(stash: &T::AccountId) -> BalanceOf { Self::bonded(stash).and_then(Self::ledger).map(|l| l.active).unwrap_or_default() } @@ -1048,67 +1214,15 @@ impl Module { >::insert(controller, ledger); } - /// Slash a given validator by a specific amount with given (historical) exposure. - /// - /// Removes the slash from the validator's balance by preference, - /// and reduces the nominators' balance if needed. - /// - /// Returns the resulting `NegativeImbalance` to allow distributing the slashed amount and - /// pushes an entry onto the slash journal. - fn slash_validator( - stash: &T::AccountId, - slash: BalanceOf, - exposure: &Exposure>, - journal: &mut Vec>>, - ) -> NegativeImbalanceOf { - // The amount we are actually going to slash (can't be bigger than the validator's total - // exposure) - let slash = slash.min(exposure.total); - - // limit what we'll slash of the stash's own to only what's in - // the exposure. - // - // note: this is fine only because we limit reports of the current era. - // otherwise, these funds may have already been slashed due to something - // reported from a prior era. - let already_slashed_own = journal.iter() - .filter(|entry| &entry.who == stash) - .map(|entry| entry.own_slash) - .fold(>::zero(), |a, c| a.saturating_add(c)); - - let own_remaining = exposure.own.saturating_sub(already_slashed_own); - - // The amount we'll slash from the validator's stash directly. - let own_slash = own_remaining.min(slash); - let (mut imbalance, missing) = T::Currency::slash(stash, own_slash); - let own_slash = own_slash - missing; - // The amount remaining that we can't slash from the validator, - // that must be taken from the nominators. - let rest_slash = slash - own_slash; - if !rest_slash.is_zero() { - // The total to be slashed from the nominators. - let total = exposure.total - exposure.own; - if !total.is_zero() { - for i in exposure.others.iter() { - let per_u64 = Perbill::from_rational_approximation(i.value, total); - // best effort - not much that can be done on fail. - imbalance.subsume(T::Currency::slash(&i.who, per_u64 * rest_slash).0) - } - } - } - - journal.push(SlashJournalEntry { - who: stash.clone(), - own_slash: own_slash.clone(), - amount: slash, - }); - - // trigger the event - Self::deposit_event( - RawEvent::Slash(stash.clone(), slash) - ); + /// Chill a stash account. + fn chill_stash(stash: &T::AccountId) { + >::remove(stash); + >::remove(stash); + } - imbalance + /// Ensures storage is upgraded to most recent necessary state. + fn ensure_storage_upgraded() { + migration::perform_migrations::(); } /// Actually make a payment to a staker. This uses the currency's reward function @@ -1229,41 +1343,61 @@ impl Module { // Increment current era. let current_era = CurrentEra::mutate(|s| { *s += 1; *s }); - // prune journal for last era. - >::remove(current_era - 1); - CurrentEraStartSessionIndex::mutate(|v| { *v = start_session_index; }); let bonding_duration = T::BondingDuration::get(); - if current_era > bonding_duration { - let first_kept = current_era - bonding_duration; - BondedEras::mutate(|bonded| { - bonded.push((current_era, start_session_index)); + BondedEras::mutate(|bonded| { + bonded.push((current_era, start_session_index)); + + if current_era > bonding_duration { + let first_kept = current_era - bonding_duration; // prune out everything that's from before the first-kept index. let n_to_prune = bonded.iter() .take_while(|&&(era_idx, _)| era_idx < first_kept) .count(); - bonded.drain(..n_to_prune); + // kill slashing metadata. + for (pruned_era, _) in bonded.drain(..n_to_prune) { + slashing::clear_era_metadata::(pruned_era); + } if let Some(&(_, first_session)) = bonded.first() { T::SessionInterface::prune_historical_up_to(first_session); } - }) - } + } + }); // Reassign all Stakers. let (_slot_stake, maybe_new_validators) = Self::select_validators(); + Self::apply_unapplied_slashes(current_era); maybe_new_validators } + /// Apply previously-unapplied slashes on the beginning of a new era, after a delay. + fn apply_unapplied_slashes(current_era: EraIndex) { + let slash_defer_duration = T::SlashDeferDuration::get(); + ::EarliestUnappliedSlash::mutate(|earliest| if let Some(ref mut earliest) = earliest { + let keep_from = current_era.saturating_sub(slash_defer_duration); + for era in (*earliest)..keep_from { + let era_slashes = ::UnappliedSlashes::take(&era); + for slash in era_slashes { + slashing::apply_slash::(slash); + } + } + + *earliest = (*earliest).max(keep_from) + }) + } + /// Select a new validator set from the assembled stakers and their role preferences. /// /// Returns the new `SlotStake` value and a set of newly selected _stash_ IDs. + /// + /// Assumes storage is coherent with the declaration. fn select_validators() -> (BalanceOf, Option>) { let mut all_nominators: Vec<(T::AccountId, Vec)> = Vec::new(); let all_validator_candidates_iter = >::enumerate(); @@ -1272,9 +1406,24 @@ impl Module { all_nominators.push(self_vote); who }).collect::>(); - all_nominators.extend(>::enumerate()); - let maybe_phragmen_result = elect::<_, _, _, T::CurrencyToVote>( + let nominator_votes = >::enumerate().map(|(nominator, nominations)| { + let Nominations { submitted_in, mut targets, suppressed: _ } = nominations; + + // Filter out nomination targets which were nominated before the most recent + // slashing span. + targets.retain(|stash| { + ::SlashingSpans::get(&stash).map_or( + true, + |spans| submitted_in >= spans.last_start(), + ) + }); + + (nominator, targets) + }); + all_nominators.extend(nominator_votes); + + let maybe_phragmen_result = phragmen::elect::<_, _, _, T::CurrencyToVote>( Self::validator_count() as usize, Self::minimum_validator_count().max(1) as usize, all_validators, @@ -1293,7 +1442,7 @@ impl Module { let to_balance = |e: ExtendedBalance| >>::convert(e); - let mut supports = build_support_map::<_, _, _, T::CurrencyToVote>( + let mut supports = phragmen::build_support_map::<_, _, _, T::CurrencyToVote>( &elected_stashes, &assignments, Self::slashable_balance_of, @@ -1324,7 +1473,7 @@ impl Module { let tolerance = 0_u128; let iterations = 2_usize; - equalize::<_, _, T::CurrencyToVote, _>( + phragmen::equalize::<_, _, T::CurrencyToVote, _>( staked_assignments, &mut supports, tolerance, @@ -1384,6 +1533,8 @@ impl Module { /// Remove all associated data of a stash account from the staking system. /// + /// Assumes storage is upgraded before calling. + /// /// This is called : /// - Immediately when an account's balance falls below existential deposit. /// - after a `withdraw_unbond()` call that frees all of a stash's bonded balance. @@ -1394,6 +1545,8 @@ impl Module { >::remove(stash); >::remove(stash); >::remove(stash); + + slashing::clear_stash_metadata::(stash); } /// Add reward points to validators using their stash account ID. @@ -1449,6 +1602,7 @@ impl Module { impl session::OnSessionEnding for Module { fn on_session_ending(_ending: SessionIndex, start_session: SessionIndex) -> Option> { + Self::ensure_storage_upgraded(); Self::new_session(start_session - 1).map(|(new, _old)| new) } } @@ -1457,12 +1611,14 @@ impl OnSessionEnding fn on_session_ending(_ending: SessionIndex, start_session: SessionIndex) -> Option<(Vec, Vec<(T::AccountId, Exposure>)>)> { + Self::ensure_storage_upgraded(); Self::new_session(start_session - 1) } } impl OnFreeBalanceZero for Module { fn on_free_balance_zero(stash: &T::AccountId) { + Self::ensure_storage_upgraded(); Self::kill_stash(stash); } } @@ -1526,12 +1682,37 @@ impl OnOffenceHandler>], slash_fraction: &[Perbill], + slash_session: SessionIndex, ) { - let mut remaining_imbalance = >::zero(); - let slash_reward_fraction = SlashRewardFraction::get(); + >::ensure_storage_upgraded(); + + let reward_proportion = SlashRewardFraction::get(); let era_now = Self::current_era(); - let mut journal = Self::era_slash_journal(era_now); + let window_start = era_now.saturating_sub(T::BondingDuration::get()); + let current_era_start_session = CurrentEraStartSessionIndex::get(); + + // fast path for current-era report - most likely. + let slash_era = if slash_session >= current_era_start_session { + era_now + } else { + let eras = BondedEras::get(); + + // reverse because it's more likely to find reports from recent eras. + match eras.iter().rev().filter(|&&(_, ref sesh)| sesh <= &slash_session).next() { + None => return, // before bonding period. defensive - should be filtered out. + Some(&(ref slash_era, _)) => *slash_era, + } + }; + + ::EarliestUnappliedSlash::mutate(|earliest| { + if earliest.is_none() { + *earliest = Some(era_now) + } + }); + + let slash_defer_duration = T::SlashDeferDuration::get(); + for (details, slash_fraction) in offenders.iter().zip(slash_fraction) { let stash = &details.offender.0; let exposure = &details.offender.1; @@ -1541,57 +1722,34 @@ impl OnOffenceHandler>::exists(stash) { - >::remove(stash); - Self::ensure_new_era(); - } - - // calculate the amount to slash - let slash_exposure = exposure.total; - let amount = *slash_fraction * slash_exposure; - // in some cases `slash_fraction` can be just `0`, - // which means we are not slashing this time. - if amount.is_zero() { - continue; - } - - // make sure to disable validator till the end of this session - if T::SessionInterface::disable_validator(stash).unwrap_or(false) { - // force a new era, to select a new validator set - Self::ensure_new_era(); - } - // actually slash the validator - let slashed_amount = Self::slash_validator(stash, amount, exposure, &mut journal); - - // distribute the rewards according to the slash - let slash_reward = slash_reward_fraction * slashed_amount.peek(); - if !slash_reward.is_zero() && !details.reporters.is_empty() { - let (mut reward, rest) = slashed_amount.split(slash_reward); - // split the reward between reporters equally. Division cannot fail because - // we guarded against it in the enclosing if. - let per_reporter = reward.peek() / (details.reporters.len() as u32).into(); - for reporter in &details.reporters { - let (reporter_reward, rest) = reward.split(per_reporter); - reward = rest; - T::Currency::resolve_creating(reporter, reporter_reward); + let unapplied = slashing::compute_slash::(slashing::SlashParams { + stash, + slash: *slash_fraction, + exposure, + slash_era, + window_start, + now: era_now, + reward_proportion, + }); + + if let Some(mut unapplied) = unapplied { + unapplied.reporters = details.reporters.clone(); + if slash_defer_duration == 0 { + // apply right away. + slashing::apply_slash::(unapplied); + } else { + // defer to end of some `slash_defer_duration` from now. + ::UnappliedSlashes::mutate( + era_now, + move |for_later| for_later.push(unapplied), + ); } - // The rest goes to the treasury. - remaining_imbalance.subsume(reward); - remaining_imbalance.subsume(rest); - } else { - remaining_imbalance.subsume(slashed_amount); } } - >::insert(era_now, journal); - - // Handle the rest of imbalances - T::Slash::on_unbalanced(remaining_imbalance); } } -/// Filter historical offences out and only allow those from the current era. +/// Filter historical offences out and only allow those from the bonding period. pub struct FilterHistoricalOffences { _inner: rstd::marker::PhantomData<(T, R)>, } @@ -1603,9 +1761,13 @@ impl ReportOffence O: Offence, { fn report_offence(reporters: Vec, offence: O) { - // disallow any slashing from before the current era. + >::ensure_storage_upgraded(); + + // disallow any slashing from before the current bonding period. let offence_session = offence.session_index(); - if offence_session >= >::current_era_start_session_index() { + let bonded_eras = BondedEras::get(); + + if bonded_eras.first().filter(|(_, start)| offence_session >= *start).is_some() { R::report_offence(reporters, offence) } else { >::deposit_event( diff --git a/frame/staking/src/migration.rs b/frame/staking/src/migration.rs new file mode 100644 index 0000000000000..e89c6af1b9113 --- /dev/null +++ b/frame/staking/src/migration.rs @@ -0,0 +1,88 @@ +// Copyright 2019 Parity Technologies (UK) Ltd. +// This file is part of Substrate. + +// Substrate 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. + +// Substrate 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 Substrate. If not, see . + +//! Storage migrations for srml-staking. + +/// Indicator of a version of a storage layout. +pub type VersionNumber = u32; + +// the current expected version of the storage +pub const CURRENT_VERSION: VersionNumber = 1; + +#[cfg(any(test, feature = "migrate"))] +mod inner { + use crate::{Store, Module, Trait}; + use support::{StorageLinkedMap, StorageValue}; + use rstd::vec::Vec; + use super::{CURRENT_VERSION, VersionNumber}; + + // the minimum supported version of the migration logic. + const MIN_SUPPORTED_VERSION: VersionNumber = 0; + + // migrate storage from v0 to v1. + // + // this upgrades the `Nominators` linked_map value type from `Vec` to + // `Option>` + pub fn to_v1(version: &mut VersionNumber) { + if *version != 0 { return } + *version += 1; + + let now = >::current_era(); + let res = as Store>::Nominators::translate::, _, _>( + |key| key, + |targets| crate::Nominations { + targets, + submitted_in: now, + suppressed: false, + }, + ); + + if let Err(e) = res { + support::print("Encountered error in migration of Staking::Nominators map."); + if e.is_none() { + support::print("Staking::Nominators map reinitialized"); + } + } + + support::print("Finished migrating Staking storage to v1."); + } + + pub(super) fn perform_migrations() { + as Store>::StorageVersion::mutate(|version| { + if *version < MIN_SUPPORTED_VERSION { + support::print("Cannot migrate staking storage because version is less than\ + minimum."); + support::print(*version); + return + } + + if *version == CURRENT_VERSION { return } + + to_v1::(version); + }); + } +} + +#[cfg(not(any(test, feature = "migrate")))] +mod inner { + pub(super) fn perform_migrations() { } +} + +/// Perform all necessary storage migrations to get storage into the expected stsate for current +/// logic. No-op if fully upgraded. +pub(crate) fn perform_migrations() { + inner::perform_migrations::(); +} diff --git a/frame/staking/src/mock.rs b/frame/staking/src/mock.rs index c2a0745983754..f6068a477307c 100644 --- a/frame/staking/src/mock.rs +++ b/frame/staking/src/mock.rs @@ -21,10 +21,10 @@ use sr_primitives::{Perbill, KeyTypeId}; use sr_primitives::curve::PiecewiseLinear; use sr_primitives::traits::{IdentityLookup, Convert, OpaqueKeys, OnInitialize, SaturatedConversion}; use sr_primitives::testing::{Header, UintAuthorityId}; -use sr_staking_primitives::SessionIndex; +use sr_staking_primitives::{SessionIndex, offence::{OffenceDetails, OnOffenceHandler}}; use primitives::{H256, crypto::key_types}; use runtime_io; -use support::{assert_ok, impl_outer_origin, parameter_types, StorageLinkedMap}; +use support::{assert_ok, impl_outer_origin, parameter_types, StorageLinkedMap, StorageValue}; use support::traits::{Currency, Get, FindAuthor}; use crate::{ EraIndex, GenesisConfig, Module, Trait, StakerStatus, ValidatorPrefs, RewardDestination, @@ -48,6 +48,7 @@ impl Convert for CurrencyToVoteHandler { thread_local! { static SESSION: RefCell<(Vec, HashSet)> = RefCell::new(Default::default()); static EXISTENTIAL_DEPOSIT: RefCell = RefCell::new(0); + static SLASH_DEFER_DURATION: RefCell = RefCell::new(0); } pub struct TestSessionHandler; @@ -87,6 +88,13 @@ impl Get for ExistentialDeposit { } } +pub struct SlashDeferDuration; +impl Get for SlashDeferDuration { + fn get() -> EraIndex { + SLASH_DEFER_DURATION.with(|v| *v.borrow()) + } +} + impl_outer_origin!{ pub enum Origin for Test {} } @@ -202,6 +210,8 @@ impl Trait for Test { type Slash = (); type Reward = (); type SessionsPerEra = SessionsPerEra; + type SlashDeferDuration = SlashDeferDuration; + type SlashCancelOrigin = system::EnsureRoot; type BondingDuration = BondingDuration; type SessionInterface = Self; type RewardCurve = RewardCurve; @@ -213,6 +223,7 @@ pub struct ExtBuilder { nominate: bool, validator_count: u32, minimum_validator_count: u32, + slash_defer_duration: EraIndex, fair: bool, num_validators: Option, invulnerables: Vec, @@ -226,6 +237,7 @@ impl Default for ExtBuilder { nominate: true, validator_count: 2, minimum_validator_count: 0, + slash_defer_duration: 0, fair: true, num_validators: None, invulnerables: vec![], @@ -254,6 +266,10 @@ impl ExtBuilder { self.minimum_validator_count = count; self } + pub fn slash_defer_duration(mut self, eras: EraIndex) -> Self { + self.slash_defer_duration = eras; + self + } pub fn fair(mut self, is_fair: bool) -> Self { self.fair = is_fair; self @@ -268,6 +284,7 @@ impl ExtBuilder { } pub fn set_associated_consts(&self) { EXISTENTIAL_DEPOSIT.with(|v| *v.borrow_mut() = self.existential_deposit); + SLASH_DEFER_DURATION.with(|v| *v.borrow_mut() = self.slash_defer_duration); } pub fn build(self) -> runtime_io::TestExternalities { self.set_associated_consts(); @@ -393,6 +410,14 @@ pub fn assert_is_stash(acc: u64) { assert!(Staking::bonded(&acc).is_some(), "Not a stash."); } +pub fn assert_ledger_consistent(stash: u64) { + assert_is_stash(stash); + let ledger = Staking::ledger(stash - 1).unwrap(); + + let real_total: Balance = ledger.unlocking.iter().fold(ledger.active, |a, c| a + c.value); + assert_eq!(real_total, ledger.total); +} + pub fn bond_validator(acc: u64, val: u64) { // a = controller // a + 1 = stash @@ -451,3 +476,33 @@ pub fn reward_all_elected() { pub fn validator_controllers() -> Vec { Session::validators().into_iter().map(|s| Staking::bonded(&s).expect("no controller for validator")).collect() } + +pub fn on_offence_in_era( + offenders: &[OffenceDetails>], + slash_fraction: &[Perbill], + era: EraIndex, +) { + let bonded_eras = crate::BondedEras::get(); + for &(bonded_era, start_session) in bonded_eras.iter() { + if bonded_era == era { + Staking::on_offence(offenders, slash_fraction, start_session); + return + } else if bonded_era > era { + break + } + } + + if Staking::current_era() == era { + Staking::on_offence(offenders, slash_fraction, Staking::current_era_start_session_index()); + } else { + panic!("cannot slash in era {}", era); + } +} + +pub fn on_offence_now( + offenders: &[OffenceDetails>], + slash_fraction: &[Perbill], +) { + let now = Staking::current_era(); + on_offence_in_era(offenders, slash_fraction, now) +} diff --git a/frame/staking/src/slashing.rs b/frame/staking/src/slashing.rs new file mode 100644 index 0000000000000..23315a670a6b5 --- /dev/null +++ b/frame/staking/src/slashing.rs @@ -0,0 +1,824 @@ +// Copyright 2019 Parity Technologies (UK) Ltd. +// This file is part of Substrate. + +// Substrate 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. + +// Substrate 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 Substrate. If not, see . + +//! A slashing implementation for NPoS systems. +//! +//! For the purposes of the economic model, it is easiest to think of each validator +//! of a nominator which nominates only its own identity. +//! +//! The act of nomination signals intent to unify economic identity with the validator - to take part in the +//! rewards of a job well done, and to take part in the punishment of a job done badly. +//! +//! There are 3 main difficulties to account for with slashing in NPoS: +//! - A nominator can nominate multiple validators and be slashed via any of them. +//! - Until slashed, stake is reused from era to era. Nominating with N coins for E eras in a row +//! does not mean you have N*E coins to be slashed - you've only ever had N. +//! - Slashable offences can be found after the fact and out of order. +//! +//! The algorithm implemented in this module tries to balance these 3 difficulties. +//! +//! First, we only slash participants for the _maximum_ slash they receive in some time period, +//! rather than the sum. This ensures a protection from overslashing. +//! +//! Second, we do not want the time period (or "span") that the maximum is computed +//! over to last indefinitely. That would allow participants to begin acting with +//! impunity after some point, fearing no further repercussions. For that reason, we +//! automatically "chill" validators and withdraw a nominator's nomination after a slashing event, +//! requiring them to re-enlist voluntarily (acknowledging the slash) and begin a new +//! slashing span. +//! +//! Typically, you will have a single slashing event per slashing span. Only in the case +//! where a validator releases many misbehaviors at once, or goes "back in time" to misbehave in +//! eras that have already passed, would you encounter situations where a slashing span +//! has multiple misbehaviors. However, accounting for such cases is necessary +//! to deter a class of "rage-quit" attacks. +//! +//! Based on research at https://research.web3.foundation/en/latest/polkadot/slashing/npos/ + +use super::{ + EraIndex, Trait, Module, Store, BalanceOf, Exposure, Perbill, SessionInterface, + NegativeImbalanceOf, UnappliedSlash, +}; +use sr_primitives::traits::{Zero, Saturating}; +use support::{ + StorageMap, StorageDoubleMap, + traits::{Currency, OnUnbalanced, Imbalance}, +}; +use rstd::vec::Vec; +use codec::{Encode, Decode}; + +/// The proportion of the slashing reward to be paid out on the first slashing detection. +/// This is f_1 in the paper. +const REWARD_F1: Perbill = Perbill::from_percent(50); + +/// The index of a slashing span - unique to each stash. +pub(crate) type SpanIndex = u32; + +// A range of start..end eras for a slashing span. +#[derive(Encode, Decode)] +#[cfg_attr(test, derive(Debug, PartialEq))] +pub(crate) struct SlashingSpan { + pub(crate) index: SpanIndex, + pub(crate) start: EraIndex, + pub(crate) length: Option, // the ongoing slashing span has indeterminate length. +} + +impl SlashingSpan { + fn contains_era(&self, era: EraIndex) -> bool { + self.start <= era && self.length.map_or(true, |l| self.start + l > era) + } +} + +/// An encoding of all of a nominator's slashing spans. +#[derive(Encode, Decode)] +pub struct SlashingSpans { + // the index of the current slashing span of the nominator. different for + // every stash, resets when the account hits free balance 0. + span_index: SpanIndex, + // the start era of the most recent (ongoing) slashing span. + last_start: EraIndex, + // all prior slashing spans start indices, in reverse order (most recent first) + // encoded as offsets relative to the slashing span after it. + prior: Vec, +} + +impl SlashingSpans { + // creates a new record of slashing spans for a stash, starting at the beginning + // of the bonding period, relative to now. + fn new(window_start: EraIndex) -> Self { + SlashingSpans { + span_index: 0, + last_start: window_start, + prior: Vec::new(), + } + } + + // update the slashing spans to reflect the start of a new span at the era after `now` + // returns `true` if a new span was started, `false` otherwise. `false` indicates + // that internal state is unchanged. + fn end_span(&mut self, now: EraIndex) -> bool { + let next_start = now + 1; + if next_start <= self.last_start { return false } + + let last_length = next_start - self.last_start; + self.prior.insert(0, last_length); + self.last_start = next_start; + self.span_index += 1; + true + } + + // an iterator over all slashing spans in _reverse_ order - most recent first. + pub(crate) fn iter(&'_ self) -> impl Iterator + '_ { + let mut last_start = self.last_start; + let mut index = self.span_index; + let last = SlashingSpan { index, start: last_start, length: None }; + let prior = self.prior.iter().cloned().map(move |length| { + let start = last_start - length; + last_start = start; + index -= 1; + + SlashingSpan { index, start, length: Some(length) } + }); + + rstd::iter::once(last).chain(prior) + } + + /// Yields the era index where the last (current) slashing span started. + pub(crate) fn last_start(&self) -> EraIndex { + self.last_start + } + + // prune the slashing spans against a window, whose start era index is given. + // + // If this returns `Some`, then it includes a range start..end of all the span + // indices which were pruned. + fn prune(&mut self, window_start: EraIndex) -> Option<(SpanIndex, SpanIndex)> { + let old_idx = self.iter() + .skip(1) // skip ongoing span. + .position(|span| span.length.map_or(false, |len| span.start + len <= window_start)); + + let earliest_span_index = self.span_index - self.prior.len() as SpanIndex; + let pruned = match old_idx { + Some(o) => { + self.prior.truncate(o); + let new_earliest = self.span_index - self.prior.len() as SpanIndex; + Some((earliest_span_index, new_earliest)) + } + None => None, + }; + + // readjust the ongoing span, if it started before the beginning of the window. + self.last_start = rstd::cmp::max(self.last_start, window_start); + pruned + } +} + +/// A slashing-span record for a particular stash. +#[derive(Encode, Decode, Default)] +pub(crate) struct SpanRecord { + slashed: Balance, + paid_out: Balance, +} + +impl SpanRecord { + /// The value of stash balance slashed in this span. + #[cfg(test)] + pub(crate) fn amount_slashed(&self) -> &Balance { + &self.slashed + } +} + +/// Parameters for performing a slash. +#[derive(Clone)] +pub(crate) struct SlashParams<'a, T: 'a + Trait> { + /// The stash account being slashed. + pub(crate) stash: &'a T::AccountId, + /// The proportion of the slash. + pub(crate) slash: Perbill, + /// The exposure of the stash and all nominators. + pub(crate) exposure: &'a Exposure>, + /// The era where the offence occurred. + pub(crate) slash_era: EraIndex, + /// The first era in the current bonding period. + pub(crate) window_start: EraIndex, + /// The current era. + pub(crate) now: EraIndex, + /// The maximum percentage of a slash that ever gets paid out. + /// This is f_inf in the paper. + pub(crate) reward_proportion: Perbill, +} + +/// Computes a slash of a validator and nominators. It returns an unapplied +/// record to be applied at some later point. Slashing metadata is updated in storage, +/// since unapplied records are only rarely intended to be dropped. +/// +/// The pending slash record returned does not have initialized reporters. Those have +/// to be set at a higher level, if any. +pub(crate) fn compute_slash(params: SlashParams) + -> Option>> +{ + let SlashParams { + stash, + slash, + exposure, + slash_era, + window_start, + now, + reward_proportion, + } = params.clone(); + + let mut reward_payout = Zero::zero(); + let mut val_slashed = Zero::zero(); + + // is the slash amount here a maximum for the era? + let own_slash = slash * exposure.own; + if slash * exposure.total == Zero::zero() { + // kick out the validator even if they won't be slashed, + // as long as the misbehavior is from their most recent slashing span. + kick_out_if_recent::(params); + return None; + } + + let (prior_slash_p, _era_slash) = as Store>::ValidatorSlashInEra::get( + &slash_era, + stash, + ).unwrap_or((Perbill::zero(), Zero::zero())); + + // compare slash proportions rather than slash values to avoid issues due to rounding + // error. + if slash.deconstruct() > prior_slash_p.deconstruct() { + as Store>::ValidatorSlashInEra::insert( + &slash_era, + stash, + &(slash, own_slash), + ); + } else { + // we slash based on the max in era - this new event is not the max, + // so neither the validator or any nominators will need an update. + // + // this does lead to a divergence of our system from the paper, which + // pays out some reward even if the latest report is not max-in-era. + // we opt to avoid the nominator lookups and edits and leave more rewards + // for more drastic misbehavior. + return None; + } + + // apply slash to validator. + { + let mut spans = fetch_spans::( + stash, + window_start, + &mut reward_payout, + &mut val_slashed, + reward_proportion, + ); + + let target_span = spans.compare_and_update_span_slash( + slash_era, + own_slash, + ); + + if target_span == Some(spans.span_index()) { + // misbehavior occurred within the current slashing span - take appropriate + // actions. + + // chill the validator - it misbehaved in the current span and should + // not continue in the next election. also end the slashing span. + spans.end_span(now); + >::chill_stash(stash); + + // make sure to disable validator till the end of this session + if T::SessionInterface::disable_validator(stash).unwrap_or(false) { + // force a new era, to select a new validator set + >::ensure_new_era() + } + } + } + + let mut nominators_slashed = Vec::new(); + reward_payout += slash_nominators::(params, prior_slash_p, &mut nominators_slashed); + + Some(UnappliedSlash { + validator: stash.clone(), + own: val_slashed, + others: nominators_slashed, + reporters: Vec::new(), + payout: reward_payout, + }) +} + +// doesn't apply any slash, but kicks out the validator if the misbehavior is from +// the most recent slashing span. +fn kick_out_if_recent( + params: SlashParams, +) { + // these are not updated by era-span or end-span. + let mut reward_payout = Zero::zero(); + let mut val_slashed = Zero::zero(); + let mut spans = fetch_spans::( + params.stash, + params.window_start, + &mut reward_payout, + &mut val_slashed, + params.reward_proportion, + ); + + if spans.era_span(params.slash_era).map(|s| s.index) == Some(spans.span_index()) { + spans.end_span(params.now); + >::chill_stash(params.stash); + + // make sure to disable validator till the end of this session + if T::SessionInterface::disable_validator(params.stash).unwrap_or(false) { + // force a new era, to select a new validator set + >::ensure_new_era() + } + } +} + +/// Slash nominators. Accepts general parameters and the prior slash percentage of the validator. +/// +/// Returns the amount of reward to pay out. +fn slash_nominators( + params: SlashParams, + prior_slash_p: Perbill, + nominators_slashed: &mut Vec<(T::AccountId, BalanceOf)>, +) -> BalanceOf { + let SlashParams { + stash: _, + slash, + exposure, + slash_era, + window_start, + now, + reward_proportion, + } = params; + + let mut reward_payout = Zero::zero(); + + nominators_slashed.reserve(exposure.others.len()); + for nominator in &exposure.others { + let stash = &nominator.who; + let mut nom_slashed = Zero::zero(); + + // the era slash of a nominator always grows, if the validator + // had a new max slash for the era. + let era_slash = { + let own_slash_prior = prior_slash_p * nominator.value; + let own_slash_by_validator = slash * nominator.value; + let own_slash_difference = own_slash_by_validator.saturating_sub(own_slash_prior); + + let mut era_slash = as Store>::NominatorSlashInEra::get( + &slash_era, + stash, + ).unwrap_or(Zero::zero()); + + era_slash += own_slash_difference; + + as Store>::NominatorSlashInEra::insert( + &slash_era, + stash, + &era_slash, + ); + + era_slash + }; + + // compare the era slash against other eras in the same span. + { + let mut spans = fetch_spans::( + stash, + window_start, + &mut reward_payout, + &mut nom_slashed, + reward_proportion, + ); + + let target_span = spans.compare_and_update_span_slash( + slash_era, + era_slash, + ); + + if target_span == Some(spans.span_index()) { + // Chill the nominator outright, ending the slashing span. + spans.end_span(now); + >::chill_stash(stash); + } + } + + nominators_slashed.push((stash.clone(), nom_slashed)); + } + + reward_payout +} + +// helper struct for managing a set of spans we are currently inspecting. +// writes alterations to disk on drop, but only if a slash has been carried out. +// +// NOTE: alterations to slashing metadata should not be done after this is dropped. +// dropping this struct applies any necessary slashes, which can lead to free balance +// being 0, and the account being garbage-collected -- a dead account should get no new +// metadata. +struct InspectingSpans<'a, T: Trait + 'a> { + dirty: bool, + window_start: EraIndex, + stash: &'a T::AccountId, + spans: SlashingSpans, + paid_out: &'a mut BalanceOf, + slash_of: &'a mut BalanceOf, + reward_proportion: Perbill, + _marker: rstd::marker::PhantomData, +} + +// fetches the slashing spans record for a stash account, initializing it if necessary. +fn fetch_spans<'a, T: Trait + 'a>( + stash: &'a T::AccountId, + window_start: EraIndex, + paid_out: &'a mut BalanceOf, + slash_of: &'a mut BalanceOf, + reward_proportion: Perbill, +) -> InspectingSpans<'a, T> { + let spans = as Store>::SlashingSpans::get(stash).unwrap_or_else(|| { + let spans = SlashingSpans::new(window_start); + as Store>::SlashingSpans::insert(stash, &spans); + spans + }); + + InspectingSpans { + dirty: false, + window_start, + stash, + spans, + slash_of, + paid_out, + reward_proportion, + _marker: rstd::marker::PhantomData, + } +} + +impl<'a, T: 'a + Trait> InspectingSpans<'a, T> { + fn span_index(&self) -> SpanIndex { + self.spans.span_index + } + + fn end_span(&mut self, now: EraIndex) { + self.dirty = self.spans.end_span(now) || self.dirty; + } + + fn add_slash(&mut self, amount: BalanceOf) { + *self.slash_of += amount; + } + + // find the span index of the given era, if covered. + fn era_span(&self, era: EraIndex) -> Option { + self.spans.iter().find(|span| span.contains_era(era)) + } + + // compares the slash in an era to the overall current span slash. + // if it's higher, applies the difference of the slashes and then updates the span on disk. + // + // returns the span index of the era where the slash occurred, if any. + fn compare_and_update_span_slash( + &mut self, + slash_era: EraIndex, + slash: BalanceOf, + ) -> Option { + let target_span = self.era_span(slash_era)?; + let span_slash_key = (self.stash.clone(), target_span.index); + let mut span_record = as Store>::SpanSlash::get(&span_slash_key); + let mut changed = false; + + let reward = if span_record.slashed < slash { + // new maximum span slash. apply the difference. + let difference = slash - span_record.slashed; + span_record.slashed = slash; + + // compute reward. + let reward = REWARD_F1 + * (self.reward_proportion * slash).saturating_sub(span_record.paid_out); + + self.add_slash(difference); + changed = true; + + reward + } else if span_record.slashed == slash { + // compute reward. no slash difference to apply. + REWARD_F1 * (self.reward_proportion * slash).saturating_sub(span_record.paid_out) + } else { + Zero::zero() + }; + + if !reward.is_zero() { + changed = true; + span_record.paid_out += reward; + *self.paid_out += reward; + } + + if changed { + self.dirty = true; + as Store>::SpanSlash::insert(&span_slash_key, &span_record); + } + + Some(target_span.index) + } +} + +impl<'a, T: 'a + Trait> Drop for InspectingSpans<'a, T> { + fn drop(&mut self) { + // only update on disk if we slashed this account. + if !self.dirty { return } + + if let Some((start, end)) = self.spans.prune(self.window_start) { + for span_index in start..end { + as Store>::SpanSlash::remove(&(self.stash.clone(), span_index)); + } + } + + as Store>::SlashingSpans::insert(self.stash, &self.spans); + } +} + +/// Clear slashing metadata for an obsolete era. +pub(crate) fn clear_era_metadata(obsolete_era: EraIndex) { + as Store>::ValidatorSlashInEra::remove_prefix(&obsolete_era); + as Store>::NominatorSlashInEra::remove_prefix(&obsolete_era); +} + +/// Clear slashing metadata for a dead account. +pub(crate) fn clear_stash_metadata(stash: &T::AccountId) { + let spans = match as Store>::SlashingSpans::take(stash) { + None => return, + Some(s) => s, + }; + + // kill slashing-span metadata for account. + // + // this can only happen while the account is staked _if_ they are completely slashed. + // in that case, they may re-bond, but it would count again as span 0. Further ancient + // slashes would slash into this new bond, since metadata has now been cleared. + for span in spans.iter() { + as Store>::SpanSlash::remove(&(stash.clone(), span.index)); + } +} + +// apply the slash to a stash account, deducting any missing funds from the reward +// payout, saturating at 0. this is mildly unfair but also an edge-case that +// can only occur when overlapping locked funds have been slashed. +fn do_slash( + stash: &T::AccountId, + value: BalanceOf, + reward_payout: &mut BalanceOf, + slashed_imbalance: &mut NegativeImbalanceOf, +) { + let controller = match >::bonded(stash) { + None => return, // defensive: should always exist. + Some(c) => c, + }; + + let mut ledger = match >::ledger(&controller) { + Some(ledger) => ledger, + None => return, // nothing to do. + }; + + let value = ledger.slash(value, T::Currency::minimum_balance()); + + if !value.is_zero() { + let (imbalance, missing) = T::Currency::slash(stash, value); + slashed_imbalance.subsume(imbalance); + + if !missing.is_zero() { + // deduct overslash from the reward payout + *reward_payout = reward_payout.saturating_sub(missing); + } + + >::update_ledger(&controller, &ledger); + + // trigger the event + >::deposit_event( + super::RawEvent::Slash(stash.clone(), value) + ); + } +} + +/// Apply a previously-unapplied slash. +pub(crate) fn apply_slash(unapplied_slash: UnappliedSlash>) { + let mut slashed_imbalance = NegativeImbalanceOf::::zero(); + let mut reward_payout = unapplied_slash.payout; + + do_slash::( + &unapplied_slash.validator, + unapplied_slash.own, + &mut reward_payout, + &mut slashed_imbalance, + ); + + for &(ref nominator, nominator_slash) in &unapplied_slash.others { + do_slash::( + &nominator, + nominator_slash, + &mut reward_payout, + &mut slashed_imbalance, + ); + } + + pay_reporters::(reward_payout, slashed_imbalance, &unapplied_slash.reporters); +} + + +/// Apply a reward payout to some reporters, paying the rewards out of the slashed imbalance. +fn pay_reporters( + reward_payout: BalanceOf, + slashed_imbalance: NegativeImbalanceOf, + reporters: &[T::AccountId], +) { + if reward_payout.is_zero() || reporters.is_empty() { + // nobody to pay out to or nothing to pay; + // just treat the whole value as slashed. + T::Slash::on_unbalanced(slashed_imbalance); + return + } + + // take rewards out of the slashed imbalance. + let reward_payout = reward_payout.min(slashed_imbalance.peek()); + let (mut reward_payout, mut value_slashed) = slashed_imbalance.split(reward_payout); + + let per_reporter = reward_payout.peek() / (reporters.len() as u32).into(); + for reporter in reporters { + let (reporter_reward, rest) = reward_payout.split(per_reporter); + reward_payout = rest; + + // this cancels out the reporter reward imbalance internally, leading + // to no change in total issuance. + T::Currency::resolve_creating(reporter, reporter_reward); + } + + // the rest goes to the on-slash imbalance handler (e.g. treasury) + value_slashed.subsume(reward_payout); // remainder of reward division remains. + T::Slash::on_unbalanced(value_slashed); +} + +// TODO: function for undoing a slash. +// + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn span_contains_era() { + // unbounded end + let span = SlashingSpan { index: 0, start: 1000, length: None }; + assert!(!span.contains_era(0)); + assert!(!span.contains_era(999)); + + assert!(span.contains_era(1000)); + assert!(span.contains_era(1001)); + assert!(span.contains_era(10000)); + + // bounded end - non-inclusive range. + let span = SlashingSpan { index: 0, start: 1000, length: Some(10) }; + assert!(!span.contains_era(0)); + assert!(!span.contains_era(999)); + + assert!(span.contains_era(1000)); + assert!(span.contains_era(1001)); + assert!(span.contains_era(1009)); + assert!(!span.contains_era(1010)); + assert!(!span.contains_era(1011)); + } + + #[test] + fn single_slashing_span() { + let spans = SlashingSpans { + span_index: 0, + last_start: 1000, + prior: Vec::new(), + }; + + assert_eq!( + spans.iter().collect::>(), + vec![SlashingSpan { index: 0, start: 1000, length: None }], + ); + } + + #[test] + fn many_prior_spans() { + let spans = SlashingSpans { + span_index: 10, + last_start: 1000, + prior: vec![10, 9, 8, 10], + }; + + assert_eq!( + spans.iter().collect::>(), + vec![ + SlashingSpan { index: 10, start: 1000, length: None }, + SlashingSpan { index: 9, start: 990, length: Some(10) }, + SlashingSpan { index: 8, start: 981, length: Some(9) }, + SlashingSpan { index: 7, start: 973, length: Some(8) }, + SlashingSpan { index: 6, start: 963, length: Some(10) }, + ], + ) + } + + #[test] + fn pruning_spans() { + let mut spans = SlashingSpans { + span_index: 10, + last_start: 1000, + prior: vec![10, 9, 8, 10], + }; + + assert_eq!(spans.prune(981), Some((6, 8))); + assert_eq!( + spans.iter().collect::>(), + vec![ + SlashingSpan { index: 10, start: 1000, length: None }, + SlashingSpan { index: 9, start: 990, length: Some(10) }, + SlashingSpan { index: 8, start: 981, length: Some(9) }, + ], + ); + + assert_eq!(spans.prune(982), None); + assert_eq!( + spans.iter().collect::>(), + vec![ + SlashingSpan { index: 10, start: 1000, length: None }, + SlashingSpan { index: 9, start: 990, length: Some(10) }, + SlashingSpan { index: 8, start: 981, length: Some(9) }, + ], + ); + + assert_eq!(spans.prune(989), None); + assert_eq!( + spans.iter().collect::>(), + vec![ + SlashingSpan { index: 10, start: 1000, length: None }, + SlashingSpan { index: 9, start: 990, length: Some(10) }, + SlashingSpan { index: 8, start: 981, length: Some(9) }, + ], + ); + + assert_eq!(spans.prune(1000), Some((8, 10))); + assert_eq!( + spans.iter().collect::>(), + vec![ + SlashingSpan { index: 10, start: 1000, length: None }, + ], + ); + + assert_eq!(spans.prune(2000), None); + assert_eq!( + spans.iter().collect::>(), + vec![ + SlashingSpan { index: 10, start: 2000, length: None }, + ], + ); + + // now all in one shot. + let mut spans = SlashingSpans { + span_index: 10, + last_start: 1000, + prior: vec![10, 9, 8, 10], + }; + assert_eq!(spans.prune(2000), Some((6, 10))); + assert_eq!( + spans.iter().collect::>(), + vec![ + SlashingSpan { index: 10, start: 2000, length: None }, + ], + ); + } + + #[test] + fn ending_span() { + let mut spans = SlashingSpans { + span_index: 1, + last_start: 10, + prior: Vec::new(), + }; + + assert!(spans.end_span(10)); + + assert_eq!( + spans.iter().collect::>(), + vec![ + SlashingSpan { index: 2, start: 11, length: None }, + SlashingSpan { index: 1, start: 10, length: Some(1) }, + ], + ); + + assert!(spans.end_span(15)); + assert_eq!( + spans.iter().collect::>(), + vec![ + SlashingSpan { index: 3, start: 16, length: None }, + SlashingSpan { index: 2, start: 11, length: Some(5) }, + SlashingSpan { index: 1, start: 10, length: Some(1) }, + ], + ); + + // does nothing if not a valid end. + assert!(!spans.end_span(15)); + assert_eq!( + spans.iter().collect::>(), + vec![ + SlashingSpan { index: 3, start: 16, length: None }, + SlashingSpan { index: 2, start: 11, length: Some(5) }, + SlashingSpan { index: 1, start: 10, length: Some(1) }, + ], + ); + } +} diff --git a/frame/staking/src/tests.rs b/frame/staking/src/tests.rs index 7c16091d065f6..7e1939e437632 100644 --- a/frame/staking/src/tests.rs +++ b/frame/staking/src/tests.rs @@ -19,7 +19,7 @@ use super::*; use mock::*; use sr_primitives::{assert_eq_error_rate, traits::OnInitialize}; -use sr_staking_primitives::offence::{OffenceDetails, OnOffenceHandler}; +use sr_staking_primitives::offence::OffenceDetails; use support::{assert_ok, assert_noop, traits::{Currency, ReservableCurrency}}; use substrate_test_utils::assert_eq_uvec; @@ -80,7 +80,7 @@ fn basic_setup_works() { Staking::ledger(100), Some(StakingLedger { stash: 101, total: 500, active: 500, unlocking: vec![] }) ); - assert_eq!(Staking::nominators(101), vec![11, 21]); + assert_eq!(Staking::nominators(101).unwrap().targets, vec![11, 21]); if cfg!(feature = "equalize") { assert_eq!( @@ -638,7 +638,7 @@ fn nominators_also_get_slashed() { assert_eq!(Balances::total_balance(&2), initial_balance); // 10 goes offline - Staking::on_offence( + on_offence_now( &[OffenceDetails { offender: ( 11, @@ -1661,12 +1661,26 @@ fn reward_validator_slashing_validator_doesnt_overflow() { // Set staker let _ = Balances::make_free_balance_be(&11, stake); let _ = Balances::make_free_balance_be(&2, stake); + + // only slashes out of bonded stake are applied. without this line, + // it is 0. + Staking::bond(Origin::signed(2), 20000, stake - 1, RewardDestination::default()).unwrap(); >::insert(&11, Exposure { total: stake, own: 1, others: vec![ IndividualExposure { who: 2, value: stake - 1 } ]}); + // Check slashing - let _ = Staking::slash_validator(&11, reward_slash, &Staking::stakers(&11), &mut Vec::new()); + on_offence_now( + &[ + OffenceDetails { + offender: (11, Staking::stakers(&11)), + reporters: vec![], + }, + ], + &[Perbill::from_percent(100)], + ); + assert_eq!(Balances::total_balance(&11), stake - 1); assert_eq!(Balances::total_balance(&2), 1); }) @@ -1761,7 +1775,7 @@ fn era_is_always_same_length() { #[test] fn offence_forces_new_era() { ExtBuilder::default().build().execute_with(|| { - Staking::on_offence( + on_offence_now( &[OffenceDetails { offender: ( 11, @@ -1781,7 +1795,7 @@ fn offence_ensures_new_era_without_clobbering() { ExtBuilder::default().build().execute_with(|| { assert_ok!(Staking::force_new_era_always(Origin::ROOT)); - Staking::on_offence( + on_offence_now( &[OffenceDetails { offender: ( 11, @@ -1800,7 +1814,7 @@ fn offence_ensures_new_era_without_clobbering() { fn offence_deselects_validator_when_slash_is_zero() { ExtBuilder::default().build().execute_with(|| { assert!(>::exists(11)); - Staking::on_offence( + on_offence_now( &[OffenceDetails { offender: ( 11, @@ -1823,7 +1837,7 @@ fn slashing_performed_according_exposure() { assert_eq!(Staking::stakers(&11).own, 1000); // Handle an offence with a historical exposure. - Staking::on_offence( + on_offence_now( &[OffenceDetails { offender: ( 11, @@ -1843,6 +1857,71 @@ fn slashing_performed_according_exposure() { }); } +#[test] +fn slash_in_old_span_does_not_deselect() { + ExtBuilder::default().build().execute_with(|| { + start_era(1); + + assert!(>::exists(11)); + on_offence_now( + &[OffenceDetails { + offender: ( + 11, + Staking::stakers(&11), + ), + reporters: vec![], + }], + &[Perbill::from_percent(0)], + ); + assert_eq!(Staking::force_era(), Forcing::ForceNew); + assert!(!>::exists(11)); + + start_era(2); + + Staking::validate(Origin::signed(10), Default::default()).unwrap(); + assert_eq!(Staking::force_era(), Forcing::NotForcing); + assert!(>::exists(11)); + + start_era(3); + + // this staker is in a new slashing span now, having re-registered after + // their prior slash. + + on_offence_in_era( + &[OffenceDetails { + offender: ( + 11, + Staking::stakers(&11), + ), + reporters: vec![], + }], + &[Perbill::from_percent(0)], + 1, + ); + + // not for zero-slash. + assert_eq!(Staking::force_era(), Forcing::NotForcing); + assert!(>::exists(11)); + + on_offence_in_era( + &[OffenceDetails { + offender: ( + 11, + Staking::stakers(&11), + ), + reporters: vec![], + }], + &[Perbill::from_percent(100)], + 1, + ); + + // or non-zero. + assert_eq!(Staking::force_era(), Forcing::NotForcing); + assert!(>::exists(11)); + assert_ledger_consistent(11); + }); +} + #[test] fn reporters_receive_their_slice() { // This test verifies that the reporters of the offence receive their slice from the slashed @@ -1856,7 +1935,7 @@ fn reporters_receive_their_slice() { assert_eq!(Staking::stakers(&11).total, initial_balance); - Staking::on_offence( + on_offence_now( &[OffenceDetails { offender: ( 11, @@ -1867,27 +1946,80 @@ fn reporters_receive_their_slice() { &[Perbill::from_percent(50)], ); - // initial_balance x 50% (slash fraction) x 10% (rewards slice) - let reward = initial_balance / 20 / 2; - assert_eq!(Balances::free_balance(&1), 10 + reward); - assert_eq!(Balances::free_balance(&2), 20 + reward); + // F1 * (reward_proportion * slash - 0) + // 50% * (10% * initial_balance / 2) + let reward = (initial_balance / 20) / 2; + let reward_each = reward / 2; // split into two pieces. + assert_eq!(Balances::free_balance(&1), 10 + reward_each); + assert_eq!(Balances::free_balance(&2), 20 + reward_each); + assert_ledger_consistent(11); }); } #[test] -fn invulnerables_are_not_slashed() { - // For invulnerable validators no slashing is performed. - ExtBuilder::default().invulnerables(vec![11]).build().execute_with(|| { +fn subsequent_reports_in_same_span_pay_out_less() { + // This test verifies that the reporters of the offence receive their slice from the slashed + // amount. + ExtBuilder::default().build().execute_with(|| { + // The reporters' reward is calculated from the total exposure. #[cfg(feature = "equalize")] let initial_balance = 1250; #[cfg(not(feature = "equalize"))] - let initial_balance = 1375; + let initial_balance = 1125; + + assert_eq!(Staking::stakers(&11).total, initial_balance); + on_offence_now( + &[OffenceDetails { + offender: ( + 11, + Staking::stakers(&11), + ), + reporters: vec![1], + }], + &[Perbill::from_percent(20)], + ); + + // F1 * (reward_proportion * slash - 0) + // 50% * (10% * initial_balance * 20%) + let reward = (initial_balance / 5) / 20; + assert_eq!(Balances::free_balance(&1), 10 + reward); + + on_offence_now( + &[OffenceDetails { + offender: ( + 11, + Staking::stakers(&11), + ), + reporters: vec![1], + }], + &[Perbill::from_percent(50)], + ); + + let prior_payout = reward; + + // F1 * (reward_proportion * slash - prior_payout) + // 50% * (10% * (initial_balance / 2) - prior_payout) + let reward = ((initial_balance / 20) - prior_payout) / 2; + assert_eq!(Balances::free_balance(&1), 10 + prior_payout + reward); + assert_ledger_consistent(11); + }); +} + +#[test] +fn invulnerables_are_not_slashed() { + // For invulnerable validators no slashing is performed. + ExtBuilder::default().invulnerables(vec![11]).build().execute_with(|| { assert_eq!(Balances::free_balance(&11), 1000); assert_eq!(Balances::free_balance(&21), 2000); - assert_eq!(Staking::stakers(&21).total, initial_balance); - Staking::on_offence( + let exposure = Staking::stakers(&21); + let initial_balance = Staking::slashable_balance_of(&21); + + let nominator_balances: Vec<_> = exposure.others + .iter().map(|o| Balances::free_balance(&o.who)).collect(); + + on_offence_now( &[ OffenceDetails { offender: (11, Staking::stakers(&11)), @@ -1905,6 +2037,16 @@ fn invulnerables_are_not_slashed() { assert_eq!(Balances::free_balance(&11), 1000); // 2000 - (0.2 * initial_balance) assert_eq!(Balances::free_balance(&21), 2000 - (2 * initial_balance / 10)); + + // ensure that nominators were slashed as well. + for (initial_balance, other) in nominator_balances.into_iter().zip(exposure.others) { + assert_eq!( + Balances::free_balance(&other.who), + initial_balance - (2 * other.value / 10), + ); + } + assert_ledger_consistent(11); + assert_ledger_consistent(21); }); } @@ -1914,7 +2056,7 @@ fn dont_slash_if_fraction_is_zero() { ExtBuilder::default().build().execute_with(|| { assert_eq!(Balances::free_balance(&11), 1000); - Staking::on_offence( + on_offence_now( &[OffenceDetails { offender: ( 11, @@ -1927,5 +2069,462 @@ fn dont_slash_if_fraction_is_zero() { // The validator hasn't been slashed. The new era is not forced. assert_eq!(Balances::free_balance(&11), 1000); + assert_ledger_consistent(11); + }); +} + +#[test] +fn only_slash_for_max_in_era() { + ExtBuilder::default().build().execute_with(|| { + assert_eq!(Balances::free_balance(&11), 1000); + + on_offence_now( + &[ + OffenceDetails { + offender: (11, Staking::stakers(&11)), + reporters: vec![], + }, + ], + &[Perbill::from_percent(50)], + ); + + // The validator has been slashed and has been force-chilled. + assert_eq!(Balances::free_balance(&11), 500); + assert_eq!(Staking::force_era(), Forcing::ForceNew); + + on_offence_now( + &[ + OffenceDetails { + offender: (11, Staking::stakers(&11)), + reporters: vec![], + }, + ], + &[Perbill::from_percent(25)], + ); + + // The validator has not been slashed additionally. + assert_eq!(Balances::free_balance(&11), 500); + + on_offence_now( + &[ + OffenceDetails { + offender: (11, Staking::stakers(&11)), + reporters: vec![], + }, + ], + &[Perbill::from_percent(60)], + ); + + // The validator got slashed 10% more. + assert_eq!(Balances::free_balance(&11), 400); + assert_ledger_consistent(11); + }) +} + +#[test] +fn garbage_collection_after_slashing() { + ExtBuilder::default().existential_deposit(1).build().execute_with(|| { + assert_eq!(Balances::free_balance(&11), 256_000); + + on_offence_now( + &[ + OffenceDetails { + offender: (11, Staking::stakers(&11)), + reporters: vec![], + }, + ], + &[Perbill::from_percent(10)], + ); + + assert_eq!(Balances::free_balance(&11), 256_000 - 25_600); + assert!(::SlashingSpans::get(&11).is_some()); + assert_eq!(::SpanSlash::get(&(11, 0)).amount_slashed(), &25_600); + + on_offence_now( + &[ + OffenceDetails { + offender: (11, Staking::stakers(&11)), + reporters: vec![], + }, + ], + &[Perbill::from_percent(100)], + ); + + // validator and nominator slash in era are garbage-collected by era change, + // so we don't test those here. + + assert_eq!(Balances::free_balance(&11), 0); + assert!(::SlashingSpans::get(&11).is_none()); + assert_eq!(::SpanSlash::get(&(11, 0)).amount_slashed(), &0); + }) +} + +#[test] +fn garbage_collection_on_window_pruning() { + ExtBuilder::default().build().execute_with(|| { + start_era(1); + + assert_eq!(Balances::free_balance(&11), 1000); + + let exposure = Staking::stakers(&11); + assert_eq!(Balances::free_balance(&101), 2000); + let nominated_value = exposure.others.iter().find(|o| o.who == 101).unwrap().value; + + on_offence_now( + &[ + OffenceDetails { + offender: (11, Staking::stakers(&11)), + reporters: vec![], + }, + ], + &[Perbill::from_percent(10)], + ); + + let now = Staking::current_era(); + + assert_eq!(Balances::free_balance(&11), 900); + assert_eq!(Balances::free_balance(&101), 2000 - (nominated_value / 10)); + + assert!(::ValidatorSlashInEra::get(&now, &11).is_some()); + assert!(::NominatorSlashInEra::get(&now, &101).is_some()); + + // + 1 because we have to exit the bonding window. + for era in (0..(BondingDuration::get() + 1)).map(|offset| offset + now + 1) { + assert!(::ValidatorSlashInEra::get(&now, &11).is_some()); + assert!(::NominatorSlashInEra::get(&now, &101).is_some()); + + start_era(era); + } + + assert!(::ValidatorSlashInEra::get(&now, &11).is_none()); + assert!(::NominatorSlashInEra::get(&now, &101).is_none()); + }) +} + +#[test] +fn slashing_nominators_by_span_max() { + ExtBuilder::default().build().execute_with(|| { + start_era(1); + start_era(2); + start_era(3); + + assert_eq!(Balances::free_balance(&11), 1000); + assert_eq!(Balances::free_balance(&21), 2000); + assert_eq!(Balances::free_balance(&101), 2000); + assert_eq!(Staking::slashable_balance_of(&21), 1000); + + + let exposure_11 = Staking::stakers(&11); + let exposure_21 = Staking::stakers(&21); + assert_eq!(Balances::free_balance(&101), 2000); + let nominated_value_11 = exposure_11.others.iter().find(|o| o.who == 101).unwrap().value; + let nominated_value_21 = exposure_21.others.iter().find(|o| o.who == 101).unwrap().value; + + on_offence_in_era( + &[ + OffenceDetails { + offender: (11, Staking::stakers(&11)), + reporters: vec![], + }, + ], + &[Perbill::from_percent(10)], + 2, + ); + + assert_eq!(Balances::free_balance(&11), 900); + + let slash_1_amount = Perbill::from_percent(10) * nominated_value_11; + assert_eq!(Balances::free_balance(&101), 2000 - slash_1_amount); + + let expected_spans = vec![ + slashing::SlashingSpan { index: 1, start: 4, length: None }, + slashing::SlashingSpan { index: 0, start: 0, length: Some(4) }, + ]; + + let get_span = |account| ::SlashingSpans::get(&account).unwrap(); + + assert_eq!( + get_span(11).iter().collect::>(), + expected_spans, + ); + + assert_eq!( + get_span(101).iter().collect::>(), + expected_spans, + ); + + // second slash: higher era, higher value, same span. + on_offence_in_era( + &[ + OffenceDetails { + offender: (21, Staking::stakers(&21)), + reporters: vec![], + }, + ], + &[Perbill::from_percent(30)], + 3, + ); + + // 11 was not further slashed, but 21 and 101 were. + assert_eq!(Balances::free_balance(&11), 900); + assert_eq!(Balances::free_balance(&21), 1700); + + let slash_2_amount = Perbill::from_percent(30) * nominated_value_21; + assert!(slash_2_amount > slash_1_amount); + + // only the maximum slash in a single span is taken. + assert_eq!(Balances::free_balance(&101), 2000 - slash_2_amount); + + // third slash: in same era and on same validator as first, higher + // in-era value, but lower slash value than slash 2. + on_offence_in_era( + &[ + OffenceDetails { + offender: (11, Staking::stakers(&11)), + reporters: vec![], + }, + ], + &[Perbill::from_percent(20)], + 2, + ); + + // 11 was further slashed, but 21 and 101 were not. + assert_eq!(Balances::free_balance(&11), 800); + assert_eq!(Balances::free_balance(&21), 1700); + + let slash_3_amount = Perbill::from_percent(20) * nominated_value_21; + assert!(slash_3_amount < slash_2_amount); + assert!(slash_3_amount > slash_1_amount); + + // only the maximum slash in a single span is taken. + assert_eq!(Balances::free_balance(&101), 2000 - slash_2_amount); + }); +} + +#[test] +fn slashes_are_summed_across_spans() { + ExtBuilder::default().build().execute_with(|| { + start_era(1); + start_era(2); + start_era(3); + + assert_eq!(Balances::free_balance(&21), 2000); + assert_eq!(Staking::slashable_balance_of(&21), 1000); + + let get_span = |account| ::SlashingSpans::get(&account).unwrap(); + + on_offence_now( + &[ + OffenceDetails { + offender: (21, Staking::stakers(&21)), + reporters: vec![], + }, + ], + &[Perbill::from_percent(10)], + ); + + let expected_spans = vec![ + slashing::SlashingSpan { index: 1, start: 4, length: None }, + slashing::SlashingSpan { index: 0, start: 0, length: Some(4) }, + ]; + + assert_eq!(get_span(21).iter().collect::>(), expected_spans); + assert_eq!(Balances::free_balance(&21), 1900); + + // 21 has been force-chilled. re-signal intent to validate. + Staking::validate(Origin::signed(20), Default::default()).unwrap(); + + start_era(4); + + assert_eq!(Staking::slashable_balance_of(&21), 900); + + on_offence_now( + &[ + OffenceDetails { + offender: (21, Staking::stakers(&21)), + reporters: vec![], + }, + ], + &[Perbill::from_percent(10)], + ); + + let expected_spans = vec![ + slashing::SlashingSpan { index: 2, start: 5, length: None }, + slashing::SlashingSpan { index: 1, start: 4, length: Some(1) }, + slashing::SlashingSpan { index: 0, start: 0, length: Some(4) }, + ]; + + assert_eq!(get_span(21).iter().collect::>(), expected_spans); + assert_eq!(Balances::free_balance(&21), 1810); + }); +} + +#[test] +fn deferred_slashes_are_deferred() { + ExtBuilder::default().slash_defer_duration(2).build().execute_with(|| { + start_era(1); + + assert_eq!(Balances::free_balance(&11), 1000); + + let exposure = Staking::stakers(&11); + assert_eq!(Balances::free_balance(&101), 2000); + let nominated_value = exposure.others.iter().find(|o| o.who == 101).unwrap().value; + + on_offence_now( + &[ + OffenceDetails { + offender: (11, Staking::stakers(&11)), + reporters: vec![], + }, + ], + &[Perbill::from_percent(10)], + ); + + assert_eq!(Balances::free_balance(&11), 1000); + assert_eq!(Balances::free_balance(&101), 2000); + + start_era(2); + + assert_eq!(Balances::free_balance(&11), 1000); + assert_eq!(Balances::free_balance(&101), 2000); + + start_era(3); + + assert_eq!(Balances::free_balance(&11), 1000); + assert_eq!(Balances::free_balance(&101), 2000); + + // at the start of era 4, slashes from era 1 are processed, + // after being deferred for at least 2 full eras. + start_era(4); + + assert_eq!(Balances::free_balance(&11), 900); + assert_eq!(Balances::free_balance(&101), 2000 - (nominated_value / 10)); + }) +} + +#[test] +fn remove_deferred() { + ExtBuilder::default().slash_defer_duration(2).build().execute_with(|| { + start_era(1); + + assert_eq!(Balances::free_balance(&11), 1000); + + let exposure = Staking::stakers(&11); + assert_eq!(Balances::free_balance(&101), 2000); + let nominated_value = exposure.others.iter().find(|o| o.who == 101).unwrap().value; + + on_offence_now( + &[ + OffenceDetails { + offender: (11, exposure.clone()), + reporters: vec![], + }, + ], + &[Perbill::from_percent(10)], + ); + + assert_eq!(Balances::free_balance(&11), 1000); + assert_eq!(Balances::free_balance(&101), 2000); + + start_era(2); + + on_offence_in_era( + &[ + OffenceDetails { + offender: (11, exposure.clone()), + reporters: vec![], + }, + ], + &[Perbill::from_percent(15)], + 1, + ); + + Staking::cancel_deferred_slash(Origin::ROOT, 1, vec![0]).unwrap(); + + assert_eq!(Balances::free_balance(&11), 1000); + assert_eq!(Balances::free_balance(&101), 2000); + + start_era(3); + + assert_eq!(Balances::free_balance(&11), 1000); + assert_eq!(Balances::free_balance(&101), 2000); + + // at the start of era 4, slashes from era 1 are processed, + // after being deferred for at least 2 full eras. + start_era(4); + + // the first slash for 10% was cancelled, so no effect. + assert_eq!(Balances::free_balance(&11), 1000); + assert_eq!(Balances::free_balance(&101), 2000); + + start_era(5); + + let slash_10 = Perbill::from_percent(10); + let slash_15 = Perbill::from_percent(15); + let initial_slash = slash_10 * nominated_value; + + let total_slash = slash_15 * nominated_value; + let actual_slash = total_slash - initial_slash; + + // 5% slash (15 - 10) processed now. + assert_eq!(Balances::free_balance(&11), 950); + assert_eq!(Balances::free_balance(&101), 2000 - actual_slash); + }) +} + +#[test] +fn remove_multi_deferred() { + ExtBuilder::default().slash_defer_duration(2).build().execute_with(|| { + start_era(1); + + assert_eq!(Balances::free_balance(&11), 1000); + + let exposure = Staking::stakers(&11); + assert_eq!(Balances::free_balance(&101), 2000); + + on_offence_now( + &[ + OffenceDetails { + offender: (11, exposure.clone()), + reporters: vec![], + }, + ], + &[Perbill::from_percent(10)], + ); + + on_offence_now( + &[ + OffenceDetails { + offender: (21, Staking::stakers(&21)), + reporters: vec![], + } + ], + &[Perbill::from_percent(10)], + ); + + + on_offence_now( + &[ + OffenceDetails { + offender: (11, exposure.clone()), + reporters: vec![], + }, + ], + &[Perbill::from_percent(25)], + ); + + assert_eq!(::UnappliedSlashes::get(&1).len(), 3); + Staking::cancel_deferred_slash(Origin::ROOT, 1, vec![0, 2]).unwrap(); + + let slashes = ::UnappliedSlashes::get(&1); + assert_eq!(slashes.len(), 1); + assert_eq!(slashes[0].validator, 21); + }) +} + +#[test] +fn version_initialized() { + ExtBuilder::default().build().execute_with(|| { + assert_eq!(::StorageVersion::get(), crate::migration::CURRENT_VERSION); }); } diff --git a/primitives/sr-staking-primitives/src/offence.rs b/primitives/sr-staking-primitives/src/offence.rs index db51f75df1bf3..04d887fbe09fc 100644 --- a/primitives/sr-staking-primitives/src/offence.rs +++ b/primitives/sr-staking-primitives/src/offence.rs @@ -117,9 +117,12 @@ pub trait OnOffenceHandler { /// the authorities should be slashed and is computed /// according to the `OffenceCount` already. This is of the same length as `offenders.` /// Zero is a valid value for a fraction. + /// + /// The `session` parameter is the session index of the offence. fn on_offence( offenders: &[OffenceDetails], slash_fraction: &[Perbill], + session: SessionIndex, ); } @@ -127,6 +130,7 @@ impl OnOffenceHandler for () { fn on_offence( _offenders: &[OffenceDetails], _slash_fraction: &[Perbill], + _session: SessionIndex, ) {} }