From 613d0d49ecc763f408e51254eb9b64288e21cdef Mon Sep 17 00:00:00 2001 From: bgallois <benjamin@gallois.cc> Date: Fri, 5 May 2023 18:01:55 +0200 Subject: [PATCH] wip handle offence pallet_im_online --- Cargo.lock | 26 ++- pallets/authority-members/Cargo.toml | 2 + pallets/authority-members/src/impls.rs | 68 ++++++ pallets/authority-members/src/lib.rs | 19 +- pallets/authority-members/src/mock.rs | 13 ++ pallets/authority-members/src/tests.rs | 254 +++++++++++++++++++++- pallets/offences/Cargo.toml | 46 ++++ pallets/offences/src/lib.rs | 246 +++++++++++++++++++++ pallets/offences/src/mock.rs | 159 ++++++++++++++ pallets/offences/src/tests.rs | 286 +++++++++++++++++++++++++ pallets/offences/src/traits.rs | 12 ++ runtime/common/src/pallets_config.rs | 2 +- runtime/gdev/Cargo.toml | 2 +- 13 files changed, 1127 insertions(+), 8 deletions(-) create mode 100644 pallets/authority-members/src/impls.rs create mode 100644 pallets/offences/Cargo.toml create mode 100644 pallets/offences/src/lib.rs create mode 100644 pallets/offences/src/mock.rs create mode 100644 pallets/offences/src/tests.rs create mode 100644 pallets/offences/src/traits.rs diff --git a/Cargo.lock b/Cargo.lock index 5edc011c7..50ca8a3ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2502,7 +2502,7 @@ dependencies = [ "pallet-im-online", "pallet-membership", "pallet-multisig", - "pallet-offences", + "pallet-offences 4.0.0-dev (git+https://github.com/duniter/substrate?branch=duniter-substrate-v0.9.32)", "pallet-oneshot-account", "pallet-preimage", "pallet-provide-randomness", @@ -2571,7 +2571,7 @@ dependencies = [ "pallet-im-online", "pallet-membership", "pallet-multisig", - "pallet-offences", + "pallet-offences 4.0.0-dev", "pallet-oneshot-account", "pallet-preimage", "pallet-provide-randomness", @@ -2865,7 +2865,7 @@ dependencies = [ "pallet-im-online", "pallet-membership", "pallet-multisig", - "pallet-offences", + "pallet-offences 4.0.0-dev (git+https://github.com/duniter/substrate?branch=duniter-substrate-v0.9.32)", "pallet-oneshot-account", "pallet-preimage", "pallet-provide-randomness", @@ -5031,6 +5031,7 @@ dependencies = [ "frame-system", "log", "maplit", + "pallet-offences 4.0.0-dev", "pallet-session", "parity-scale-codec", "scale-info", @@ -5296,7 +5297,6 @@ dependencies = [ [[package]] name = "pallet-offences" version = "4.0.0-dev" -source = "git+https://github.com/duniter/substrate?branch=duniter-substrate-v0.9.32#7f8b8db65b441ce1d1b2ffb26ebde314b54e117c" dependencies = [ "frame-support", "frame-system", @@ -5305,6 +5305,24 @@ dependencies = [ "parity-scale-codec", "scale-info", "serde", + "sp-core", + "sp-io", + "sp-runtime", + "sp-staking", + "sp-std", +] + +[[package]] +name = "pallet-offences" +version = "4.0.0-dev" +source = "git+https://github.com/duniter/substrate?branch=duniter-substrate-v0.9.32#7f8b8db65b441ce1d1b2ffb26ebde314b54e117c" +dependencies = [ + "frame-support", + "frame-system", + "log", + "pallet-balances", + "parity-scale-codec", + "scale-info", "sp-runtime", "sp-staking", "sp-std", diff --git a/pallets/authority-members/Cargo.toml b/pallets/authority-members/Cargo.toml index 485f2b203..94040f67c 100644 --- a/pallets/authority-members/Cargo.toml +++ b/pallets/authority-members/Cargo.toml @@ -19,6 +19,7 @@ std = [ 'frame-benchmarking/std', 'log/std', 'pallet-session/std', + 'pallet-offences/std', 'serde', 'sp-core/std', 'sp-membership/std', @@ -30,6 +31,7 @@ try-runtime = ['frame-support/try-runtime'] [dependencies] # local +pallet-offences = { path = "../offences", default-features = false } sp-membership = { path = "../../primitives/membership", default-features = false } # crates.io diff --git a/pallets/authority-members/src/impls.rs b/pallets/authority-members/src/impls.rs new file mode 100644 index 000000000..6d0cccb54 --- /dev/null +++ b/pallets/authority-members/src/impls.rs @@ -0,0 +1,68 @@ +// Copyright 2021 Axiom-Team +// +// This file is part of Duniter-v2S. +// +// Duniter-v2S is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// Duniter-v2S 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Duniter-v2S. If not, see <https://www.gnu.org/licenses/>. + +#![cfg_attr(not(feature = "std"), no_std)] +#![allow(clippy::type_complexity)] + +use super::pallet::*; +use frame_support::pallet_prelude::Weight; +use frame_support::traits::Get; +use pallet_offences::traits::OnOffenceHandler; +use pallet_offences::SlashStrategy; +use sp_runtime::traits::Convert; +use sp_staking::offence::OffenceDetails; +use sp_staking::SessionIndex; + +// This is intended to be used with `FilterHistoricalOffences`. +impl<T: Config> + OnOffenceHandler<T::AccountId, pallet_session::historical::IdentificationTuple<T>, Weight> + for Pallet<T> +where + T: pallet_session::Config<ValidatorId = <T as frame_system::Config>::AccountId>, +{ + fn on_offence( + offenders: &[OffenceDetails< + T::AccountId, + pallet_session::historical::IdentificationTuple<T>, + >], + strategy: SlashStrategy, + _slash_session: SessionIndex, + ) -> Weight { + match strategy { + SlashStrategy::BlackList => { + for offender in offenders { + BlackList::<T>::mutate(|blacklist| { + if let Some(member_id) = T::MemberIdOf::convert(offender.offender.0.clone()) + { + if !blacklist.contains(&member_id) { + blacklist.push(member_id.clone()); + } + Self::insert_out(member_id); + } + }) + } + } + SlashStrategy::Disconnect => { + for offender in offenders { + if let Some(member_id) = T::MemberIdOf::convert(offender.offender.0.clone()) { + Self::insert_out(member_id); + } + } + } + } + T::DbWeight::get().reads(3) // TODO + } +} diff --git a/pallets/authority-members/src/lib.rs b/pallets/authority-members/src/lib.rs index 21b23b64d..66c18493e 100644 --- a/pallets/authority-members/src/lib.rs +++ b/pallets/authority-members/src/lib.rs @@ -29,6 +29,9 @@ mod tests; /*#[cfg(feature = "runtime-benchmarks")] mod benchmarking;*/ +mod impls; +pub use impls::*; + pub use pallet::*; pub use types::*; @@ -172,6 +175,11 @@ pub mod pallet { pub type MustRotateKeysBefore<T: Config> = StorageMap<_, Twox64Concat, SessionIndex, Vec<T::MemberId>, ValueQuery>; + // Blacklist. + #[pallet::storage] + #[pallet::getter(fn blacklist)] + pub type BlackList<T: Config> = StorageValue<_, Vec<T::MemberId>, ValueQuery>; + // HOOKS // // EVENTS // @@ -210,6 +218,8 @@ pub mod pallet { /// Not found owner key MemberIdNotFound, /// Member not found + MemberIdBlackListed, + /// Member is blacklisted MemberNotFound, /// Neither online nor scheduled NotOnlineNorIncoming, @@ -261,6 +271,9 @@ pub mod pallet { let who = ensure_signed(origin)?; let member_id = Self::verify_ownership_and_membership(&who)?; + if Self::is_blacklisted(member_id) { + return Err(Error::<T>::MemberIdBlackListed.into()); + } if !Members::<T>::contains_key(member_id) { return Err(Error::<T>::MemberNotFound.into()); } @@ -449,7 +462,7 @@ pub mod pallet { not_already_inserted } /// perform outgoing authority insertion - fn insert_out(member_id: T::MemberId) -> bool { + pub fn insert_out(member_id: T::MemberId) -> bool { let not_already_inserted = OutgoingAuthorities::<T>::mutate(|members_ids| { if let Err(index) = members_ids.binary_search(&member_id) { members_ids.insert(index, member_id); @@ -486,6 +499,10 @@ pub mod pallet { .binary_search(&member_id) .is_ok() } + /// check if member is blacklisted + fn is_blacklisted(member_id: T::MemberId) -> bool { + BlackList::<T>::get().contains(&member_id) + } /// perform removal from incoming authorities fn remove_in(member_id: T::MemberId) { AuthoritiesCounter::<T>::mutate(|counter| counter.saturating_sub(1)); diff --git a/pallets/authority-members/src/mock.rs b/pallets/authority-members/src/mock.rs index d28782a4a..46d8c2577 100644 --- a/pallets/authority-members/src/mock.rs +++ b/pallets/authority-members/src/mock.rs @@ -23,6 +23,8 @@ use frame_support::{ BasicExternalities, }; use frame_system as system; +use pallet_offences::traits::OnOffenceHandler; +use pallet_offences::SlashStrategy; use pallet_session::ShouldEndSession; use sp_core::{crypto::key_types::DUMMY, H256}; use sp_runtime::{ @@ -31,6 +33,7 @@ use sp_runtime::{ traits::{BlakeTwo256, ConvertInto, IdentityLookup, IsMember, OpaqueKeys}, KeyTypeId, }; +use sp_staking::offence::OffenceDetails; type AccountId = u64; type Block = frame_system::mocking::MockBlock<Test>; @@ -204,3 +207,13 @@ pub fn run_to_block(n: u64) { Session::on_initialize(System::block_number()); } } + +pub(crate) fn on_offence( + offenders: &[OffenceDetails< + AccountId, + pallet_session::historical::IdentificationTuple<Test>, + >], + slash_strategy: SlashStrategy, +) { + AuthorityMembers::on_offence(offenders, slash_strategy, 0); +} diff --git a/pallets/authority-members/src/tests.rs b/pallets/authority-members/src/tests.rs index d26c3f410..9055f733a 100644 --- a/pallets/authority-members/src/tests.rs +++ b/pallets/authority-members/src/tests.rs @@ -17,8 +17,9 @@ use super::*; use crate::mock::*; use crate::MemberData; -use frame_support::{assert_noop, assert_ok}; +use frame_support::{assert_err, assert_noop, assert_ok}; use sp_runtime::testing::UintAuthorityId; +use sp_staking::offence::OffenceDetails; const EMPTY: Vec<u64> = Vec::new(); @@ -178,6 +179,257 @@ fn test_go_offline() { }); } +#[test] +fn test_go_offline_at_offence_with_black_list() { + new_test_ext(3).execute_with(|| { + run_to_block(1); + + on_offence( + &[OffenceDetails { + offender: (9, ()), + reporters: vec![], + }], + pallet_offences::SlashStrategy::BlackList, + ); + + // Verify state + assert_eq!(AuthorityMembers::incoming(), EMPTY); + assert_eq!(AuthorityMembers::online(), vec![3, 6, 9]); + assert_eq!(AuthorityMembers::outgoing(), vec![9]); + assert_eq!(AuthorityMembers::blacklist(), vec![9]); + assert_eq!( + AuthorityMembers::member(9), + Some(MemberData { + expire_on_session: 0, + must_rotate_keys_before: 5, + owner_key: 9, + }) + ); + + // Member 9 should be "deprogrammed" at the next session + run_to_block(5); + assert_eq!( + AuthorityMembers::member(9), + Some(MemberData { + expire_on_session: 4, + must_rotate_keys_before: 5, + owner_key: 9, + }) + ); + assert_eq!(AuthorityMembers::members_expire_on(4), vec![9],); + assert_eq!(Session::current_index(), 1); + assert_eq!(Session::validators(), vec![3, 6, 9]); + assert_eq!(Session::queued_keys().len(), 2); + assert_eq!(Session::queued_keys()[0].0, 3); + assert_eq!(Session::queued_keys()[1].0, 6); + + // Member 9 should be **effectively** out at session 2 + run_to_block(10); + assert_eq!(Session::current_index(), 2); + assert_eq!(Session::validators(), vec![3, 6]); + + // Member 9 should be removed at session 4 + run_to_block(20); + assert_eq!(Session::current_index(), 4); + assert_eq!(Session::validators(), vec![3, 6]); + assert_eq!(AuthorityMembers::members_expire_on(4), EMPTY); + assert_eq!(AuthorityMembers::member(9), None); + }); +} + +#[test] +fn test_black_list_ok() { + new_test_ext(3).execute_with(|| { + run_to_block(1); + + on_offence( + &[OffenceDetails { + offender: (9, ()), + reporters: vec![], + }], + pallet_offences::SlashStrategy::Disconnect, + ); + + // Verify state + assert_eq!(AuthorityMembers::incoming(), EMPTY); + assert_eq!(AuthorityMembers::online(), vec![3, 6, 9]); + assert_eq!(AuthorityMembers::outgoing(), vec![9]); + assert_eq!(AuthorityMembers::blacklist(), EMPTY); + assert_eq!( + AuthorityMembers::member(9), + Some(MemberData { + expire_on_session: 0, + must_rotate_keys_before: 5, + owner_key: 9, + }) + ); + + // Member 9 should be "deprogrammed" at the next session + run_to_block(5); + assert_eq!( + AuthorityMembers::member(9), + Some(MemberData { + expire_on_session: 4, + must_rotate_keys_before: 5, + owner_key: 9, + }) + ); + assert_eq!(AuthorityMembers::members_expire_on(4), vec![9],); + assert_eq!(Session::current_index(), 1); + assert_eq!(Session::validators(), vec![3, 6, 9]); + assert_eq!(Session::queued_keys().len(), 2); + assert_eq!(Session::queued_keys()[0].0, 3); + assert_eq!(Session::queued_keys()[1].0, 6); + + // Member 9 should be **effectively** out at session 2 + run_to_block(10); + assert_eq!(Session::current_index(), 2); + assert_eq!(Session::validators(), vec![3, 6]); + + // Member 9 should be removed at session 4 + run_to_block(20); + assert_eq!(Session::current_index(), 4); + assert_eq!(Session::validators(), vec![3, 6]); + assert_eq!(AuthorityMembers::members_expire_on(4), EMPTY); + assert_eq!(AuthorityMembers::member(9), None); + + // Member 9 should be allowed to go online + run_to_block(25); + assert_ok!(AuthorityMembers::set_session_keys( + RuntimeOrigin::signed(9), + UintAuthorityId(9).into(), + )); + assert_ok!(AuthorityMembers::go_online(RuntimeOrigin::signed(9)),); + }); +} + +#[test] +fn test_black_list_err() { + new_test_ext(3).execute_with(|| { + run_to_block(1); + + on_offence( + &[OffenceDetails { + offender: (9, ()), + reporters: vec![], + }], + pallet_offences::SlashStrategy::BlackList, + ); + + // Verify state + assert_eq!(AuthorityMembers::incoming(), EMPTY); + assert_eq!(AuthorityMembers::online(), vec![3, 6, 9]); + assert_eq!(AuthorityMembers::outgoing(), vec![9]); + assert_eq!(AuthorityMembers::blacklist(), vec![9]); + assert_eq!( + AuthorityMembers::member(9), + Some(MemberData { + expire_on_session: 0, + must_rotate_keys_before: 5, + owner_key: 9, + }) + ); + + // Member 9 should be "deprogrammed" at the next session + run_to_block(5); + assert_eq!( + AuthorityMembers::member(9), + Some(MemberData { + expire_on_session: 4, + must_rotate_keys_before: 5, + owner_key: 9, + }) + ); + assert_eq!(AuthorityMembers::members_expire_on(4), vec![9],); + assert_eq!(Session::current_index(), 1); + assert_eq!(Session::validators(), vec![3, 6, 9]); + assert_eq!(Session::queued_keys().len(), 2); + assert_eq!(Session::queued_keys()[0].0, 3); + assert_eq!(Session::queued_keys()[1].0, 6); + + // Member 9 should be **effectively** out at session 2 + run_to_block(10); + assert_eq!(Session::current_index(), 2); + assert_eq!(Session::validators(), vec![3, 6]); + + // Member 9 should be removed at session 4 + run_to_block(20); + assert_eq!(Session::current_index(), 4); + assert_eq!(Session::validators(), vec![3, 6]); + assert_eq!(AuthorityMembers::members_expire_on(4), EMPTY); + assert_eq!(AuthorityMembers::member(9), None); + + // Member 9 should not be allowed to go online + run_to_block(25); + assert_ok!(AuthorityMembers::set_session_keys( + RuntimeOrigin::signed(9), + UintAuthorityId(9).into(), + )); + assert_err!( + AuthorityMembers::go_online(RuntimeOrigin::signed(9)), + Error::<Test>::MemberIdBlackListed + ); + }); +} + +#[test] +fn test_go_offline_at_offence_no_black_list() { + new_test_ext(3).execute_with(|| { + run_to_block(1); + + on_offence( + &[OffenceDetails { + offender: (9, ()), + reporters: vec![], + }], + pallet_offences::SlashStrategy::Disconnect, + ); + + // Verify state + assert_eq!(AuthorityMembers::incoming(), EMPTY); + assert_eq!(AuthorityMembers::online(), vec![3, 6, 9]); + assert_eq!(AuthorityMembers::outgoing(), vec![9]); + assert_eq!(AuthorityMembers::blacklist(), EMPTY); + assert_eq!( + AuthorityMembers::member(9), + Some(MemberData { + expire_on_session: 0, + must_rotate_keys_before: 5, + owner_key: 9, + }) + ); + + // Member 9 should be "deprogrammed" at the next session + run_to_block(5); + assert_eq!( + AuthorityMembers::member(9), + Some(MemberData { + expire_on_session: 4, + must_rotate_keys_before: 5, + owner_key: 9, + }) + ); + assert_eq!(AuthorityMembers::members_expire_on(4), vec![9],); + assert_eq!(Session::current_index(), 1); + assert_eq!(Session::validators(), vec![3, 6, 9]); + assert_eq!(Session::queued_keys().len(), 2); + assert_eq!(Session::queued_keys()[0].0, 3); + assert_eq!(Session::queued_keys()[1].0, 6); + + // Member 9 should be **effectively** out at session 2 + run_to_block(10); + assert_eq!(Session::current_index(), 2); + assert_eq!(Session::validators(), vec![3, 6]); + + // Member 9 should be removed at session 4 + run_to_block(20); + assert_eq!(Session::current_index(), 4); + assert_eq!(Session::validators(), vec![3, 6]); + assert_eq!(AuthorityMembers::members_expire_on(4), EMPTY); + assert_eq!(AuthorityMembers::member(9), None); + }); +} + #[test] fn test_go_online() { new_test_ext(3).execute_with(|| { diff --git a/pallets/offences/Cargo.toml b/pallets/offences/Cargo.toml new file mode 100644 index 000000000..60262da7f --- /dev/null +++ b/pallets/offences/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "pallet-offences" +version = "4.0.0-dev" +authors = ["Parity Technologies <admin@parity.io>"] +edition = "2021" +license = "Apache-2.0" +homepage = "https://substrate.io" +repository = "https://github.com/paritytech/substrate/" +description = "FRAME offences pallet" +readme = "README.md" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { package = "parity-scale-codec", version = "3.1.5", features = ["derive"], default-features = false } +log = { version = "0.4.17", default-features = false } +scale-info = { version = "2.1.1", default-features = false, features = ["derive"] } +serde = { version = "1.0.101", default-features = false, optional = true } +frame-support = { git = 'https://github.com/duniter/substrate', branch = 'duniter-substrate-v0.9.32', default-features = false } +frame-system = { git = 'https://github.com/duniter/substrate', branch = 'duniter-substrate-v0.9.32', default-features = false } +pallet-balances = { git = 'https://github.com/duniter/substrate', branch = 'duniter-substrate-v0.9.32', default-features = false } +sp-runtime = { git = 'https://github.com/duniter/substrate', branch = 'duniter-substrate-v0.9.32', default-features = false } +sp-staking = { git = 'https://github.com/duniter/substrate', branch = 'duniter-substrate-v0.9.32', default-features = false } +sp-std = { git = 'https://github.com/duniter/substrate', branch = 'duniter-substrate-v0.9.32', default-features = false } + +[dev-dependencies] +sp-core = { git = 'https://github.com/duniter/substrate', branch = 'duniter-substrate-v0.9.32', default-features = false } +sp-io = { git = 'https://github.com/duniter/substrate', branch = 'duniter-substrate-v0.9.32' } + +[features] +default = ["std"] +std = [ + "codec/std", + "frame-support/std", + "frame-system/std", + "log/std", + "pallet-balances/std", + "scale-info/std", + "serde", + "sp-runtime/std", + "sp-staking/std", + "sp-std/std", +] +runtime-benchmarks = [] +try-runtime = ["frame-support/try-runtime"] diff --git a/pallets/offences/src/lib.rs b/pallets/offences/src/lib.rs new file mode 100644 index 000000000..207acbd6c --- /dev/null +++ b/pallets/offences/src/lib.rs @@ -0,0 +1,246 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +/*#[cfg(feature = "runtime-benchmarks")] +mod benchmarking;*/ + +use core::marker::PhantomData; + +use codec::Encode; +use frame_support::weights::Weight; +use sp_runtime::traits::Hash; +use sp_staking::offence::{Kind, Offence, OffenceDetails, OffenceError, ReportOffence}; +use sp_std::prelude::*; + +pub use pallet::*; + +pub mod traits; +use self::traits::*; + +/// A binary blob which represents a SCALE codec-encoded `O::TimeSlot`. +type OpaqueTimeSlot = Vec<u8>; + +/// A type alias for a report identifier. +type ReportIdOf<T> = <T as frame_system::Config>::Hash; + +pub enum SlashStrategy { + Disconnect, + BlackList, +} + +#[frame_support::pallet] +pub mod pallet { + use super::*; + use frame_support::pallet_prelude::*; + + const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); + + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] + #[pallet::without_storage_info] + pub struct Pallet<T>(_); + + /// The pallet's config trait. + #[pallet::config] + pub trait Config: frame_system::Config { + /// The overarching event type. + type RuntimeEvent: From<Event> + IsType<<Self as frame_system::Config>::RuntimeEvent>; + /// Full identification of the validator. + type IdentificationTuple: Parameter; + /// A handler called for every offence report. + type OnOffenceHandler: OnOffenceHandler<Self::AccountId, Self::IdentificationTuple, Weight>; + } + + /// The primary structure that holds all offence records keyed by report identifiers. + #[pallet::storage] + #[pallet::getter(fn reports)] + pub type Reports<T: Config> = StorageMap< + _, + Twox64Concat, + ReportIdOf<T>, + OffenceDetails<T::AccountId, T::IdentificationTuple>, + >; + + /// A vector of reports of the same kind that happened at the same time slot. + #[pallet::storage] + pub type ConcurrentReportsIndex<T: Config> = StorageDoubleMap< + _, + Twox64Concat, + Kind, + Twox64Concat, + OpaqueTimeSlot, + Vec<ReportIdOf<T>>, + ValueQuery, + >; + + /// Events type. + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// There is an offence reported of the given `kind` happened at the `session_index` and + /// (kind-specific) time slot. This event is not deposited for duplicate slashes. + /// \[kind, timeslot\]. + Offence { + kind: Kind, + timeslot: OpaqueTimeSlot, + }, + } +} + +impl<T, O> ReportOffence<T::AccountId, T::IdentificationTuple, O> for Pallet<T> +where + T: Config, + O: Offence<T::IdentificationTuple>, +{ + fn report_offence(reporters: Vec<T::AccountId>, offence: O) -> Result<(), OffenceError> { + let offenders = offence.offenders(); + let time_slot = offence.time_slot(); + + // Go through all offenders in the offence report and find all offenders that were spotted + // in unique reports. + let TriageOutcome { + concurrent_offenders, + } = match Self::triage_offence_report::<O>(reporters, &time_slot, offenders) { + Some(triage) => triage, + None => return Err(OffenceError::DuplicateReport), + }; + + // Define the slash strategy. + let slash_strategy = if O::ID != *b"im-online:offlin" { + SlashStrategy::Disconnect + } else { + SlashStrategy::BlackList + }; + + T::OnOffenceHandler::on_offence( + &concurrent_offenders, + slash_strategy, + offence.session_index(), + ); + + Self::deposit_event(Event::Offence { + kind: O::ID, + timeslot: time_slot.encode(), + }); + + Ok(()) + } + + fn is_known_offence(offenders: &[T::IdentificationTuple], time_slot: &O::TimeSlot) -> bool { + let any_unknown = offenders.iter().any(|offender| { + let report_id = Self::report_id::<O>(time_slot, offender); + !<Reports<T>>::contains_key(&report_id) + }); + + !any_unknown + } +} + +impl<T: Config> Pallet<T> { + /// Compute the ID for the given report properties. + /// + /// The report id depends on the offence kind, time slot and the id of offender. + fn report_id<O: Offence<T::IdentificationTuple>>( + time_slot: &O::TimeSlot, + offender: &T::IdentificationTuple, + ) -> ReportIdOf<T> { + (O::ID, time_slot.encode(), offender).using_encoded(T::Hashing::hash) + } + + /// Triages the offence report and returns the set of offenders that was involved in unique + /// reports along with the list of the concurrent offences. + fn triage_offence_report<O: Offence<T::IdentificationTuple>>( + reporters: Vec<T::AccountId>, + time_slot: &O::TimeSlot, + offenders: Vec<T::IdentificationTuple>, + ) -> Option<TriageOutcome<T>> { + let mut storage = ReportIndexStorage::<T, O>::load(time_slot); + + let mut any_new = false; + for offender in offenders { + let report_id = Self::report_id::<O>(time_slot, &offender); + + if !<Reports<T>>::contains_key(&report_id) { + any_new = true; + <Reports<T>>::insert( + &report_id, + OffenceDetails { + offender, + reporters: reporters.clone(), + }, + ); + + storage.insert(report_id); + } + } + + if any_new { + // Load report details for the all reports happened at the same time. + let concurrent_offenders = storage + .concurrent_reports + .iter() + .filter_map(<Reports<T>>::get) + .collect::<Vec<_>>(); + + storage.save(); + + Some(TriageOutcome { + concurrent_offenders, + }) + } else { + None + } + } +} + +struct TriageOutcome<T: Config> { + /// Other reports for the same report kinds. + concurrent_offenders: Vec<OffenceDetails<T::AccountId, T::IdentificationTuple>>, +} + +/// An auxiliary struct for working with storage of indexes localized for a specific offence +/// kind (specified by the `O` type parameter). +/// +/// This struct is responsible for aggregating storage writes and the underlying storage should not +/// accessed directly meanwhile. +#[must_use = "The changes are not saved without called `save`"] +struct ReportIndexStorage<T: Config, O: Offence<T::IdentificationTuple>> { + opaque_time_slot: OpaqueTimeSlot, + concurrent_reports: Vec<ReportIdOf<T>>, + _phantom: PhantomData<O>, +} + +impl<T: Config, O: Offence<T::IdentificationTuple>> ReportIndexStorage<T, O> { + /// Preload indexes from the storage for the specific `time_slot` and the kind of the offence. + fn load(time_slot: &O::TimeSlot) -> Self { + let opaque_time_slot = time_slot.encode(); + + let concurrent_reports = <ConcurrentReportsIndex<T>>::get(&O::ID, &opaque_time_slot); + + Self { + opaque_time_slot, + concurrent_reports, + _phantom: Default::default(), + } + } + + /// Insert a new report to the index. + fn insert(&mut self, report_id: ReportIdOf<T>) { + // Update the list of concurrent reports. + self.concurrent_reports.push(report_id); + } + + /// Dump the indexes to the storage. + fn save(self) { + <ConcurrentReportsIndex<T>>::insert( + &O::ID, + &self.opaque_time_slot, + &self.concurrent_reports, + ); + } +} diff --git a/pallets/offences/src/mock.rs b/pallets/offences/src/mock.rs new file mode 100644 index 000000000..d461e840c --- /dev/null +++ b/pallets/offences/src/mock.rs @@ -0,0 +1,159 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Test utilities + +#![cfg(test)] + +use crate::Config; +use crate::{self as pallet_offences, SlashStrategy}; +use codec::Encode; +use frame_support::{ + parameter_types, + traits::{ConstU32, ConstU64}, + weights::{constants::RocksDbWeight, Weight}, +}; +use sp_core::H256; +use sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, + Perbill, +}; +use sp_staking::{ + offence::{Kind, OffenceDetails}, + SessionIndex, +}; + +pub struct OnOffenceHandler; + +parameter_types! { + pub static OnOffencePerbill: Vec<Perbill> = Default::default(); + pub static OffenceWeight: Weight = Default::default(); +} + +impl<Reporter, Offender> pallet_offences::OnOffenceHandler<Reporter, Offender, Weight> + for OnOffenceHandler +{ + fn on_offence( + _offenders: &[OffenceDetails<Reporter, Offender>], + _strategy: SlashStrategy, + _offence_session: SessionIndex, + ) -> Weight { + OffenceWeight::get() + } +} + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic<Runtime>; +type Block = frame_system::mocking::MockBlock<Runtime>; + +frame_support::construct_runtime!( + pub struct Runtime where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system::{Pallet, Call, Config, Storage, Event<T>}, + Offences: pallet_offences::{Pallet, Storage, Event}, + } +); + +impl frame_system::Config for Runtime { + type BaseCallFilter = frame_support::traits::Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = RocksDbWeight; + type RuntimeOrigin = RuntimeOrigin; + type Index = u64; + type BlockNumber = u64; + type RuntimeCall = RuntimeCall; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup<Self::AccountId>; + type Header = Header; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = ConstU64<250>; + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = (); + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = ConstU32<16>; +} + +impl Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type IdentificationTuple = u64; + type OnOffenceHandler = OnOffenceHandler; +} + +pub fn new_test_ext() -> sp_io::TestExternalities { + let t = frame_system::GenesisConfig::default() + .build_storage::<Runtime>() + .unwrap(); + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| System::set_block_number(1)); + ext +} + +pub const KIND: [u8; 16] = *b"test_report_1234"; + +/// Returns all offence details for the specific `kind` happened at the specific time slot. +pub fn offence_reports(kind: Kind, time_slot: u128) -> Vec<OffenceDetails<u64, u64>> { + <crate::ConcurrentReportsIndex<Runtime>>::get(&kind, &time_slot.encode()) + .into_iter() + .map(|report_id| { + <crate::Reports<Runtime>>::get(&report_id) + .expect("dangling report id is found in ConcurrentReportsIndex") + }) + .collect() +} + +#[derive(Clone)] +pub struct Offence { + pub validator_set_count: u32, + pub offenders: Vec<u64>, + pub time_slot: u128, +} + +impl pallet_offences::Offence<u64> for Offence { + const ID: pallet_offences::Kind = KIND; + type TimeSlot = u128; + + fn offenders(&self) -> Vec<u64> { + self.offenders.clone() + } + + fn validator_set_count(&self) -> u32 { + self.validator_set_count + } + + fn time_slot(&self) -> u128 { + self.time_slot + } + + fn session_index(&self) -> SessionIndex { + 1 + } + + fn slash_fraction(&self, offenders_count: u32) -> Perbill { + Perbill::from_percent(5 + offenders_count * 100 / self.validator_set_count) + } +} diff --git a/pallets/offences/src/tests.rs b/pallets/offences/src/tests.rs new file mode 100644 index 000000000..c7da5a768 --- /dev/null +++ b/pallets/offences/src/tests.rs @@ -0,0 +1,286 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Tests for the offences module. + +#![cfg(test)] + +use super::*; +use crate::mock::{new_test_ext, offence_reports, Offence, Offences, RuntimeEvent, System, KIND}; +use frame_system::{EventRecord, Phase}; + +#[test] +fn should_report_an_authority_and_trigger_on_offence_and_add_to_blacklist() { + new_test_ext().execute_with(|| { + // given + let time_slot = 42; + assert_eq!(offence_reports(KIND, time_slot), vec![]); + + let offence = Offence { + validator_set_count: 5, + time_slot, + offenders: vec![5, 9], + }; + + // when + Offences::report_offence(vec![], offence).unwrap(); + + // then + }); +} + +#[test] +fn should_not_report_the_same_authority_twice_in_the_same_slot() { + new_test_ext().execute_with(|| { + // given + let time_slot = 42; + assert_eq!(offence_reports(KIND, time_slot), vec![]); + + let offence = Offence { + validator_set_count: 5, + time_slot, + offenders: vec![5], + }; + Offences::report_offence(vec![], offence.clone()).unwrap(); + + // when + // report for the second time + assert_eq!( + Offences::report_offence(vec![], offence), + Err(OffenceError::DuplicateReport) + ); + + // then + }); +} + +#[test] +fn should_report_in_different_time_slot() { + new_test_ext().execute_with(|| { + // given + let time_slot = 42; + assert_eq!(offence_reports(KIND, time_slot), vec![]); + + let mut offence = Offence { + validator_set_count: 5, + time_slot, + offenders: vec![5], + }; + Offences::report_offence(vec![], offence.clone()).unwrap(); + System::assert_last_event( + Event::Offence { + kind: KIND, + timeslot: time_slot.encode(), + } + .into(), + ); + + // when + // report for the second time + offence.time_slot += 1; + Offences::report_offence(vec![], offence.clone()).unwrap(); + + // then + System::assert_last_event( + Event::Offence { + kind: KIND, + timeslot: offence.time_slot.encode(), + } + .into(), + ); + }); +} + +#[test] +fn should_deposit_event() { + new_test_ext().execute_with(|| { + // given + let time_slot = 42; + assert_eq!(offence_reports(KIND, time_slot), vec![]); + + let offence = Offence { + validator_set_count: 5, + time_slot, + offenders: vec![5], + }; + + // when + Offences::report_offence(vec![], offence).unwrap(); + + // then + assert_eq!( + System::events(), + vec![EventRecord { + phase: Phase::Initialization, + event: RuntimeEvent::Offences(crate::Event::Offence { + kind: KIND, + timeslot: time_slot.encode() + }), + topics: vec![], + }] + ); + }); +} + +#[test] +fn doesnt_deposit_event_for_dups() { + new_test_ext().execute_with(|| { + // given + let time_slot = 42; + assert_eq!(offence_reports(KIND, time_slot), vec![]); + + let offence = Offence { + validator_set_count: 5, + time_slot, + offenders: vec![5], + }; + Offences::report_offence(vec![], offence.clone()).unwrap(); + + // when + // report for the second time + assert_eq!( + Offences::report_offence(vec![], offence), + Err(OffenceError::DuplicateReport) + ); + + // then + // there is only one event. + assert_eq!( + System::events(), + vec![EventRecord { + phase: Phase::Initialization, + event: RuntimeEvent::Offences(crate::Event::Offence { + kind: KIND, + timeslot: time_slot.encode() + }), + topics: vec![], + }] + ); + }); +} + +#[test] +fn reports_if_an_offence_is_dup() { + new_test_ext().execute_with(|| { + let time_slot = 42; + assert_eq!(offence_reports(KIND, time_slot), vec![]); + + let offence = |time_slot, offenders| Offence { + validator_set_count: 5, + time_slot, + offenders, + }; + + let mut test_offence = offence(time_slot, vec![0]); + + // the report for authority 0 at time slot 42 should not be a known + // offence + assert!( + !<Offences as ReportOffence<_, _, Offence>>::is_known_offence( + &test_offence.offenders, + &test_offence.time_slot + ) + ); + + // we report an offence for authority 0 at time slot 42 + Offences::report_offence(vec![], test_offence.clone()).unwrap(); + + // the same report should be a known offence now + assert!( + <Offences as ReportOffence<_, _, Offence>>::is_known_offence( + &test_offence.offenders, + &test_offence.time_slot + ) + ); + + // and reporting it again should yield a duplicate report error + assert_eq!( + Offences::report_offence(vec![], test_offence.clone()), + Err(OffenceError::DuplicateReport) + ); + + // after adding a new offender to the offence report + test_offence.offenders.push(1); + + // it should not be a known offence anymore + assert!( + !<Offences as ReportOffence<_, _, Offence>>::is_known_offence( + &test_offence.offenders, + &test_offence.time_slot + ) + ); + + // and reporting it again should work without any error + assert_eq!( + Offences::report_offence(vec![], test_offence.clone()), + Ok(()) + ); + + // creating a new offence for the same authorities on the next slot + // should be considered a new offence and thefore not known + let test_offence_next_slot = offence(time_slot + 1, vec![0, 1]); + assert!( + !<Offences as ReportOffence<_, _, Offence>>::is_known_offence( + &test_offence_next_slot.offenders, + &test_offence_next_slot.time_slot + ) + ); + }); +} + +#[test] +fn should_properly_count_offences() { + // We report two different authorities for the same issue. Ultimately, the 1st authority + // should have `count` equal 2 and the count of the 2nd one should be equal to 1. + 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], + }; + Offences::report_offence(vec![], offence1).unwrap(); + + // when + // report for the second time + Offences::report_offence(vec![], offence2).unwrap(); + + // then + // the 1st authority should have count 2 and the 2nd one should be reported only once. + assert_eq!( + offence_reports(KIND, time_slot), + vec![ + OffenceDetails { + offender: 5, + reporters: vec![] + }, + OffenceDetails { + offender: 4, + reporters: vec![] + }, + ] + ); + }); +} diff --git a/pallets/offences/src/traits.rs b/pallets/offences/src/traits.rs new file mode 100644 index 000000000..c7b4bc3d9 --- /dev/null +++ b/pallets/offences/src/traits.rs @@ -0,0 +1,12 @@ +use sp_staking::{offence::OffenceDetails, SessionIndex}; + +use crate::SlashStrategy; + +pub trait OnOffenceHandler<Reporter, Offender, Res> { + // Required method + fn on_offence( + offenders: &[OffenceDetails<Reporter, Offender>], + slash_strategy: SlashStrategy, + session: SessionIndex, + ) -> Res; +} diff --git a/runtime/common/src/pallets_config.rs b/runtime/common/src/pallets_config.rs index 892cb3723..d98d20537 100644 --- a/runtime/common/src/pallets_config.rs +++ b/runtime/common/src/pallets_config.rs @@ -227,7 +227,7 @@ macro_rules! pallets_config { impl pallet_offences::Config for Runtime { type RuntimeEvent = RuntimeEvent; type IdentificationTuple = pallet_session::historical::IdentificationTuple<Self>; - type OnOffenceHandler = (); + type OnOffenceHandler = AuthorityMembers; } impl pallet_session::Config for Runtime { type RuntimeEvent = RuntimeEvent; diff --git a/runtime/gdev/Cargo.toml b/runtime/gdev/Cargo.toml index 643fc4b03..da3f43577 100644 --- a/runtime/gdev/Cargo.toml +++ b/runtime/gdev/Cargo.toml @@ -142,6 +142,7 @@ pallet-duniter-account = { path = '../../pallets/duniter-account', default-featu pallet-duniter-wot = { path = '../../pallets/duniter-wot', default-features = false } pallet-identity = { path = '../../pallets/identity', default-features = false } pallet-membership = { path = '../../pallets/membership', default-features = false } +pallet-offences = { path = '../../pallets/offences', default-features = false } pallet-oneshot-account = { path = '../../pallets/oneshot-account', default-features = false } pallet-provide-randomness = { path = '../../pallets/provide-randomness', default-features = false } pallet-universal-dividend = { path = '../../pallets/universal-dividend', default-features = false } @@ -170,7 +171,6 @@ pallet-balances = { git = 'https://github.com/duniter/substrate', branch = 'duni pallet-collective = { git = 'https://github.com/duniter/substrate', branch = 'duniter-substrate-v0.9.32', default-features = false } pallet-grandpa = { git = 'https://github.com/duniter/substrate', branch = 'duniter-substrate-v0.9.32', default-features = false } pallet-im-online = { git = 'https://github.com/duniter/substrate', branch = 'duniter-substrate-v0.9.32', default-features = false } -pallet-offences = { git = 'https://github.com/duniter/substrate', branch = 'duniter-substrate-v0.9.32', default-features = false } pallet-multisig = { git = 'https://github.com/duniter/substrate', branch = 'duniter-substrate-v0.9.32', default-features = false } pallet-preimage = { git = 'https://github.com/duniter/substrate', branch = 'duniter-substrate-v0.9.32', default-features = false } pallet-proxy = { git = 'https://github.com/duniter/substrate', branch = 'duniter-substrate-v0.9.32', default-features = false } -- GitLab