diff --git a/.github/workflows/e2e-test-bridge.yml b/.github/workflows/e2e-test-bridge.yml
new file mode 100644
index 000000000..fcbe41994
--- /dev/null
+++ b/.github/workflows/e2e-test-bridge.yml
@@ -0,0 +1,70 @@
+name: Bridge e2e test
+
+on:
+ workflow_run:
+ workflows: [CI]
+ branches: [master]
+ types: [completed]
+
+jobs:
+ e2e-bridge-test:
+ runs-on: self-hosted
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Check g++
+ id: setup_g_plusplus
+ run: |
+ g++ --version
+ - name: Check protoc
+ id: check_proto_c
+ run: |
+ protoc --version
+ - name: Check jq
+ id: check_jq
+ run: |
+ jq --version
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '^1.22.0'
+ - name: Check go
+ id: check_go
+ run: |
+ go version
+ - name: Run Mage
+ uses: magefile/mage-action@v3
+ with:
+ install-only: true
+ - name: Check mage
+ id: check_mage
+ run: |
+ mage --version
+ - name: Install Foundry
+ uses: foundry-rs/foundry-toolchain@v1
+ - name: Check forge
+ id: check_forge
+ run: |
+ forge --version
+ - name: Pnpm
+ uses: pnpm/action-setup@v4.0.0
+ with:
+ version: 9
+ - name: Use Node.js 22.x
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22.x
+ - name: Install yarn
+ run: |-
+ curl -fsSL --create-dirs -o $HOME/bin/yarn \
+ https://github.com/yarnpkg/yarn/releases/download/v1.22.22/yarn-1.22.22.js
+ chmod +x $HOME/bin/yarn
+ echo "$HOME/bin" >> $GITHUB_PATH
+ - name: Check yarn
+ id: check_yarn
+ run: |
+ yarn --version
+ - name: Check date
+ id: check_date
+ run: |
+ date --version
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index d57330a96..b29662dc3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3220,6 +3220,7 @@ dependencies = [
"dancelight-runtime-constants",
"dp-consensus",
"dp-container-chain-genesis-data",
+ "finality-grandpa",
"frame-benchmarking",
"frame-executive",
"frame-metadata-hash-extension",
@@ -3250,6 +3251,7 @@ dependencies = [
"pallet-data-preservers",
"pallet-democracy",
"pallet-elections-phragmen",
+ "pallet-external-validator-slashes",
"pallet-external-validators",
"pallet-grandpa",
"pallet-identity",
@@ -9365,6 +9367,26 @@ dependencies = [
"xcm-primitives",
]
+[[package]]
+name = "pallet-external-validator-slashes"
+version = "0.1.0"
+dependencies = [
+ "frame-benchmarking",
+ "frame-support",
+ "frame-system",
+ "log",
+ "pallet-session",
+ "pallet-staking",
+ "parity-scale-codec",
+ "scale-info",
+ "sp-core",
+ "sp-io",
+ "sp-runtime",
+ "sp-staking",
+ "sp-std",
+ "tp-traits",
+]
+
[[package]]
name = "pallet-external-validators"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index c2432be9e..8d13c03e4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -60,6 +60,7 @@ pallet-collator-assignment-runtime-api = { path = "pallets/collator-assignment/r
pallet-configuration = { path = "pallets/configuration", default-features = false }
pallet-data-preservers = { path = "pallets/data-preservers", default-features = false }
pallet-data-preservers-runtime-api = { path = "pallets/data-preservers/runtime-api", default-features = false }
+pallet-external-validator-slashes = { path = "pallets/external-validator-slashes", default-features = false }
pallet-external-validators = { path = "pallets/external-validators", default-features = false }
pallet-inflation-rewards = { path = "pallets/inflation-rewards", default-features = false }
pallet-initializer = { path = "pallets/initializer", default-features = false }
@@ -326,6 +327,7 @@ fc-storage = { git = "https://github.com/moondance-labs/frontier", branch = "tan
# General (wasm)
bounded-collections = { version = "0.1.8", default-features = false }
+finality-grandpa = { version = "0.16.2", default-features = false }
hex-literal = { version = "0.3.4" }
impl-trait-for-tuples = "0.2.2"
impls = "1.0.3"
diff --git a/pallets/external-validator-slashes/Cargo.toml b/pallets/external-validator-slashes/Cargo.toml
new file mode 100644
index 000000000..32a3cd6e2
--- /dev/null
+++ b/pallets/external-validator-slashes/Cargo.toml
@@ -0,0 +1,68 @@
+[package]
+name = "pallet-external-validator-slashes"
+authors = { workspace = true }
+description = "External validator info slashes"
+edition = "2021"
+license = "GPL-3.0-only"
+version = "0.1.0"
+
+[package.metadata.docs.rs]
+targets = [ "x86_64-unknown-linux-gnu" ]
+
+[lints]
+workspace = true
+
+[dependencies]
+frame-benchmarking = { workspace = true, optional = true }
+frame-support = { workspace = true }
+frame-system = { workspace = true }
+log = { workspace = true }
+pallet-session = { workspace = true }
+pallet-staking = { workspace = true }
+parity-scale-codec = { workspace = true, features = [ "derive", "max-encoded-len" ] }
+scale-info = { workspace = true }
+sp-runtime = { workspace = true }
+sp-staking = { workspace = true }
+sp-std = { workspace = true }
+tp-traits = { workspace = true }
+
+[dev-dependencies]
+sp-core = { workspace = true }
+sp-io = { workspace = true }
+
+[features]
+default = [ "std" ]
+std = [
+ "frame-benchmarking/std",
+ "frame-support/std",
+ "frame-system/std",
+ "log/std",
+ "pallet-session/std",
+ "pallet-staking/std",
+ "parity-scale-codec/std",
+ "scale-info/std",
+ "sp-core/std",
+ "sp-io/std",
+ "sp-runtime/std",
+ "sp-staking/std",
+ "sp-std/std",
+ "tp-traits/std",
+]
+runtime-benchmarks = [
+ "frame-benchmarking/runtime-benchmarks",
+ "frame-support/runtime-benchmarks",
+ "frame-system/runtime-benchmarks",
+ "pallet-staking/runtime-benchmarks",
+ "scale-info/std",
+ "sp-runtime/runtime-benchmarks",
+ "sp-staking/runtime-benchmarks",
+ "tp-traits/runtime-benchmarks",
+]
+
+try-runtime = [
+ "frame-support/try-runtime",
+ "frame-system/try-runtime",
+ "pallet-session/try-runtime",
+ "pallet-staking/try-runtime",
+ "sp-runtime/try-runtime",
+]
diff --git a/pallets/external-validator-slashes/src/benchmarking.rs b/pallets/external-validator-slashes/src/benchmarking.rs
new file mode 100644
index 000000000..783b77687
--- /dev/null
+++ b/pallets/external-validator-slashes/src/benchmarking.rs
@@ -0,0 +1,95 @@
+// Copyright (C) Moondance Labs Ltd.
+// This file is part of Tanssi.
+
+// Tanssi 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.
+
+// Tanssi 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 Tanssi. If not, see
+
+//! Benchmarking setup for pallet-external-validator-slashes
+
+use super::*;
+
+#[allow(unused)]
+use crate::Pallet as ExternalValidatorSlashes;
+use {
+ frame_benchmarking::{v2::*, BenchmarkError},
+ frame_system::RawOrigin,
+ pallet_session::{self as session},
+ sp_runtime::traits::TrailingZeroInput,
+ sp_std::prelude::*,
+};
+
+const MAX_SLASHES: u32 = 1000;
+
+#[allow(clippy::multiple_bound_locations)]
+#[benchmarks(where T: session::Config)]
+mod benchmarks {
+ use super::*;
+
+ #[benchmark]
+ fn cancel_deferred_slash(s: Linear<1, MAX_SLASHES>) -> Result<(), BenchmarkError> {
+ let mut existing_slashes = Vec::new();
+ let era = T::EraIndexProvider::active_era().index;
+ let dummy = || T::AccountId::decode(&mut TrailingZeroInput::zeroes()).unwrap();
+ for _ in 0..MAX_SLASHES {
+ existing_slashes.push(Slash::::default_from(dummy()));
+ }
+ Slashes::::insert(
+ era.saturating_add(T::SlashDeferDuration::get())
+ .saturating_add(One::one()),
+ &existing_slashes,
+ );
+ let slash_indices: Vec = (0..s).collect();
+
+ #[extrinsic_call]
+ _(
+ RawOrigin::Root,
+ era.saturating_add(T::SlashDeferDuration::get())
+ .saturating_add(One::one()),
+ slash_indices,
+ );
+
+ assert_eq!(
+ Slashes::::get(
+ &era.saturating_add(T::SlashDeferDuration::get())
+ .saturating_add(One::one())
+ )
+ .len(),
+ (MAX_SLASHES - s) as usize
+ );
+ Ok(())
+ }
+
+ #[benchmark]
+ fn force_inject_slash() -> Result<(), BenchmarkError> {
+ let era = T::EraIndexProvider::active_era().index;
+ let dummy = || T::AccountId::decode(&mut TrailingZeroInput::zeroes()).unwrap();
+ #[extrinsic_call]
+ _(RawOrigin::Root, era, dummy(), Perbill::from_percent(50));
+
+ assert_eq!(
+ Slashes::::get(
+ &era.saturating_add(T::SlashDeferDuration::get())
+ .saturating_add(One::one())
+ )
+ .len(),
+ 1_usize
+ );
+ Ok(())
+ }
+
+ impl_benchmark_test_suite!(
+ ExternalValidatorSlashes,
+ crate::mock::new_test_ext(),
+ crate::mock::Test,
+ );
+}
diff --git a/pallets/external-validator-slashes/src/lib.rs b/pallets/external-validator-slashes/src/lib.rs
new file mode 100644
index 000000000..eff102f22
--- /dev/null
+++ b/pallets/external-validator-slashes/src/lib.rs
@@ -0,0 +1,510 @@
+// Copyright (C) Moondance Labs Ltd.
+// This file is part of Tanssi.
+
+// Tanssi 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.
+
+// Tanssi 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 Tanssi. If not, see
+
+//! ExternalValidatorSlashes pallet.
+//!
+//! A pallet to store slashes based on offences committed by validators
+//! Slashes can be cancelled during the DeferPeriod through cancel_deferred_slash
+//! Slashes can also be forcedly injected via the force_inject_slash extrinsic
+//! Slashes for a particular era are removed after the bondingPeriod has elapsed
+//!
+//! ## OnOffence trait
+//!
+//! The pallet also implements the OnOffence trait that reacts to offences being injected by other pallets
+//! Invulnerables are not slashed and no slashing information is stored for them
+
+#![cfg_attr(not(feature = "std"), no_std)]
+
+use {
+ frame_support::{pallet_prelude::*, traits::DefensiveSaturating},
+ frame_system::pallet_prelude::*,
+ log::log,
+ pallet_staking::SessionInterface,
+ parity_scale_codec::FullCodec,
+ parity_scale_codec::{Decode, Encode},
+ sp_runtime::traits::{Convert, Debug, One, Saturating, Zero},
+ sp_runtime::DispatchResult,
+ sp_runtime::Perbill,
+ sp_staking::{
+ offence::{OffenceDetails, OnOffenceHandler},
+ EraIndex, SessionIndex,
+ },
+ sp_std::vec,
+ sp_std::vec::Vec,
+ tp_traits::{EraIndexProvider, InvulnerablesProvider, OnEraStart},
+};
+
+pub use pallet::*;
+
+#[cfg(test)]
+mod mock;
+
+#[cfg(test)]
+mod tests;
+
+#[cfg(feature = "runtime-benchmarks")]
+mod benchmarking;
+pub mod weights;
+
+#[frame_support::pallet]
+pub mod pallet {
+ use super::*;
+ pub use crate::weights::WeightInfo;
+
+ #[pallet::event]
+ #[pallet::generate_deposit(pub(super) fn deposit_event)]
+ pub enum Event {
+ /// Removed author data
+ SlashReported {
+ validator: T::ValidatorId,
+ fraction: Perbill,
+ slash_era: EraIndex,
+ },
+ }
+
+ #[pallet::config]
+ pub trait Config: frame_system::Config {
+ /// The overarching event type.
+ type RuntimeEvent: From> + IsType<::RuntimeEvent>;
+
+ /// A stable ID for a validator.
+ type ValidatorId: Member
+ + Parameter
+ + MaybeSerializeDeserialize
+ + MaxEncodedLen
+ + TryFrom;
+
+ /// A conversion from account ID to validator ID.
+ type ValidatorIdOf: Convert>;
+
+ /// 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.
+ #[pallet::constant]
+ type SlashDeferDuration: Get;
+
+ /// Number of eras that staked funds must remain bonded for.
+ #[pallet::constant]
+ type BondingDuration: Get;
+
+ // SlashId type, used as a counter on the number of slashes
+ type SlashId: Default
+ + FullCodec
+ + TypeInfo
+ + Copy
+ + Clone
+ + Debug
+ + Eq
+ + Saturating
+ + One
+ + Ord
+ + MaxEncodedLen;
+
+ /// Interface for interacting with a session pallet.
+ type SessionInterface: SessionInterface;
+
+ /// Era index provider, used to fetch the active era among other things
+ type EraIndexProvider: EraIndexProvider;
+
+ /// Invulnerable provider, used to get the invulnerables to know when not to slash
+ type InvulnerablesProvider: InvulnerablesProvider;
+
+ /// The weight information of this pallet.
+ type WeightInfo: WeightInfo;
+ }
+
+ #[pallet::error]
+ pub enum Error {
+ /// The era for which the slash wants to be cancelled has no slashes
+ EmptyTargets,
+ /// No slash was found to be cancelled at the given index
+ InvalidSlashIndex,
+ /// Slash indices to be cancelled are not sorted or unique
+ NotSortedAndUnique,
+ /// Provided an era in the future
+ ProvidedFutureEra,
+ /// Provided an era that is not slashable
+ ProvidedNonSlashableEra,
+ /// The slash to be cancelled has already elapsed the DeferPeriod
+ DeferPeriodIsOver,
+ /// There was an error computing the slash
+ ErrorComputingSlash,
+ }
+
+ #[pallet::pallet]
+ pub struct Pallet(PhantomData);
+
+ /// All slashing events on validators, mapped by era to the highest slash proportion
+ /// and slash value of the era.
+ #[pallet::storage]
+ pub(crate) type ValidatorSlashInEra =
+ StorageDoubleMap<_, Twox64Concat, EraIndex, Twox64Concat, T::AccountId, Perbill>;
+
+ /// A mapping from still-bonded eras to the first session index of that era.
+ ///
+ /// Must contains information for eras for the range:
+ /// `[active_era - bounding_duration; active_era]`
+ #[pallet::storage]
+ #[pallet::unbounded]
+ pub type BondedEras = StorageValue<_, Vec<(EraIndex, SessionIndex)>, ValueQuery>;
+
+ /// A counter on the number of slashes we have performed
+ #[pallet::storage]
+ #[pallet::getter(fn next_slash_id)]
+ pub type NextSlashId = StorageValue<_, T::SlashId, ValueQuery>;
+
+ /// All unapplied slashes that are queued for later.
+ #[pallet::storage]
+ #[pallet::unbounded]
+ #[pallet::getter(fn slashes)]
+ pub type Slashes =
+ StorageMap<_, Twox64Concat, EraIndex, Vec>, ValueQuery>;
+
+ #[pallet::call]
+ impl Pallet {
+ /// Cancel a slash that was deferred for a later era
+ #[pallet::call_index(0)]
+ #[pallet::weight(T::WeightInfo::cancel_deferred_slash(slash_indices.len() as u32))]
+ pub fn cancel_deferred_slash(
+ origin: OriginFor,
+ era: EraIndex,
+ slash_indices: Vec,
+ ) -> DispatchResult {
+ ensure_root(origin)?;
+
+ let active_era = T::EraIndexProvider::active_era().index;
+
+ // We need to be in the defer period
+ ensure!(
+ era <= active_era
+ .saturating_add(T::SlashDeferDuration::get().saturating_add(One::one()))
+ && era > active_era,
+ Error::::DeferPeriodIsOver
+ );
+
+ ensure!(!slash_indices.is_empty(), Error::::EmptyTargets);
+ ensure!(
+ is_sorted_and_unique(&slash_indices),
+ Error::::NotSortedAndUnique
+ );
+ // fetch slashes for the era in which we want to defer
+ let mut era_slashes = Slashes::::get(&era);
+
+ let last_item = slash_indices[slash_indices.len() - 1];
+ ensure!(
+ (last_item as usize) < era_slashes.len(),
+ Error::::InvalidSlashIndex
+ );
+
+ // Remove elements starting from the highest index to avoid shifting issues.
+ for index in slash_indices.into_iter().rev() {
+ era_slashes.remove(index as usize);
+ }
+ // insert back slashes
+ Slashes::::insert(&era, &era_slashes);
+ Ok(())
+ }
+
+ #[pallet::call_index(1)]
+ #[pallet::weight(T::WeightInfo::force_inject_slash())]
+ pub fn force_inject_slash(
+ origin: OriginFor,
+ era: EraIndex,
+ validator: T::AccountId,
+ percentage: Perbill,
+ ) -> DispatchResult {
+ ensure_root(origin)?;
+ let active_era = T::EraIndexProvider::active_era().index;
+
+ ensure!(era <= active_era, Error::::ProvidedFutureEra);
+
+ let slash_defer_duration = T::SlashDeferDuration::get();
+
+ let _ = T::EraIndexProvider::era_to_session_start(era)
+ .ok_or(Error::::ProvidedNonSlashableEra)?;
+
+ let next_slash_id = NextSlashId::::get();
+
+ let slash = compute_slash::(
+ percentage,
+ next_slash_id,
+ era,
+ validator,
+ slash_defer_duration,
+ )
+ .ok_or(Error::::ErrorComputingSlash)?;
+
+ // If we defer duration is 0, we immediately apply and confirm
+ let era_to_consider = if slash_defer_duration == 0 {
+ era
+ } else {
+ era.saturating_add(slash_defer_duration)
+ .saturating_add(One::one())
+ };
+
+ Slashes::::mutate(&era_to_consider, |era_slashes| {
+ era_slashes.push(slash);
+ });
+
+ NextSlashId::::put(next_slash_id.saturating_add(One::one()));
+ Ok(())
+ }
+ }
+}
+
+/// This is intended to be used with `FilterHistoricalOffences`.
+impl
+ OnOffenceHandler, Weight>
+ for Pallet
+where
+ T: Config::AccountId>,
+ T: pallet_session::Config::AccountId>,
+ T: pallet_session::historical::Config,
+ T::SessionHandler: pallet_session::SessionHandler<::AccountId>,
+ T::SessionManager: pallet_session::SessionManager<::AccountId>,
+ ::ValidatorIdOf: Convert<
+ ::AccountId,
+ Option<::AccountId>,
+ >,
+{
+ fn on_offence(
+ offenders: &[OffenceDetails<
+ T::AccountId,
+ pallet_session::historical::IdentificationTuple,
+ >],
+ slash_fraction: &[Perbill],
+ slash_session: SessionIndex,
+ ) -> Weight {
+ let active_era = {
+ let active_era = T::EraIndexProvider::active_era().index;
+ active_era
+ };
+ let active_era_start_session_index = T::EraIndexProvider::era_to_session_start(active_era)
+ .unwrap_or_else(|| {
+ frame_support::print("Error: start_session_index must be set for current_era");
+ 0
+ });
+
+ // Fast path for active-era report - most likely.
+ // `slash_session` cannot be in a future active era. It must be in `active_era` or before.
+ let slash_era = if slash_session >= active_era_start_session_index {
+ active_era
+ } else {
+ let eras = BondedEras::::get();
+
+ // Reverse because it's more likely to find reports from recent eras.
+ match eras.iter().rev().find(|&(_, sesh)| sesh <= &slash_session) {
+ Some((slash_era, _)) => *slash_era,
+ // Before bonding period. defensive - should be filtered out.
+ None => return Weight::default(),
+ }
+ };
+
+ let slash_defer_duration = T::SlashDeferDuration::get();
+
+ let invulnerables = T::InvulnerablesProvider::invulnerables();
+
+ let mut next_slash_id = NextSlashId::::get();
+
+ for (details, slash_fraction) in offenders.iter().zip(slash_fraction) {
+ let (stash, _) = &details.offender;
+
+ // Skip if the validator is invulnerable.
+ if invulnerables.contains(stash) {
+ continue;
+ }
+
+ let slash = compute_slash::(
+ *slash_fraction,
+ next_slash_id,
+ slash_era,
+ stash.clone(),
+ slash_defer_duration,
+ );
+
+ Self::deposit_event(Event::::SlashReported {
+ validator: stash.clone(),
+ fraction: *slash_fraction,
+ slash_era,
+ });
+
+ if let Some(mut slash) = slash {
+ slash.reporters = details.reporters.clone();
+
+ // Defer to end of some `slash_defer_duration` from now.
+ log!(
+ log::Level::Debug,
+ "deferring slash of {:?}% happened in {:?} (reported in {:?}) to {:?}",
+ slash_fraction,
+ slash_era,
+ active_era,
+ slash_era + slash_defer_duration + 1,
+ );
+
+ // Cover slash defer duration equal to 0
+ if slash_defer_duration == 0 {
+ Slashes::::mutate(slash_era, move |for_now| for_now.push(slash));
+ } else {
+ Slashes::::mutate(
+ slash_era
+ .saturating_add(slash_defer_duration)
+ .saturating_add(One::one()),
+ move |for_later| for_later.push(slash),
+ );
+ }
+
+ // Fix unwrap
+ next_slash_id = next_slash_id.saturating_add(One::one());
+ }
+ }
+ NextSlashId::::put(next_slash_id);
+ Weight::default()
+ }
+}
+
+impl OnEraStart for Pallet {
+ fn on_era_start(era_index: EraIndex, session_start: SessionIndex) {
+ // This should be small, as slashes are limited by the num of validators
+ // let's put 1000 as a conservative measure
+ const REMOVE_LIMIT: u32 = 1000;
+
+ let bonding_duration = T::BondingDuration::get();
+
+ BondedEras::::mutate(|bonded| {
+ bonded.push((era_index, session_start));
+
+ if era_index > bonding_duration {
+ let first_kept = era_index.defensive_saturating_sub(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();
+
+ // Kill slashing metadata.
+ for (pruned_era, _) in bonded.drain(..n_to_prune) {
+ let removal_result =
+ ValidatorSlashInEra::::clear_prefix(&pruned_era, REMOVE_LIMIT, None);
+ if removal_result.maybe_cursor.is_some() {
+ log::error!(
+ "Not all validator slashes were remove for era {:?}",
+ pruned_era
+ );
+ }
+ Slashes::::remove(&pruned_era);
+ }
+
+ if let Some(&(_, first_session)) = bonded.first() {
+ T::SessionInterface::prune_historical_up_to(first_session);
+ }
+ }
+ });
+
+ Self::confirm_unconfirmed_slashes(era_index);
+ }
+}
+
+impl Pallet {
+ /// Apply previously-unapplied slashes on the beginning of a new era, after a delay.
+ fn confirm_unconfirmed_slashes(active_era: EraIndex) {
+ Slashes::::mutate(&active_era, |era_slashes| {
+ log!(
+ log::Level::Debug,
+ "found {} slashes scheduled to be confirmed in era {:?}",
+ era_slashes.len(),
+ active_era,
+ );
+ for slash in era_slashes {
+ slash.confirmed = true;
+ }
+ });
+ }
+}
+
+/// A pending slash record. The value of the slash has been computed but not applied yet,
+/// rather deferred for several eras.
+#[derive(Encode, Decode, RuntimeDebug, TypeInfo, Clone, PartialEq)]
+pub struct Slash {
+ /// The stash ID of the offending validator.
+ pub validator: AccountId,
+ /// Reporters of the offence; bounty payout recipients.
+ pub reporters: Vec,
+ /// The amount of payout.
+ pub slash_id: SlashId,
+ pub percentage: Perbill,
+ // Whether the slash is confirmed or still needs to go through deferred period
+ pub confirmed: bool,
+}
+
+impl Slash {
+ /// Initializes the default object using the given `validator`.
+ pub fn default_from(validator: AccountId) -> Self {
+ Self {
+ validator,
+ reporters: vec![],
+ slash_id: One::one(),
+ percentage: Perbill::from_percent(50),
+ confirmed: false,
+ }
+ }
+}
+
+/// 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(
+ slash_fraction: Perbill,
+ slash_id: T::SlashId,
+ slash_era: EraIndex,
+ stash: T::AccountId,
+ slash_defer_duration: EraIndex,
+) -> Option> {
+ let prior_slash_p = ValidatorSlashInEra::::get(&slash_era, &stash).unwrap_or(Zero::zero());
+
+ // compare slash proportions rather than slash values to avoid issues due to rounding
+ // error.
+ if slash_fraction.deconstruct() > prior_slash_p.deconstruct() {
+ ValidatorSlashInEra::::insert(&slash_era, &stash, &slash_fraction);
+ } 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;
+ }
+
+ let confirmed = slash_defer_duration.is_zero();
+ Some(Slash {
+ validator: stash.clone(),
+ percentage: slash_fraction,
+ slash_id,
+ reporters: Vec::new(),
+ confirmed,
+ })
+}
+
+/// Check that list is sorted and has no duplicates.
+fn is_sorted_and_unique(list: &[u32]) -> bool {
+ list.windows(2).all(|w| w[0] < w[1])
+}
diff --git a/pallets/external-validator-slashes/src/mock.rs b/pallets/external-validator-slashes/src/mock.rs
new file mode 100644
index 000000000..4132e28e0
--- /dev/null
+++ b/pallets/external-validator-slashes/src/mock.rs
@@ -0,0 +1,240 @@
+// Copyright (C) Moondance Labs Ltd.
+// This file is part of Tanssi.
+
+// Tanssi 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.
+
+// Tanssi 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 Tanssi. If not, see
+
+use {
+ crate as external_validator_slashes,
+ frame_support::{
+ parameter_types,
+ traits::{ConstU16, ConstU64, Get},
+ },
+ frame_system as system,
+ sp_core::H256,
+ sp_runtime::{
+ testing::UintAuthorityId,
+ traits::{BlakeTwo256, ConvertInto, IdentityLookup},
+ BuildStorage,
+ },
+ sp_staking::SessionIndex,
+ sp_std::cell::RefCell,
+ tp_traits::{ActiveEraInfo, EraIndex, EraIndexProvider, InvulnerablesProvider},
+};
+
+type Block = frame_system::mocking::MockBlock;
+
+// Configure a mock runtime to test the pallet.
+frame_support::construct_runtime!(
+ pub enum Test
+ {
+ System: frame_system,
+ Session: pallet_session,
+ Historical: pallet_session::historical,
+ ExternalValidatorSlashes: external_validator_slashes,
+ }
+);
+
+impl system::Config for Test {
+ type BaseCallFilter = frame_support::traits::Everything;
+ type BlockWeights = ();
+ type BlockLength = ();
+ type DbWeight = ();
+ type RuntimeOrigin = RuntimeOrigin;
+ type RuntimeCall = RuntimeCall;
+ type Nonce = u64;
+ type Block = Block;
+ type Hash = H256;
+ type Hashing = BlakeTwo256;
+ type AccountId = u64;
+ type Lookup = IdentityLookup;
+ type RuntimeEvent = RuntimeEvent;
+ type BlockHashCount = ConstU64<250>;
+ type Version = ();
+ type PalletInfo = PalletInfo;
+ type AccountData = ();
+ type OnNewAccount = ();
+ type OnKilledAccount = ();
+ type SystemWeightInfo = ();
+ type SS58Prefix = ConstU16<42>;
+ type OnSetCode = ();
+ type MaxConsumers = frame_support::traits::ConstU32<16>;
+ type RuntimeTask = ();
+ type SingleBlockMigrations = ();
+ type MultiBlockMigrator = ();
+ type PreInherents = ();
+ type PostInherents = ();
+ type PostTransactions = ();
+}
+
+parameter_types! {
+ pub static Validators: Option> = Some(vec![
+ 1,
+ 2,
+ 3,
+ ]);
+}
+
+pub struct TestSessionManager;
+impl pallet_session::SessionManager for TestSessionManager {
+ fn new_session(_new_index: SessionIndex) -> Option> {
+ Validators::get()
+ }
+ fn end_session(_: SessionIndex) {}
+ fn start_session(_: SessionIndex) {}
+}
+
+impl pallet_session::historical::SessionManager for TestSessionManager {
+ fn new_session(_new_index: SessionIndex) -> Option> {
+ Validators::mutate(|l| {
+ l.take()
+ .map(|validators| validators.iter().map(|v| (*v, ())).collect())
+ })
+ }
+ fn end_session(_: SessionIndex) {}
+ fn start_session(_: SessionIndex) {}
+}
+
+parameter_types! {
+ pub const Period: u64 = 1;
+ pub const Offset: u64 = 0;
+}
+
+pub struct MockEraIndexProvider;
+
+thread_local! {
+ pub static ERA_INDEX: RefCell = const { RefCell::new(0) };
+ pub static DEFER_PERIOD: RefCell = const { RefCell::new(2) };
+}
+
+impl MockEraIndexProvider {
+ pub fn with_era(era_index: EraIndex) {
+ ERA_INDEX.with(|r| *r.borrow_mut() = era_index);
+ }
+}
+
+impl EraIndexProvider for MockEraIndexProvider {
+ fn active_era() -> ActiveEraInfo {
+ ActiveEraInfo {
+ index: ERA_INDEX.with(|q| *q.borrow()),
+ start: None,
+ }
+ }
+ fn era_to_session_start(era_index: EraIndex) -> Option {
+ let active_era = Self::active_era().index;
+ if era_index > active_era || era_index < active_era.saturating_sub(BondingDuration::get()) {
+ None
+ } else {
+ // Else we assume eras start at the same time as sessions
+ Some(era_index)
+ }
+ }
+}
+
+impl pallet_session::Config for Test {
+ type SessionManager = pallet_session::historical::NoteHistoricalRoot;
+ type Keys = SessionKeys;
+ type ShouldEndSession = pallet_session::PeriodicSessions;
+ type SessionHandler = TestSessionHandler;
+ type RuntimeEvent = RuntimeEvent;
+ type ValidatorId = ::AccountId;
+ type ValidatorIdOf = ConvertInto;
+ type NextSessionRotation = pallet_session::PeriodicSessions;
+ type WeightInfo = ();
+}
+
+sp_runtime::impl_opaque_keys! {
+ pub struct SessionKeys {
+ pub foo: sp_runtime::testing::UintAuthorityId,
+ }
+}
+
+use sp_runtime::RuntimeAppPublic;
+type AccountId = u64;
+pub struct TestSessionHandler;
+impl pallet_session::SessionHandler for TestSessionHandler {
+ const KEY_TYPE_IDS: &'static [sp_runtime::KeyTypeId] = &[UintAuthorityId::ID];
+
+ fn on_genesis_session(_validators: &[(AccountId, Ks)]) {}
+
+ fn on_new_session(
+ _: bool,
+ _: &[(AccountId, Ks)],
+ _: &[(AccountId, Ks)],
+ ) {
+ }
+ fn on_disabled(_: u32) {}
+}
+
+pub struct MockInvulnerableProvider;
+impl InvulnerablesProvider for MockInvulnerableProvider {
+ fn invulnerables() -> Vec {
+ vec![1, 2]
+ }
+}
+
+pub struct DeferPeriodGetter;
+impl Get for DeferPeriodGetter {
+ fn get() -> EraIndex {
+ DEFER_PERIOD.with(|q| (*q.borrow()))
+ }
+}
+
+impl DeferPeriodGetter {
+ pub fn with_defer_period(defer_period: EraIndex) {
+ DEFER_PERIOD.with(|r| *r.borrow_mut() = defer_period);
+ }
+}
+
+parameter_types! {
+ pub const BondingDuration: u32 = 5u32;
+}
+
+impl external_validator_slashes::Config for Test {
+ type RuntimeEvent = RuntimeEvent;
+ type ValidatorId = ::AccountId;
+ type ValidatorIdOf = IdentityValidator;
+ type SlashDeferDuration = DeferPeriodGetter;
+ type BondingDuration = BondingDuration;
+ type SlashId = u32;
+ type SessionInterface = ();
+ type EraIndexProvider = MockEraIndexProvider;
+ type InvulnerablesProvider = MockInvulnerableProvider;
+ type WeightInfo = ();
+}
+
+pub struct FullIdentificationOf;
+impl sp_runtime::traits::Convert> for FullIdentificationOf {
+ fn convert(_: AccountId) -> Option<()> {
+ Some(())
+ }
+}
+
+impl pallet_session::historical::Config for Test {
+ type FullIdentification = ();
+ type FullIdentificationOf = FullIdentificationOf;
+}
+// Build genesis storage according to the mock runtime.
+pub fn new_test_ext() -> sp_io::TestExternalities {
+ system::GenesisConfig::::default()
+ .build_storage()
+ .unwrap()
+ .into()
+}
+
+pub struct IdentityValidator;
+impl sp_runtime::traits::Convert> for IdentityValidator {
+ fn convert(a: u64) -> Option {
+ Some(a)
+ }
+}
diff --git a/pallets/external-validator-slashes/src/tests.rs b/pallets/external-validator-slashes/src/tests.rs
new file mode 100644
index 000000000..5609346e4
--- /dev/null
+++ b/pallets/external-validator-slashes/src/tests.rs
@@ -0,0 +1,288 @@
+// Copyright (C) Moondance Labs Ltd.
+// This file is part of Tanssi.
+
+// Tanssi 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.
+
+// Tanssi 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 Tanssi. If not, see
+
+use {
+ super::*,
+ crate::mock::{new_test_ext, ExternalValidatorSlashes, RuntimeOrigin, Test},
+ frame_support::{assert_noop, assert_ok},
+};
+
+#[test]
+fn root_can_inject_manual_offence() {
+ new_test_ext().execute_with(|| {
+ start_era(0, 0);
+ assert_ok!(ExternalValidatorSlashes::force_inject_slash(
+ RuntimeOrigin::root(),
+ 0,
+ 1u64,
+ Perbill::from_percent(75)
+ ));
+ assert_eq!(
+ Slashes::::get(3),
+ vec![Slash {
+ validator: 1,
+ percentage: Perbill::from_percent(75),
+ confirmed: false,
+ reporters: vec![],
+ slash_id: 0
+ }]
+ );
+ assert_eq!(NextSlashId::::get(), 1);
+ });
+}
+
+#[test]
+fn cannot_inject_future_era_offence() {
+ new_test_ext().execute_with(|| {
+ start_era(0, 0);
+ assert_noop!(
+ ExternalValidatorSlashes::force_inject_slash(
+ RuntimeOrigin::root(),
+ 1,
+ 1u64,
+ Perbill::from_percent(75)
+ ),
+ Error::::ProvidedFutureEra
+ );
+ });
+}
+
+#[test]
+fn cannot_inject_era_offence_too_far_in_the_past() {
+ new_test_ext().execute_with(|| {
+ start_era(10, 0);
+ //Bonding period is 5, we cannot inject slash for era 4
+ assert_noop!(
+ ExternalValidatorSlashes::force_inject_slash(
+ RuntimeOrigin::root(),
+ 1,
+ 4u64,
+ Perbill::from_percent(75)
+ ),
+ Error::::ProvidedNonSlashableEra
+ );
+ });
+}
+
+#[test]
+fn root_can_cance_deferred_slash() {
+ new_test_ext().execute_with(|| {
+ start_era(1, 0);
+ assert_ok!(ExternalValidatorSlashes::force_inject_slash(
+ RuntimeOrigin::root(),
+ 0,
+ 1u64,
+ Perbill::from_percent(75)
+ ));
+ assert_ok!(ExternalValidatorSlashes::cancel_deferred_slash(
+ RuntimeOrigin::root(),
+ 3,
+ vec![0]
+ ));
+
+ assert_eq!(Slashes::::get(3), vec![]);
+ });
+}
+
+#[test]
+fn root_cannot_cancel_deferred_slash_if_outside_deferring_period() {
+ new_test_ext().execute_with(|| {
+ start_era(1, 0);
+ assert_ok!(ExternalValidatorSlashes::force_inject_slash(
+ RuntimeOrigin::root(),
+ 0,
+ 1u64,
+ Perbill::from_percent(75)
+ ));
+
+ start_era(4, 0);
+
+ assert_noop!(
+ ExternalValidatorSlashes::cancel_deferred_slash(RuntimeOrigin::root(), 0, vec![0]),
+ Error::::DeferPeriodIsOver
+ );
+ });
+}
+
+#[test]
+fn test_after_bonding_period_we_can_remove_slashes() {
+ new_test_ext().execute_with(|| {
+ start_era(0, 0);
+ start_era(1, 1);
+
+ // we are storing a tuple (era index, start_session_block)
+ assert_eq!(BondedEras::::get(), [(0, 0), (1, 1)]);
+ assert_ok!(ExternalValidatorSlashes::force_inject_slash(
+ RuntimeOrigin::root(),
+ 0,
+ 1u64,
+ Perbill::from_percent(75)
+ ));
+
+ assert_eq!(
+ Slashes::::get(3),
+ vec![Slash {
+ validator: 1,
+ percentage: Perbill::from_percent(75),
+ confirmed: false,
+ reporters: vec![],
+ slash_id: 0
+ }]
+ );
+
+ Pallet::::on_era_start(3, 3);
+
+ start_era(8, 8);
+
+ // whenever we start the 6th era, we can remove everything from era 3
+ Pallet::::on_era_start(9, 9);
+
+ assert_eq!(Slashes::::get(3), vec![]);
+ });
+}
+
+#[test]
+fn test_on_offence_injects_offences() {
+ new_test_ext().execute_with(|| {
+ start_era(0, 0);
+ start_era(1, 1);
+ Pallet::::on_offence(
+ &[OffenceDetails {
+ // 1 and 2 are invulnerables
+ offender: (3, ()),
+ reporters: vec![],
+ }],
+ &[Perbill::from_percent(75)],
+ 0,
+ );
+ // current era (1) + defer period + 1
+ let slash_era = 0
+ .saturating_add(crate::mock::DeferPeriodGetter::get())
+ .saturating_add(One::one());
+
+ assert_eq!(
+ Slashes::::get(slash_era),
+ vec![Slash {
+ validator: 3,
+ percentage: Perbill::from_percent(75),
+ confirmed: false,
+ reporters: vec![],
+ slash_id: 0
+ }]
+ );
+ });
+}
+
+#[test]
+fn test_on_offence_does_not_work_for_invulnerables() {
+ new_test_ext().execute_with(|| {
+ start_era(0, 0);
+ start_era(1, 1);
+ // account 1 invulnerable
+ Pallet::::on_offence(
+ &[OffenceDetails {
+ offender: (1, ()),
+ reporters: vec![],
+ }],
+ &[Perbill::from_percent(75)],
+ 0,
+ );
+ // current era (1) + defer period + 1
+ let slash_era = 1
+ .saturating_add(crate::mock::DeferPeriodGetter::get())
+ .saturating_add(One::one());
+
+ assert_eq!(Slashes::::get(slash_era), vec![]);
+ });
+}
+
+#[test]
+fn defer_period_of_zero_confirms_immediately_slashes() {
+ new_test_ext().execute_with(|| {
+ crate::mock::DeferPeriodGetter::with_defer_period(0);
+ start_era(0, 0);
+ assert_ok!(ExternalValidatorSlashes::force_inject_slash(
+ RuntimeOrigin::root(),
+ 0,
+ 1u64,
+ Perbill::from_percent(75)
+ ));
+ assert_eq!(
+ Slashes::::get(0),
+ vec![Slash {
+ validator: 1,
+ percentage: Perbill::from_percent(75),
+ confirmed: true,
+ reporters: vec![],
+ slash_id: 0
+ }]
+ );
+ });
+}
+
+#[test]
+fn we_cannot_cancel_anything_with_defer_period_zero() {
+ new_test_ext().execute_with(|| {
+ crate::mock::DeferPeriodGetter::with_defer_period(0);
+ start_era(0, 0);
+ assert_ok!(ExternalValidatorSlashes::force_inject_slash(
+ RuntimeOrigin::root(),
+ 0,
+ 1u64,
+ Perbill::from_percent(75)
+ ));
+ assert_noop!(
+ ExternalValidatorSlashes::cancel_deferred_slash(RuntimeOrigin::root(), 0, vec![0]),
+ Error::::DeferPeriodIsOver
+ );
+ });
+}
+
+#[test]
+fn test_on_offence_defer_period_0() {
+ new_test_ext().execute_with(|| {
+ crate::mock::DeferPeriodGetter::with_defer_period(0);
+ start_era(0, 0);
+ start_era(1, 1);
+ Pallet::::on_offence(
+ &[OffenceDetails {
+ // 1 and 2 are invulnerables
+ offender: (3, ()),
+ reporters: vec![],
+ }],
+ &[Perbill::from_percent(75)],
+ 0,
+ );
+
+ let slash_era = 0;
+
+ assert_eq!(
+ Slashes::::get(slash_era),
+ vec![Slash {
+ validator: 3,
+ percentage: Perbill::from_percent(75),
+ confirmed: true,
+ reporters: vec![],
+ slash_id: 0
+ }]
+ );
+ });
+}
+
+fn start_era(era_index: EraIndex, session_index: SessionIndex) {
+ Pallet::::on_era_start(era_index, session_index);
+ crate::mock::MockEraIndexProvider::with_era(era_index);
+}
diff --git a/pallets/external-validator-slashes/src/weights.rs b/pallets/external-validator-slashes/src/weights.rs
new file mode 100644
index 000000000..295087614
--- /dev/null
+++ b/pallets/external-validator-slashes/src/weights.rs
@@ -0,0 +1,129 @@
+// Copyright (C) Moondance Labs Ltd.
+// This file is part of Tanssi.
+
+// Tanssi 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.
+
+// Tanssi 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 Tanssi. If not, see
+
+
+//! Autogenerated weights for pallet_external_validator_slashes
+//!
+//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 42.0.0
+//! DATE: 2024-10-24, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]`
+//! WORST CASE MAP SIZE: `1000000`
+//! HOSTNAME: `girazoki-XPS-15-9530`, CPU: `13th Gen Intel(R) Core(TM) i9-13900H`
+//! EXECUTION: , WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024
+
+// Executed Command:
+// target/release/tanssi-relay
+// benchmark
+// pallet
+// --execution=wasm
+// --wasm-execution=compiled
+// --pallet
+// pallet_external_validator_slashes
+// --extrinsic
+// *
+// --chain=dev
+// --steps
+// 50
+// --repeat
+// 20
+// --template=./benchmarking/frame-weight-pallet-template.hbs
+// --json-file
+// raw.json
+// --output
+// tmp/pallet_external_validator_slashes.rs
+
+#![cfg_attr(rustfmt, rustfmt_skip)]
+#![allow(unused_parens)]
+#![allow(unused_imports)]
+
+use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}};
+use sp_std::marker::PhantomData;
+
+/// Weight functions needed for pallet_external_validator_slashes.
+pub trait WeightInfo {
+ fn cancel_deferred_slash(s: u32, ) -> Weight;
+ fn force_inject_slash() -> Weight;
+}
+
+/// Weights for pallet_external_validator_slashes using the Substrate node and recommended hardware.
+pub struct SubstrateWeight(PhantomData);
+impl WeightInfo for SubstrateWeight {
+ /// Storage: `ExternalValidators::ActiveEra` (r:1 w:0)
+ /// Proof: `ExternalValidators::ActiveEra` (`max_values`: Some(1), `max_size`: Some(13), added: 508, mode: `MaxEncodedLen`)
+ /// Storage: `ExternalValidatorSlashes::Slashes` (r:1 w:1)
+ /// Proof: `ExternalValidatorSlashes::Slashes` (`max_values`: None, `max_size`: None, mode: `Measured`)
+ /// The range of component `s` is `[1, 1000]`.
+ fn cancel_deferred_slash(s: u32, ) -> Weight {
+ // Proof Size summary in bytes:
+ // Measured: `42194`
+ // Estimated: `45659`
+ // Minimum execution time: 69_654_000 picoseconds.
+ Weight::from_parts(430_467_141, 45659)
+ // Standard Error: 25_862
+ .saturating_add(Weight::from_parts(2_233_402, 0).saturating_mul(s.into()))
+ .saturating_add(T::DbWeight::get().reads(2_u64))
+ .saturating_add(T::DbWeight::get().writes(1_u64))
+ }
+ /// Storage: `ExternalValidators::ActiveEra` (r:1 w:0)
+ /// Proof: `ExternalValidators::ActiveEra` (`max_values`: Some(1), `max_size`: Some(13), added: 508, mode: `MaxEncodedLen`)
+ /// Storage: `ExternalValidatorSlashes::NextSlashId` (r:1 w:1)
+ /// Proof: `ExternalValidatorSlashes::NextSlashId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`)
+ /// Storage: `ExternalValidatorSlashes::Slashes` (r:1 w:1)
+ /// Proof: `ExternalValidatorSlashes::Slashes` (`max_values`: None, `max_size`: None, mode: `Measured`)
+ fn force_inject_slash() -> Weight {
+ // Proof Size summary in bytes:
+ // Measured: `151`
+ // Estimated: `3616`
+ // Minimum execution time: 7_086_000 picoseconds.
+ Weight::from_parts(7_402_000, 3616)
+ .saturating_add(T::DbWeight::get().reads(3_u64))
+ .saturating_add(T::DbWeight::get().writes(2_u64))
+ }
+}
+
+// For backwards compatibility and tests
+impl WeightInfo for () {
+ /// Storage: `ExternalValidators::ActiveEra` (r:1 w:0)
+ /// Proof: `ExternalValidators::ActiveEra` (`max_values`: Some(1), `max_size`: Some(13), added: 508, mode: `MaxEncodedLen`)
+ /// Storage: `ExternalValidatorSlashes::Slashes` (r:1 w:1)
+ /// Proof: `ExternalValidatorSlashes::Slashes` (`max_values`: None, `max_size`: None, mode: `Measured`)
+ /// The range of component `s` is `[1, 1000]`.
+ fn cancel_deferred_slash(s: u32, ) -> Weight {
+ // Proof Size summary in bytes:
+ // Measured: `42194`
+ // Estimated: `45659`
+ // Minimum execution time: 69_654_000 picoseconds.
+ Weight::from_parts(430_467_141, 45659)
+ // Standard Error: 25_862
+ .saturating_add(Weight::from_parts(2_233_402, 0).saturating_mul(s.into()))
+ .saturating_add(RocksDbWeight::get().reads(2_u64))
+ .saturating_add(RocksDbWeight::get().writes(1_u64))
+ }
+ /// Storage: `ExternalValidators::ActiveEra` (r:1 w:0)
+ /// Proof: `ExternalValidators::ActiveEra` (`max_values`: Some(1), `max_size`: Some(13), added: 508, mode: `MaxEncodedLen`)
+ /// Storage: `ExternalValidatorSlashes::NextSlashId` (r:1 w:1)
+ /// Proof: `ExternalValidatorSlashes::NextSlashId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`)
+ /// Storage: `ExternalValidatorSlashes::Slashes` (r:1 w:1)
+ /// Proof: `ExternalValidatorSlashes::Slashes` (`max_values`: None, `max_size`: None, mode: `Measured`)
+ fn force_inject_slash() -> Weight {
+ // Proof Size summary in bytes:
+ // Measured: `151`
+ // Estimated: `3616`
+ // Minimum execution time: 7_086_000 picoseconds.
+ Weight::from_parts(7_402_000, 3616)
+ .saturating_add(RocksDbWeight::get().reads(3_u64))
+ .saturating_add(RocksDbWeight::get().writes(2_u64))
+ }
+}
diff --git a/solo-chains/runtime/dancelight/Cargo.toml b/solo-chains/runtime/dancelight/Cargo.toml
index 56da752ae..de0a9d816 100644
--- a/solo-chains/runtime/dancelight/Cargo.toml
+++ b/solo-chains/runtime/dancelight/Cargo.toml
@@ -71,6 +71,7 @@ pallet-collective = { workspace = true }
pallet-conviction-voting = { workspace = true }
pallet-democracy = { workspace = true }
pallet-elections-phragmen = { workspace = true }
+pallet-external-validator-slashes = { workspace = true }
pallet-external-validators = { workspace = true }
pallet-grandpa = { workspace = true }
pallet-identity = { workspace = true }
@@ -146,6 +147,7 @@ snowbridge-beacon-primitives = { workspace = true }
snowbridge-pallet-ethereum-client = { workspace = true }
[dev-dependencies]
+finality-grandpa = { workspace = true, default-features = true, features = [ "derive-codec" ] }
keyring = { workspace = true }
milagro-bls = { workspace = true, features = [ "std" ] }
rand = { workspace = true, features = [ "std", "std_rng" ] }
@@ -208,6 +210,7 @@ std = [
"pallet-data-preservers/std",
"pallet-democracy/std",
"pallet-elections-phragmen/std",
+ "pallet-external-validator-slashes/std",
"pallet-external-validators/std",
"pallet-grandpa/std",
"pallet-identity/std",
@@ -309,6 +312,7 @@ runtime-benchmarks = [
"pallet-data-preservers/runtime-benchmarks",
"pallet-democracy/runtime-benchmarks",
"pallet-elections-phragmen/runtime-benchmarks",
+ "pallet-external-validator-slashes/runtime-benchmarks",
"pallet-external-validators/runtime-benchmarks",
"pallet-grandpa/runtime-benchmarks",
"pallet-identity/runtime-benchmarks",
@@ -376,6 +380,7 @@ try-runtime = [
"pallet-data-preservers/try-runtime",
"pallet-democracy/try-runtime",
"pallet-elections-phragmen/try-runtime",
+ "pallet-external-validator-slashes/try-runtime",
"pallet-external-validators/try-runtime",
"pallet-grandpa/try-runtime",
"pallet-identity/try-runtime",
diff --git a/solo-chains/runtime/dancelight/src/lib.rs b/solo-chains/runtime/dancelight/src/lib.rs
index e6813e680..f073aa093 100644
--- a/solo-chains/runtime/dancelight/src/lib.rs
+++ b/solo-chains/runtime/dancelight/src/lib.rs
@@ -90,7 +90,7 @@ use {
prelude::*,
},
tp_traits::{
- apply, derive_storage_traits, GetHostConfiguration, GetSessionContainerChains,
+ apply, derive_storage_traits, EraIndex, GetHostConfiguration, GetSessionContainerChains,
RegistrarHandler, RemoveParaIdsWithNoCredits, Slot, SlotFrequency,
},
};
@@ -490,7 +490,7 @@ impl pallet_session::historical::Config for Runtime {
}
parameter_types! {
- pub const BondingDuration: sp_staking::EraIndex = 28;
+ pub const BondingDuration: sp_staking::EraIndex = runtime_common::prod_or_fast!(28, 3);
}
parameter_types! {
@@ -568,7 +568,7 @@ impl pallet_treasury::Config for Runtime {
impl pallet_offences::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
type IdentificationTuple = pallet_session::historical::IdentificationTuple;
- type OnOffenceHandler = ();
+ type OnOffenceHandler = ExternalValidatorSlashes;
}
impl pallet_authority_discovery::Config for Runtime {
@@ -1210,8 +1210,25 @@ impl pallet_beefy_mmr::Config for Runtime {
impl paras_sudo_wrapper::Config for Runtime {}
+use pallet_staking::SessionInterface;
+pub struct DancelightSessionInterface;
+impl SessionInterface for DancelightSessionInterface {
+ fn disable_validator(validator_index: u32) -> bool {
+ Session::disable_index(validator_index)
+ }
+
+ fn validators() -> Vec {
+ Session::validators()
+ }
+
+ fn prune_historical_up_to(up_to: SessionIndex) {
+ Historical::prune_up_to(up_to);
+ }
+}
+
parameter_types! {
pub const SessionsPerEra: SessionIndex = runtime_common::prod_or_fast!(6, 3);
+ pub const SlashDeferDuration: EraIndex = runtime_common::prod_or_fast!(27, 2);
}
impl pallet_external_validators::Config for Runtime {
@@ -1225,13 +1242,26 @@ impl pallet_external_validators::Config for Runtime {
type ValidatorRegistration = Session;
type UnixTime = Timestamp;
type SessionsPerEra = SessionsPerEra;
- type OnEraStart = ();
+ type OnEraStart = ExternalValidatorSlashes;
type OnEraEnd = ();
type WeightInfo = weights::pallet_external_validators::SubstrateWeight;
#[cfg(feature = "runtime-benchmarks")]
type Currency = Balances;
}
+impl pallet_external_validator_slashes::Config for Runtime {
+ type RuntimeEvent = RuntimeEvent;
+ type ValidatorId = AccountId;
+ type ValidatorIdOf = ValidatorIdOf;
+ type SlashDeferDuration = SlashDeferDuration;
+ type BondingDuration = BondingDuration;
+ type SlashId = u32;
+ type SessionInterface = DancelightSessionInterface;
+ type EraIndexProvider = ExternalValidators;
+ type InvulnerablesProvider = ExternalValidators;
+ type WeightInfo = weights::pallet_external_validator_slashes::SubstrateWeight;
+}
+
impl pallet_sudo::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
type RuntimeCall = RuntimeCall;
@@ -1542,6 +1572,7 @@ construct_runtime! {
// Validator stuff
ExternalValidators: pallet_external_validators = 20,
+ ExternalValidatorSlashes: pallet_external_validator_slashes = 21,
// Session management
Session: pallet_session = 30,
@@ -1940,11 +1971,13 @@ mod benches {
[pallet_registrar, ContainerRegistrar]
[pallet_collator_assignment, TanssiCollatorAssignment]
[pallet_external_validators, ExternalValidators]
+ [pallet_external_validator_slashes, ExternalValidatorSlashes]
// XCM
[pallet_xcm, PalletXcmExtrinsicsBenchmark::]
[pallet_xcm_benchmarks::fungible, pallet_xcm_benchmarks::fungible::Pallet::]
[pallet_xcm_benchmarks::generic, pallet_xcm_benchmarks::generic::Pallet::]
+
// Bridges
[snowbridge_pallet_ethereum_client, EthereumBeaconClient]
);
diff --git a/solo-chains/runtime/dancelight/src/tests/common/mod.rs b/solo-chains/runtime/dancelight/src/tests/common/mod.rs
index 9be0b6bc7..4c9c13222 100644
--- a/solo-chains/runtime/dancelight/src/tests/common/mod.rs
+++ b/solo-chains/runtime/dancelight/src/tests/common/mod.rs
@@ -1213,3 +1213,105 @@ pub fn generate_ethereum_pub_keys(n: u32) -> Vec {
}
keys
}
+
+use babe_primitives::AuthorityPair as BabeAuthorityPair;
+use grandpa_primitives::{
+ AuthorityPair as GrandpaAuthorityPair, Equivocation, EquivocationProof, RoundNumber, SetId,
+};
+use sp_core::H256;
+pub fn generate_grandpa_equivocation_proof(
+ set_id: SetId,
+ vote1: (RoundNumber, H256, u32, &GrandpaAuthorityPair),
+ vote2: (RoundNumber, H256, u32, &GrandpaAuthorityPair),
+) -> EquivocationProof {
+ let signed_prevote = |round, hash, number, authority_pair: &GrandpaAuthorityPair| {
+ let prevote = finality_grandpa::Prevote {
+ target_hash: hash,
+ target_number: number,
+ };
+
+ let prevote_msg = finality_grandpa::Message::Prevote(prevote.clone());
+ let payload = grandpa_primitives::localized_payload(round, set_id, &prevote_msg);
+ let signed = authority_pair.sign(&payload);
+ (prevote, signed)
+ };
+
+ let (prevote1, signed1) = signed_prevote(vote1.0, vote1.1, vote1.2, vote1.3);
+ let (prevote2, signed2) = signed_prevote(vote2.0, vote2.1, vote2.2, vote2.3);
+
+ EquivocationProof::new(
+ set_id,
+ Equivocation::Prevote(finality_grandpa::Equivocation {
+ round_number: vote1.0,
+ identity: vote1.3.public(),
+ first: (prevote1, signed1),
+ second: (prevote2, signed2),
+ }),
+ )
+}
+
+/// Creates an equivocation at the current block, by generating two headers.
+pub fn generate_babe_equivocation_proof(
+ offender_authority_pair: &BabeAuthorityPair,
+) -> babe_primitives::EquivocationProof {
+ use babe_primitives::digests::CompatibleDigestItem;
+
+ let current_digest = System::digest();
+ let babe_predigest = current_digest
+ .clone()
+ .logs()
+ .iter()
+ .find_map(|log| log.as_babe_pre_digest());
+ let slot_proof = babe_predigest.expect("babe should be presesnt").slot();
+
+ let make_headers = || {
+ (
+ HeaderFor::::new(
+ 0,
+ H256::default(),
+ H256::default(),
+ H256::default(),
+ current_digest.clone(),
+ ),
+ HeaderFor::::new(
+ 1,
+ H256::default(),
+ H256::default(),
+ H256::default(),
+ current_digest.clone(),
+ ),
+ )
+ };
+
+ // sign the header prehash and sign it, adding it to the block as the seal
+ // digest item
+ let seal_header = |header: &mut crate::Header| {
+ let prehash = header.hash();
+ let seal = ::babe_seal(
+ offender_authority_pair.sign(prehash.as_ref()),
+ );
+ header.digest_mut().push(seal);
+ };
+
+ // generate two headers at the current block
+ let (mut h1, mut h2) = make_headers();
+
+ seal_header(&mut h1);
+ seal_header(&mut h2);
+
+ babe_primitives::EquivocationProof {
+ slot: slot_proof,
+ offender: offender_authority_pair.public(),
+ first_header: h1,
+ second_header: h2,
+ }
+}
+
+use sp_core::Public;
+/// Helper function to generate a crypto pair from seed
+pub fn get_pair_from_seed(seed: &str) -> TPublic::Pair {
+ let secret_uri = format!("//{}", seed);
+ let pair = TPublic::Pair::from_string(&secret_uri, None).expect("static values are valid; qed");
+
+ pair
+}
diff --git a/solo-chains/runtime/dancelight/src/tests/mod.rs b/solo-chains/runtime/dancelight/src/tests/mod.rs
index b74850346..a9c7af4ca 100644
--- a/solo-chains/runtime/dancelight/src/tests/mod.rs
+++ b/solo-chains/runtime/dancelight/src/tests/mod.rs
@@ -34,6 +34,7 @@ mod relay_configuration;
mod relay_registrar;
mod services_payment;
mod session_keys;
+mod slashes;
mod sudo;
#[test]
diff --git a/solo-chains/runtime/dancelight/src/tests/slashes.rs b/solo-chains/runtime/dancelight/src/tests/slashes.rs
new file mode 100644
index 000000000..8ee1214ba
--- /dev/null
+++ b/solo-chains/runtime/dancelight/src/tests/slashes.rs
@@ -0,0 +1,396 @@
+// Copyright (C) Moondance Labs Ltd.
+// This file is part of Tanssi.
+
+// Tanssi 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.
+
+// Tanssi 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 Tanssi. If not, see
+
+use frame_support::traits::KeyOwnerProofSystem;
+use sp_core::Pair;
+use sp_runtime::Perbill;
+use {
+ crate::tests::common::*,
+ crate::{
+ BondingDuration, ExternalValidatorSlashes, ExternalValidators, Grandpa, Historical,
+ SessionsPerEra, SlashDeferDuration,
+ },
+ frame_support::{assert_noop, assert_ok},
+ sp_core::H256,
+ sp_std::vec,
+};
+
+#[test]
+fn invulnerables_cannot_be_slashed() {
+ ExtBuilder::default()
+ .with_balances(vec![
+ // Alice gets 10k extra tokens for her mapping deposit
+ (AccountId::from(ALICE), 210_000 * UNIT),
+ (AccountId::from(BOB), 100_000 * UNIT),
+ (AccountId::from(CHARLIE), 100_000 * UNIT),
+ (AccountId::from(DAVE), 100_000 * UNIT),
+ ])
+ .build()
+ .execute_with(|| {
+ run_to_block(2);
+ inject_babe_slash(&AccountId::from(ALICE).to_string());
+ let reports = pallet_offences::Reports::::iter().collect::>();
+ assert_eq!(reports.len(), 1);
+ assert_eq!(ExternalValidators::current_era().unwrap(), 0);
+
+ let slashes = ExternalValidatorSlashes::slashes(
+ ExternalValidators::current_era().unwrap() + SlashDeferDuration::get() + 1,
+ );
+ assert_eq!(slashes.len(), 0);
+ });
+}
+
+#[test]
+fn non_invulnerables_can_be_slashed_with_babe() {
+ ExtBuilder::default()
+ .with_balances(vec![
+ // Alice gets 10k extra tokens for her mapping deposit
+ (AccountId::from(ALICE), 210_000 * UNIT),
+ (AccountId::from(BOB), 100_000 * UNIT),
+ (AccountId::from(CHARLIE), 100_000 * UNIT),
+ (AccountId::from(DAVE), 100_000 * UNIT),
+ ])
+ .build()
+ .execute_with(|| {
+ run_to_block(2);
+ assert_ok!(ExternalValidators::remove_whitelisted(
+ RuntimeOrigin::root(),
+ AccountId::from(ALICE)
+ ));
+
+ inject_babe_slash(&AccountId::from(ALICE).to_string());
+
+ let reports = pallet_offences::Reports::::iter().collect::>();
+ assert_eq!(reports.len(), 1);
+ assert_eq!(ExternalValidators::current_era().unwrap(), 0);
+
+ let slashes = ExternalValidatorSlashes::slashes(
+ ExternalValidators::current_era().unwrap() + SlashDeferDuration::get() + 1,
+ );
+ assert_eq!(slashes.len(), 1);
+ assert_eq!(slashes[0].validator, AccountId::from(ALICE));
+ //the formula is (3*offenders/num_validators)^2
+ // since we have 1 offender, 2 validators, this makes it a maximum of 1
+ assert_eq!(slashes[0].percentage, Perbill::from_percent(100));
+ });
+}
+
+#[test]
+fn non_invulnerables_can_be_slashed_with_grandpa() {
+ ExtBuilder::default()
+ .with_balances(vec![
+ // Alice gets 10k extra tokens for her mapping deposit
+ (AccountId::from(ALICE), 210_000 * UNIT),
+ (AccountId::from(BOB), 100_000 * UNIT),
+ (AccountId::from(CHARLIE), 100_000 * UNIT),
+ (AccountId::from(DAVE), 100_000 * UNIT),
+ ])
+ .build()
+ .execute_with(|| {
+ run_to_block(2);
+ assert_ok!(ExternalValidators::remove_whitelisted(
+ RuntimeOrigin::root(),
+ AccountId::from(ALICE)
+ ));
+
+ inject_grandpa_slash(&AccountId::from(ALICE).to_string());
+
+ let reports = pallet_offences::Reports::::iter().collect::>();
+ assert_eq!(reports.len(), 1);
+ assert_eq!(ExternalValidators::current_era().unwrap(), 0);
+
+ let slashes = ExternalValidatorSlashes::slashes(
+ ExternalValidators::current_era().unwrap() + SlashDeferDuration::get() + 1,
+ );
+ assert_eq!(slashes.len(), 1);
+ assert_eq!(slashes[0].validator, AccountId::from(ALICE));
+ //the formula is (3*offenders/num_validators)^2
+ // since we have 1 offender, 2 validators, this makes it a maximum of 1
+ assert_eq!(slashes[0].percentage, Perbill::from_percent(100));
+ });
+}
+
+#[test]
+fn test_slashing_percentage_applied_correctly() {
+ ExtBuilder::default()
+ .with_balances(vec![
+ // Alice gets 10k extra tokens for her mapping deposit
+ (AccountId::from(ALICE), 210_000 * UNIT),
+ (AccountId::from(BOB), 100_000 * UNIT),
+ (AccountId::from(CHARLIE), 100_000 * UNIT),
+ (AccountId::from(DAVE), 100_000 * UNIT),
+ ])
+ .with_validators(vec![
+ (AccountId::from(ALICE), 210 * UNIT),
+ (AccountId::from(BOB), 100 * UNIT),
+ (AccountId::from(CHARLIE), 100 * UNIT),
+ (AccountId::from(DAVE), 100 * UNIT),
+ ])
+ .build()
+ .execute_with(|| {
+ run_to_block(2);
+ assert_ok!(ExternalValidators::remove_whitelisted(
+ RuntimeOrigin::root(),
+ AccountId::from(ALICE)
+ ));
+
+ inject_babe_slash(&AccountId::from(ALICE).to_string());
+
+ let reports = pallet_offences::Reports::::iter().collect::>();
+ assert_eq!(reports.len(), 1);
+ assert_eq!(ExternalValidators::current_era().unwrap(), 0);
+
+ let slashes = ExternalValidatorSlashes::slashes(
+ ExternalValidators::current_era().unwrap() + SlashDeferDuration::get() + 1,
+ );
+ assert_eq!(slashes.len(), 1);
+ assert_eq!(slashes[0].validator, AccountId::from(ALICE));
+ //the formula is (3*offenders/num_validators)^2
+ // since we have 1 offender, 4 validators, this makes it a maximum of 0.75^2=0.5625
+ assert_eq!(slashes[0].percentage, Perbill::from_parts(562500000));
+ });
+}
+
+#[test]
+fn test_slashes_are_not_additive_in_percentage() {
+ ExtBuilder::default()
+ .with_balances(vec![
+ // Alice gets 10k extra tokens for her mapping deposit
+ (AccountId::from(ALICE), 210_000 * UNIT),
+ (AccountId::from(BOB), 100_000 * UNIT),
+ (AccountId::from(CHARLIE), 100_000 * UNIT),
+ (AccountId::from(DAVE), 100_000 * UNIT),
+ (AccountId::from(EVE), 100_000 * UNIT),
+ ])
+ .with_validators(vec![
+ (AccountId::from(ALICE), 210 * UNIT),
+ (AccountId::from(BOB), 100 * UNIT),
+ (AccountId::from(CHARLIE), 100 * UNIT),
+ (AccountId::from(DAVE), 100 * UNIT),
+ (AccountId::from(EVE), 100 * UNIT),
+ ])
+ .build()
+ .execute_with(|| {
+ run_to_block(2);
+ assert_ok!(ExternalValidators::remove_whitelisted(
+ RuntimeOrigin::root(),
+ AccountId::from(ALICE)
+ ));
+
+ inject_babe_slash(&AccountId::from(ALICE).to_string());
+
+ inject_grandpa_slash(&AccountId::from(ALICE).to_string());
+
+ let reports = pallet_offences::Reports::::iter().collect::>();
+
+ // we have 2 reports
+ assert_eq!(reports.len(), 2);
+ assert_eq!(ExternalValidators::current_era().unwrap(), 0);
+
+ let slashes = ExternalValidatorSlashes::slashes(
+ ExternalValidators::current_era().unwrap() + SlashDeferDuration::get() + 1,
+ );
+
+ // but a single slash
+ assert_eq!(slashes.len(), 1);
+ assert_eq!(slashes[0].validator, AccountId::from(ALICE));
+ // the formula is (3*offenders/num_validators)^2
+ // since we have 1 offender, 5 validators, this makes it 0.36
+ // we injected 2 offences BUT THEY ARE NOT ADDITIVE
+ assert_eq!(slashes[0].percentage, Perbill::from_parts(360000000));
+ });
+}
+#[test]
+fn test_slashes_are_cleaned_after_bonding_period() {
+ ExtBuilder::default()
+ .with_balances(vec![
+ // Alice gets 10k extra tokens for her mapping deposit
+ (AccountId::from(ALICE), 210_000 * UNIT),
+ (AccountId::from(BOB), 100_000 * UNIT),
+ (AccountId::from(CHARLIE), 100_000 * UNIT),
+ (AccountId::from(DAVE), 100_000 * UNIT),
+ ])
+ .build()
+ .execute_with(|| {
+ run_to_block(2);
+ assert_ok!(ExternalValidators::remove_whitelisted(
+ RuntimeOrigin::root(),
+ AccountId::from(ALICE)
+ ));
+
+ inject_babe_slash(&AccountId::from(ALICE).to_string());
+
+ let reports = pallet_offences::Reports::::iter().collect::>();
+ assert_eq!(reports.len(), 1);
+ assert_eq!(ExternalValidators::current_era().unwrap(), 0);
+
+ let slashes = ExternalValidatorSlashes::slashes(
+ ExternalValidators::current_era().unwrap() + SlashDeferDuration::get() + 1,
+ );
+ assert_eq!(slashes.len(), 1);
+ // The first session in which the era 3 will be pruned is
+ // (28+3+1)*sessionsPerEra
+ let fist_session_era_3_pruned = (ExternalValidators::current_era().unwrap()
+ + SlashDeferDuration::get()
+ + 1
+ + BondingDuration::get()
+ + 1)
+ * SessionsPerEra::get();
+
+ let first_era_deferred =
+ ExternalValidators::current_era().unwrap() + SlashDeferDuration::get() + 1;
+
+ println!("first era deferred {:?}", first_era_deferred);
+ run_to_session(fist_session_era_3_pruned);
+
+ let slashes_after_bonding_period =
+ ExternalValidatorSlashes::slashes(first_era_deferred);
+ assert_eq!(slashes_after_bonding_period.len(), 0);
+ });
+}
+
+#[test]
+fn test_slashes_can_be_cleared_before_deferred_period_applies() {
+ ExtBuilder::default()
+ .with_balances(vec![
+ // Alice gets 10k extra tokens for her mapping deposit
+ (AccountId::from(ALICE), 210_000 * UNIT),
+ (AccountId::from(BOB), 100_000 * UNIT),
+ (AccountId::from(CHARLIE), 100_000 * UNIT),
+ (AccountId::from(DAVE), 100_000 * UNIT),
+ ])
+ .build()
+ .execute_with(|| {
+ run_to_block(2);
+ assert_ok!(ExternalValidators::remove_whitelisted(
+ RuntimeOrigin::root(),
+ AccountId::from(ALICE)
+ ));
+
+ inject_babe_slash(&AccountId::from(ALICE).to_string());
+
+ let reports = pallet_offences::Reports::::iter().collect::>();
+ assert_eq!(reports.len(), 1);
+ assert_eq!(ExternalValidators::current_era().unwrap(), 0);
+
+ let deferred_era =
+ ExternalValidators::current_era().unwrap() + SlashDeferDuration::get() + 1;
+ let slashes = ExternalValidatorSlashes::slashes(deferred_era);
+ assert_eq!(slashes.len(), 1);
+ assert_eq!(slashes[0].validator, AccountId::from(ALICE));
+
+ // Now let's clean it up
+ assert_ok!(ExternalValidatorSlashes::cancel_deferred_slash(
+ RuntimeOrigin::root(),
+ deferred_era,
+ vec![0]
+ ));
+ let slashes_after_cancel = ExternalValidatorSlashes::slashes(deferred_era);
+ assert_eq!(slashes_after_cancel.len(), 0);
+ });
+}
+
+#[test]
+fn test_slashes_cannot_be_cancelled_after_defer_period() {
+ ExtBuilder::default()
+ .with_balances(vec![
+ // Alice gets 10k extra tokens for her mapping deposit
+ (AccountId::from(ALICE), 210_000 * UNIT),
+ (AccountId::from(BOB), 100_000 * UNIT),
+ (AccountId::from(CHARLIE), 100_000 * UNIT),
+ (AccountId::from(DAVE), 100_000 * UNIT),
+ ])
+ .build()
+ .execute_with(|| {
+ run_to_block(2);
+ assert_ok!(ExternalValidators::remove_whitelisted(
+ RuntimeOrigin::root(),
+ AccountId::from(ALICE)
+ ));
+
+ inject_babe_slash(&AccountId::from(ALICE).to_string());
+
+ let reports = pallet_offences::Reports::::iter().collect::>();
+ assert_eq!(reports.len(), 1);
+ assert_eq!(ExternalValidators::current_era().unwrap(), 0);
+
+ let deferred_era =
+ ExternalValidators::current_era().unwrap() + SlashDeferDuration::get() + 1;
+
+ let slashes = ExternalValidatorSlashes::slashes(deferred_era);
+ assert_eq!(slashes.len(), 1);
+ assert_eq!(slashes[0].validator, AccountId::from(ALICE));
+
+ // The first session in which the era 3 will be deferred is 18
+ // 3 sessions per era
+ // (externalValidators::current_era().unwrap() + SlashDeferDuration::get() + 1)*SessionsPerEra
+ // formula is:
+
+ let first_deferred_session =
+ (ExternalValidators::current_era().unwrap() + SlashDeferDuration::get() + 1)
+ * SessionsPerEra::get();
+ run_to_session(first_deferred_session);
+
+ assert_eq!(ExternalValidators::current_era().unwrap(), deferred_era);
+ // Now let's clean it up
+ assert_noop!(
+ ExternalValidatorSlashes::cancel_deferred_slash(
+ RuntimeOrigin::root(),
+ deferred_era,
+ vec![0]
+ ),
+ pallet_external_validator_slashes::Error::::DeferPeriodIsOver
+ );
+ });
+}
+
+fn inject_babe_slash(seed: &str) {
+ let babe_key = get_pair_from_seed::(seed);
+ let equivocation_proof = generate_babe_equivocation_proof(&babe_key);
+
+ // create the key ownership proof
+ let key = (babe_primitives::KEY_TYPE, babe_key.public());
+ let key_owner_proof = Historical::prove(key).unwrap();
+
+ // report the equivocation
+ assert_ok!(Babe::report_equivocation_unsigned(
+ RuntimeOrigin::none(),
+ Box::new(equivocation_proof),
+ key_owner_proof,
+ ));
+}
+
+fn inject_grandpa_slash(seed: &str) {
+ let grandpa_key = get_pair_from_seed::(seed);
+
+ let set_id = Grandpa::current_set_id();
+
+ let equivocation_proof = generate_grandpa_equivocation_proof(
+ set_id,
+ (1, H256::random(), 1, &grandpa_key),
+ (1, H256::random(), 1, &grandpa_key),
+ );
+ // create the key ownership proof
+ let key = (grandpa_primitives::KEY_TYPE, grandpa_key.public());
+ let key_owner_proof = Historical::prove(key).unwrap();
+
+ // report the equivocation
+ assert_ok!(Grandpa::report_equivocation_unsigned(
+ RuntimeOrigin::none(),
+ Box::new(equivocation_proof),
+ key_owner_proof,
+ ));
+}
diff --git a/solo-chains/runtime/dancelight/src/weights/mod.rs b/solo-chains/runtime/dancelight/src/weights/mod.rs
index f8eb4f314..ee0098635 100644
--- a/solo-chains/runtime/dancelight/src/weights/mod.rs
+++ b/solo-chains/runtime/dancelight/src/weights/mod.rs
@@ -20,6 +20,7 @@ pub mod pallet_author_noting;
pub mod pallet_balances;
pub mod pallet_collator_assignment;
pub mod pallet_conviction_voting;
+pub mod pallet_external_validator_slashes;
pub mod pallet_external_validators;
pub mod pallet_identity;
pub mod pallet_message_queue;
diff --git a/solo-chains/runtime/dancelight/src/weights/pallet_external_validator_slashes.rs b/solo-chains/runtime/dancelight/src/weights/pallet_external_validator_slashes.rs
new file mode 100644
index 000000000..f4dcd30ff
--- /dev/null
+++ b/solo-chains/runtime/dancelight/src/weights/pallet_external_validator_slashes.rs
@@ -0,0 +1,88 @@
+// Copyright (C) Moondance Labs Ltd.
+// This file is part of Tanssi.
+
+// Tanssi 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.
+
+// Tanssi 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 Tanssi. If not, see
+
+
+//! Autogenerated weights for pallet_external_validator_slashes
+//!
+//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 42.0.0
+//! DATE: 2024-10-24, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]`
+//! WORST CASE MAP SIZE: `1000000`
+//! HOSTNAME: `girazoki-XPS-15-9530`, CPU: `13th Gen Intel(R) Core(TM) i9-13900H`
+//! EXECUTION: , WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024
+
+// Executed Command:
+// target/release/tanssi-relay
+// benchmark
+// pallet
+// --execution=wasm
+// --wasm-execution=compiled
+// --pallet
+// pallet_external_validator_slashes
+// --extrinsic
+// *
+// --chain=dev
+// --steps
+// 50
+// --repeat
+// 20
+// --template=./benchmarking/frame-weight-runtime-template.hbs
+// --json-file
+// raw.json
+// --output
+// tmp/pallet_external_validator_slashes.rs
+
+#![cfg_attr(rustfmt, rustfmt_skip)]
+#![allow(unused_parens)]
+#![allow(unused_imports)]
+
+use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}};
+use sp_std::marker::PhantomData;
+
+/// Weights for pallet_external_validator_slashes using the Substrate node and recommended hardware.
+pub struct SubstrateWeight(PhantomData);
+impl pallet_external_validator_slashes::WeightInfo for SubstrateWeight {
+ /// Storage: `ExternalValidators::ActiveEra` (r:1 w:0)
+ /// Proof: `ExternalValidators::ActiveEra` (`max_values`: Some(1), `max_size`: Some(13), added: 508, mode: `MaxEncodedLen`)
+ /// Storage: `ExternalValidatorSlashes::Slashes` (r:1 w:1)
+ /// Proof: `ExternalValidatorSlashes::Slashes` (`max_values`: None, `max_size`: None, mode: `Measured`)
+ /// The range of component `s` is `[1, 1000]`.
+ fn cancel_deferred_slash(s: u32, ) -> Weight {
+ // Proof Size summary in bytes:
+ // Measured: `42194`
+ // Estimated: `45659`
+ // Minimum execution time: 67_311_000 picoseconds.
+ Weight::from_parts(536_999_990, 45659)
+ // Standard Error: 37_157
+ .saturating_add(Weight::from_parts(3_022_012, 0).saturating_mul(s.into()))
+ .saturating_add(T::DbWeight::get().reads(2_u64))
+ .saturating_add(T::DbWeight::get().writes(1_u64))
+ }
+ /// Storage: `ExternalValidators::ActiveEra` (r:1 w:0)
+ /// Proof: `ExternalValidators::ActiveEra` (`max_values`: Some(1), `max_size`: Some(13), added: 508, mode: `MaxEncodedLen`)
+ /// Storage: `ExternalValidatorSlashes::NextSlashId` (r:1 w:1)
+ /// Proof: `ExternalValidatorSlashes::NextSlashId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`)
+ /// Storage: `ExternalValidatorSlashes::Slashes` (r:1 w:1)
+ /// Proof: `ExternalValidatorSlashes::Slashes` (`max_values`: None, `max_size`: None, mode: `Measured`)
+ fn force_inject_slash() -> Weight {
+ // Proof Size summary in bytes:
+ // Measured: `151`
+ // Estimated: `3616`
+ // Minimum execution time: 7_398_000 picoseconds.
+ Weight::from_parts(7_725_000, 3616)
+ .saturating_add(T::DbWeight::get().reads(3_u64))
+ .saturating_add(T::DbWeight::get().writes(2_u64))
+ }
+}
\ No newline at end of file
diff --git a/test/suites/dev-tanssi-relay/slashes/test_slashes_are_confirmed_after_defer_period.ts b/test/suites/dev-tanssi-relay/slashes/test_slashes_are_confirmed_after_defer_period.ts
new file mode 100644
index 000000000..9b27c56e7
--- /dev/null
+++ b/test/suites/dev-tanssi-relay/slashes/test_slashes_are_confirmed_after_defer_period.ts
@@ -0,0 +1,98 @@
+import "@tanssi/api-augment";
+import { describeSuite, expect, beforeAll } from "@moonwall/cli";
+import { ApiPromise } from "@polkadot/api";
+import { KeyringPair } from "@moonwall/util";
+import { Keyring } from "@polkadot/keyring";
+import { u8aToHex } from "@polkadot/util";
+import { jumpToSession } from "../../../util/block";
+import { generateBabeEquivocationProof } from "../../../util/slashes";
+
+describeSuite({
+ id: "DTR1304",
+ title: "Babe slashes defer period confirmation",
+ foundationMethods: "dev",
+ testCases: ({ it, context }) => {
+ let polkadotJs: ApiPromise;
+ let alice: KeyringPair;
+ let aliceBabePair: KeyringPair;
+ let aliceStash: KeyringPair;
+ beforeAll(async () => {
+ const keyringBabe = new Keyring({ type: "sr25519" });
+ aliceBabePair = keyringBabe.addFromUri("//Alice");
+ polkadotJs = context.polkadotJs();
+ alice = context.keyring.alice;
+ aliceStash = keyringBabe.addFromUri("//Alice//stash");
+ });
+ it({
+ id: "E01",
+ title: "Babe offences should be confirmed after defer period",
+ test: async function () {
+ // we crate one block so that we at least have one seal.
+ await jumpToSession(context, 1);
+
+ // Remove alice from invulnerables (just for the slash)
+ const removeAliceFromInvulnerables = await polkadotJs.tx.sudo
+ .sudo(polkadotJs.tx.externalValidators.removeWhitelisted(aliceStash.address))
+ .signAsync(alice);
+ await context.createBlock([removeAliceFromInvulnerables]);
+
+ // let's inject the equivocation proof
+ const doubleVotingProof = await generateBabeEquivocationProof(polkadotJs, aliceBabePair);
+
+ // generate key ownership proof
+ const keyOwnershipProof = (
+ await polkadotJs.call.babeApi.generateKeyOwnershipProof(
+ doubleVotingProof.slotNumber,
+ u8aToHex(aliceBabePair.publicKey)
+ )
+ ).unwrap();
+ const keyOwnershipProofHex = `0x${keyOwnershipProof.toHuman().toString().slice(8)}`;
+
+ const tx = polkadotJs.tx.sudo.sudoUncheckedWeight(
+ polkadotJs.tx.utility.dispatchAs(
+ {
+ system: { Signed: alice.address },
+ } as any,
+ polkadotJs.tx.babe.reportEquivocation(doubleVotingProof, keyOwnershipProofHex)
+ ),
+ {
+ refTime: 1n,
+ proofSize: 1n,
+ }
+ );
+
+ const signedTx = await tx.signAsync(alice);
+ await context.createBlock(signedTx);
+
+ // Slash item should be there
+ const DeferPeriod = (await polkadotJs.consts.externalValidatorSlashes.slashDeferDuration).toNumber();
+
+ // scheduled slashes
+ const expectedSlashes = await polkadotJs.query.externalValidatorSlashes.slashes(DeferPeriod + 1);
+ expect(expectedSlashes.length).to.be.eq(1);
+ expect(u8aToHex(expectedSlashes[0].validator)).to.be.eq(u8aToHex(aliceStash.addressRaw));
+
+ // Put alice back to invulnerables
+ const addAliceFromInvulnerables = await polkadotJs.tx.sudo
+ .sudo(polkadotJs.tx.externalValidators.addWhitelisted(aliceStash.address))
+ .signAsync(alice);
+ await context.createBlock([addAliceFromInvulnerables]);
+
+ const sessionsPerEra = await polkadotJs.consts.externalValidators.sessionsPerEra;
+
+ const currentIndex = await polkadotJs.query.session.currentIndex();
+
+ const targetSession = currentIndex * sessionsPerEra * (DeferPeriod + 1);
+
+ await jumpToSession(context, targetSession);
+
+ // scheduled slashes
+ const expectedSlashesAfterDefer = await polkadotJs.query.externalValidatorSlashes.slashes(
+ DeferPeriod + 1
+ );
+ expect(expectedSlashesAfterDefer.length).to.be.eq(1);
+ expect(expectedSlashesAfterDefer[0].confirmed.toHuman()).to.be.true;
+ },
+ });
+ },
+});
diff --git a/test/suites/dev-tanssi-relay/slashes/test_slashes_are_not_applicable_to_invulnerables.ts b/test/suites/dev-tanssi-relay/slashes/test_slashes_are_not_applicable_to_invulnerables.ts
new file mode 100644
index 000000000..e4c5b4ce6
--- /dev/null
+++ b/test/suites/dev-tanssi-relay/slashes/test_slashes_are_not_applicable_to_invulnerables.ts
@@ -0,0 +1,70 @@
+import "@tanssi/api-augment";
+import { describeSuite, expect, beforeAll } from "@moonwall/cli";
+import { ApiPromise } from "@polkadot/api";
+import { KeyringPair } from "@moonwall/util";
+import { Keyring } from "@polkadot/keyring";
+import { u8aToHex } from "@polkadot/util";
+import { jumpToSession } from "../../../util/block";
+import { generateBabeEquivocationProof } from "../../../util/slashes";
+
+describeSuite({
+ id: "DTR1302",
+ title: "Babe offences invulnerables",
+ foundationMethods: "dev",
+ testCases: ({ it, context }) => {
+ let polkadotJs: ApiPromise;
+ let alice: KeyringPair;
+ let aliceBabePair: KeyringPair;
+ beforeAll(async () => {
+ const keyringBabe = new Keyring({ type: "sr25519" });
+ aliceBabePair = keyringBabe.addFromUri("//Alice");
+ polkadotJs = context.polkadotJs();
+ alice = context.keyring.alice;
+ });
+ it({
+ id: "E01",
+ title: "Babe offences do not trigger a slash to invulnerables",
+ test: async function () {
+ // we crate one block so that we at least have one seal.
+ await jumpToSession(context, 1);
+
+ // let's inject the equivocation proof
+ const doubleVotingProof = await generateBabeEquivocationProof(polkadotJs, aliceBabePair);
+
+ // generate key ownership proof
+ const keyOwnershipProof = (
+ await polkadotJs.call.babeApi.generateKeyOwnershipProof(
+ doubleVotingProof.slotNumber,
+ u8aToHex(aliceBabePair.publicKey)
+ )
+ ).unwrap();
+ const keyOwnershipProofHex = `0x${keyOwnershipProof.toHuman().toString().slice(8)}`;
+
+ const tx = polkadotJs.tx.sudo.sudoUncheckedWeight(
+ polkadotJs.tx.utility.dispatchAs(
+ {
+ system: { Signed: alice.address },
+ } as any,
+ polkadotJs.tx.babe.reportEquivocation(doubleVotingProof, keyOwnershipProofHex)
+ ),
+ {
+ refTime: 1n,
+ proofSize: 1n,
+ }
+ );
+
+ const signedTx = await tx.signAsync(alice);
+ await context.createBlock(signedTx);
+
+ // Slash item should be there
+ const DeferPeriod = await polkadotJs.consts.externalValidatorSlashes.slashDeferDuration;
+
+ // Alice is an invulnerable, therefore she should not be slashed
+ const expectedSlashes = await polkadotJs.query.externalValidatorSlashes.slashes(
+ DeferPeriod.toNumber() + 1
+ );
+ expect(expectedSlashes.length).to.be.eq(0);
+ },
+ });
+ },
+});
diff --git a/test/suites/dev-tanssi-relay/slashes/test_slashes_are_removed_after_bonding_period.ts b/test/suites/dev-tanssi-relay/slashes/test_slashes_are_removed_after_bonding_period.ts
new file mode 100644
index 000000000..91eb73304
--- /dev/null
+++ b/test/suites/dev-tanssi-relay/slashes/test_slashes_are_removed_after_bonding_period.ts
@@ -0,0 +1,99 @@
+import "@tanssi/api-augment";
+import { describeSuite, expect, beforeAll } from "@moonwall/cli";
+import { ApiPromise } from "@polkadot/api";
+import { KeyringPair } from "@moonwall/util";
+import { Keyring } from "@polkadot/keyring";
+import { u8aToHex } from "@polkadot/util";
+import { jumpToSession } from "../../../util/block";
+import { generateBabeEquivocationProof } from "../../../util/slashes";
+
+describeSuite({
+ id: "DTR1305",
+ title: "Babe offences bonding period",
+ foundationMethods: "dev",
+ testCases: ({ it, context }) => {
+ let polkadotJs: ApiPromise;
+ let alice: KeyringPair;
+ let aliceBabePair: KeyringPair;
+ let aliceStash: KeyringPair;
+ beforeAll(async () => {
+ const keyringBabe = new Keyring({ type: "sr25519" });
+ aliceBabePair = keyringBabe.addFromUri("//Alice");
+ polkadotJs = context.polkadotJs();
+ alice = context.keyring.alice;
+ aliceStash = keyringBabe.addFromUri("//Alice//stash");
+ });
+ it({
+ id: "E01",
+ title: "Babe offences should be removed after bonding period",
+ test: async function () {
+ // we crate one block so that we at least have one seal.
+ await jumpToSession(context, 1);
+
+ // Remove alice from invulnerables (just for the slash)
+ const removeAliceFromInvulnerables = await polkadotJs.tx.sudo
+ .sudo(polkadotJs.tx.externalValidators.removeWhitelisted(aliceStash.address))
+ .signAsync(alice);
+ await context.createBlock([removeAliceFromInvulnerables]);
+
+ // let's inject the equivocation proof
+ const doubleVotingProof = await generateBabeEquivocationProof(polkadotJs, aliceBabePair);
+
+ // generate key ownership proof
+ const keyOwnershipProof = (
+ await polkadotJs.call.babeApi.generateKeyOwnershipProof(
+ doubleVotingProof.slotNumber,
+ u8aToHex(aliceBabePair.publicKey)
+ )
+ ).unwrap();
+ const keyOwnershipProofHex = `0x${keyOwnershipProof.toHuman().toString().slice(8)}`;
+
+ const tx = polkadotJs.tx.sudo.sudoUncheckedWeight(
+ polkadotJs.tx.utility.dispatchAs(
+ {
+ system: { Signed: alice.address },
+ } as any,
+ polkadotJs.tx.babe.reportEquivocation(doubleVotingProof, keyOwnershipProofHex)
+ ),
+ {
+ refTime: 1n,
+ proofSize: 1n,
+ }
+ );
+
+ const signedTx = await tx.signAsync(alice);
+ await context.createBlock(signedTx);
+
+ // Slash item should be there
+ const DeferPeriod = (await polkadotJs.consts.externalValidatorSlashes.slashDeferDuration).toNumber();
+
+ // scheduled slashes
+ const expectedSlashes = await polkadotJs.query.externalValidatorSlashes.slashes(DeferPeriod + 1);
+ expect(expectedSlashes.length).to.be.eq(1);
+ expect(u8aToHex(expectedSlashes[0].validator)).to.be.eq(u8aToHex(aliceStash.addressRaw));
+
+ // Put alice back to invulnerables
+ const addAliceFromInvulnerables = await polkadotJs.tx.sudo
+ .sudo(polkadotJs.tx.externalValidators.addWhitelisted(aliceStash.address))
+ .signAsync(alice);
+ await context.createBlock([addAliceFromInvulnerables]);
+
+ const sessionsPerEra = await polkadotJs.consts.externalValidators.sessionsPerEra;
+ const bondingPeriod = await polkadotJs.consts.externalValidatorSlashes.bondingDuration;
+
+ const currentIndex = await polkadotJs.query.session.currentIndex();
+
+ const targetSession =
+ currentIndex.toNumber() + sessionsPerEra * (DeferPeriod + 1) + sessionsPerEra * (bondingPeriod + 1);
+ // TODO: check this
+ await jumpToSession(context, targetSession);
+
+ // scheduled slashes
+ const expectedSlashesAfterDefer = await polkadotJs.query.externalValidatorSlashes.slashes(
+ DeferPeriod + 1
+ );
+ expect(expectedSlashesAfterDefer.length).to.be.eq(0);
+ },
+ });
+ },
+});
diff --git a/test/suites/dev-tanssi-relay/slashes/test_slashes_babe.ts b/test/suites/dev-tanssi-relay/slashes/test_slashes_babe.ts
new file mode 100644
index 000000000..f1a5d56c4
--- /dev/null
+++ b/test/suites/dev-tanssi-relay/slashes/test_slashes_babe.ts
@@ -0,0 +1,79 @@
+import "@tanssi/api-augment";
+import { describeSuite, expect, beforeAll } from "@moonwall/cli";
+import { ApiPromise } from "@polkadot/api";
+import { KeyringPair } from "@moonwall/util";
+import { Keyring } from "@polkadot/keyring";
+import { u8aToHex } from "@polkadot/util";
+import { jumpToSession } from "../../../util/block";
+import { generateBabeEquivocationProof } from "../../../util/slashes";
+
+describeSuite({
+ id: "DTR1301",
+ title: "Babe offences should trigger a slash",
+ foundationMethods: "dev",
+ testCases: ({ it, context }) => {
+ let polkadotJs: ApiPromise;
+ let alice: KeyringPair;
+ let aliceBabePair: KeyringPair;
+ let aliceStash: KeyringPair;
+ beforeAll(async () => {
+ const keyringBabe = new Keyring({ type: "sr25519" });
+ aliceBabePair = keyringBabe.addFromUri("//Alice");
+ polkadotJs = context.polkadotJs();
+ alice = context.keyring.alice;
+ aliceStash = keyringBabe.addFromUri("//Alice//stash");
+ });
+ it({
+ id: "E01",
+ title: "Babe offences trigger a slash",
+ test: async function () {
+ // we crate one block so that we at least have one seal.
+ await jumpToSession(context, 1);
+
+ // Remove alice from invulnerables (just for the slash)
+ const removeAliceFromInvulnerables = await polkadotJs.tx.sudo
+ .sudo(polkadotJs.tx.externalValidators.removeWhitelisted(aliceStash.address))
+ .signAsync(alice);
+ await context.createBlock([removeAliceFromInvulnerables]);
+
+ // let's inject the equivocation proof
+ const doubleVotingProof = await generateBabeEquivocationProof(polkadotJs, aliceBabePair);
+
+ // generate key ownership proof
+ const keyOwnershipProof = (
+ await polkadotJs.call.babeApi.generateKeyOwnershipProof(
+ doubleVotingProof.slotNumber,
+ u8aToHex(aliceBabePair.publicKey)
+ )
+ ).unwrap();
+ const keyOwnershipProofHex = `0x${keyOwnershipProof.toHuman().toString().slice(8)}`;
+
+ const tx = polkadotJs.tx.sudo.sudoUncheckedWeight(
+ polkadotJs.tx.utility.dispatchAs(
+ {
+ system: { Signed: alice.address },
+ } as any,
+ polkadotJs.tx.babe.reportEquivocation(doubleVotingProof, keyOwnershipProofHex)
+ ),
+ {
+ refTime: 1n,
+ proofSize: 1n,
+ }
+ );
+
+ const signedTx = await tx.signAsync(alice);
+ await context.createBlock(signedTx);
+
+ // Slash item should be there
+ const DeferPeriod = await polkadotJs.consts.externalValidatorSlashes.slashDeferDuration;
+
+ // scheduled slashes
+ const expectedSlashes = await polkadotJs.query.externalValidatorSlashes.slashes(
+ DeferPeriod.toNumber() + 1
+ );
+ expect(expectedSlashes.length).to.be.eq(1);
+ expect(u8aToHex(expectedSlashes[0].validator)).to.be.eq(u8aToHex(aliceStash.addressRaw));
+ },
+ });
+ },
+});
diff --git a/test/suites/dev-tanssi-relay/slashes/test_slashes_can_be_cancelled.ts b/test/suites/dev-tanssi-relay/slashes/test_slashes_can_be_cancelled.ts
new file mode 100644
index 000000000..8047093e7
--- /dev/null
+++ b/test/suites/dev-tanssi-relay/slashes/test_slashes_can_be_cancelled.ts
@@ -0,0 +1,89 @@
+import "@tanssi/api-augment";
+import { describeSuite, expect, beforeAll } from "@moonwall/cli";
+import { ApiPromise } from "@polkadot/api";
+import { KeyringPair } from "@moonwall/util";
+import { Keyring } from "@polkadot/keyring";
+import { u8aToHex } from "@polkadot/util";
+import { jumpToSession } from "../../../util/block";
+import { generateBabeEquivocationProof } from "../../../util/slashes";
+
+describeSuite({
+ id: "DTR1303",
+ title: "Babe offences should be cancellable",
+ foundationMethods: "dev",
+ testCases: ({ it, context }) => {
+ let polkadotJs: ApiPromise;
+ let alice: KeyringPair;
+ let aliceBabePair: KeyringPair;
+ let aliceStash: KeyringPair;
+ beforeAll(async () => {
+ const keyringBabe = new Keyring({ type: "sr25519" });
+ aliceBabePair = keyringBabe.addFromUri("//Alice");
+ polkadotJs = context.polkadotJs();
+ alice = context.keyring.alice;
+ aliceStash = keyringBabe.addFromUri("//Alice//stash");
+ });
+ it({
+ id: "E01",
+ title: "Babe offences are cancellable during the defer period",
+ test: async function () {
+ // we crate one block so that we at least have one seal.
+ await jumpToSession(context, 1);
+
+ // Remove alice from invulnerables (just for the slash)
+ const removeAliceFromInvulnerables = await polkadotJs.tx.sudo
+ .sudo(polkadotJs.tx.externalValidators.removeWhitelisted(aliceStash.address))
+ .signAsync(alice);
+ await context.createBlock([removeAliceFromInvulnerables]);
+
+ // let's inject the equivocation proof
+ const doubleVotingProof = await generateBabeEquivocationProof(polkadotJs, aliceBabePair);
+
+ // generate key ownership proof
+ const keyOwnershipProof = (
+ await polkadotJs.call.babeApi.generateKeyOwnershipProof(
+ doubleVotingProof.slotNumber,
+ u8aToHex(aliceBabePair.publicKey)
+ )
+ ).unwrap();
+ const keyOwnershipProofHex = `0x${keyOwnershipProof.toHuman().toString().slice(8)}`;
+
+ const tx = polkadotJs.tx.sudo.sudoUncheckedWeight(
+ polkadotJs.tx.utility.dispatchAs(
+ {
+ system: { Signed: alice.address },
+ } as any,
+ polkadotJs.tx.babe.reportEquivocation(doubleVotingProof, keyOwnershipProofHex)
+ ),
+ {
+ refTime: 1n,
+ proofSize: 1n,
+ }
+ );
+
+ const signedTx = await tx.signAsync(alice);
+ await context.createBlock(signedTx);
+
+ // Slash item should be there
+ const DeferPeriod = (await polkadotJs.consts.externalValidatorSlashes.slashDeferDuration).toNumber();
+
+ // scheduled slashes
+ const expectedSlashes = await polkadotJs.query.externalValidatorSlashes.slashes(DeferPeriod + 1);
+ expect(expectedSlashes.length).to.be.eq(1);
+ expect(u8aToHex(expectedSlashes[0].validator)).to.be.eq(u8aToHex(aliceStash.addressRaw));
+
+ // Remove alice from invulnerables (just for the slash)
+ const cancelSlash = await polkadotJs.tx.sudo
+ .sudo(polkadotJs.tx.externalValidatorSlashes.cancelDeferredSlash(DeferPeriod + 1, [0]))
+ .signAsync(alice);
+ await context.createBlock([cancelSlash]);
+
+ // alashes have dissapeared
+ const expectedSlashesAfterCancel = await polkadotJs.query.externalValidatorSlashes.slashes(
+ DeferPeriod + 1
+ );
+ expect(expectedSlashesAfterCancel.length).to.be.eq(0);
+ },
+ });
+ },
+});
diff --git a/test/suites/dev-tanssi-relay/slashes/test_slashes_grandpa.ts b/test/suites/dev-tanssi-relay/slashes/test_slashes_grandpa.ts
new file mode 100644
index 000000000..830ca0a3f
--- /dev/null
+++ b/test/suites/dev-tanssi-relay/slashes/test_slashes_grandpa.ts
@@ -0,0 +1,76 @@
+import "@tanssi/api-augment";
+import { describeSuite, expect, beforeAll } from "@moonwall/cli";
+import { ApiPromise } from "@polkadot/api";
+import { KeyringPair } from "@moonwall/util";
+import { Keyring } from "@polkadot/keyring";
+import { u8aToHex } from "@polkadot/util";
+import { jumpToSession } from "../../../util/block";
+import { generateGrandpaEquivocationProof } from "../../../util/slashes";
+
+describeSuite({
+ id: "DTR1306",
+ title: "Grandpa offences should trigger a slash",
+ foundationMethods: "dev",
+ testCases: ({ it, context }) => {
+ let polkadotJs: ApiPromise;
+ let alice: KeyringPair;
+ let aliceGrandpaPair: KeyringPair;
+ let aliceStash: KeyringPair;
+ beforeAll(async () => {
+ const keyringGrandpa = new Keyring({ type: "ed25519" });
+ const keyringSr25519 = new Keyring({ type: "sr25519" });
+ aliceGrandpaPair = keyringGrandpa.addFromUri("//Alice");
+ polkadotJs = context.polkadotJs();
+ alice = context.keyring.alice;
+ aliceStash = keyringSr25519.addFromUri("//Alice//stash");
+ });
+ it({
+ id: "E01",
+ title: "Grandpa offences trigger a slashing event",
+ test: async function () {
+ // we crate one block so that we at least have one seal.
+ await jumpToSession(context, 1);
+
+ // Remove alice from invulnerables (just for the slash)
+ const removeAliceFromInvulnerables = await polkadotJs.tx.sudo
+ .sudo(polkadotJs.tx.externalValidators.removeWhitelisted(aliceStash.address))
+ .signAsync(alice);
+ await context.createBlock([removeAliceFromInvulnerables]);
+
+ const doubleVotingProof = await generateGrandpaEquivocationProof(polkadotJs, aliceGrandpaPair);
+
+ const keyOwnershipProof = (
+ await polkadotJs.call.grandpaApi.generateKeyOwnershipProof(
+ doubleVotingProof.setId,
+ u8aToHex(aliceGrandpaPair.publicKey)
+ )
+ ).unwrap();
+ const keyOwnershipProofHex = `0x${keyOwnershipProof.toHuman().toString().slice(8)}`;
+
+ const tx = polkadotJs.tx.sudo.sudoUncheckedWeight(
+ polkadotJs.tx.utility.dispatchAs(
+ {
+ system: { Signed: alice.address },
+ } as any,
+ polkadotJs.tx.grandpa.reportEquivocation(doubleVotingProof, keyOwnershipProofHex)
+ ),
+ {
+ refTime: 1n,
+ proofSize: 1n,
+ }
+ );
+
+ const signedTx = await tx.signAsync(alice);
+ await context.createBlock(signedTx);
+
+ // Slash item should be there
+ const DeferPeriod = (await polkadotJs.consts.externalValidatorSlashes.slashDeferDuration).toNumber();
+
+ // scheduled slashes
+ const expectedSlashes = await polkadotJs.query.externalValidatorSlashes.slashes(DeferPeriod + 1);
+ expect(expectedSlashes.length).to.be.eq(1);
+ expect(u8aToHex(expectedSlashes[0].validator)).to.be.eq(u8aToHex(aliceStash.addressRaw));
+ },
+ });
+ },
+});
diff --git a/test/util/slashes.ts b/test/util/slashes.ts
new file mode 100644
index 000000000..483dbaf3f
--- /dev/null
+++ b/test/util/slashes.ts
@@ -0,0 +1,118 @@
+import { ApiPromise } from "@polkadot/api";
+import {
+ BabeEquivocationProof,
+ GrandpaEquivocationProof,
+ GrandpaEquivocation,
+ GrandpaEquivocationValue,
+} from "@polkadot/types/interfaces";
+import { SpRuntimeHeader, SpRuntimeDigestDigestItem, FinalityGrandpaPrevote } from "@polkadot/types/lookup";
+import { KeyringPair } from "@moonwall/util";
+import { blake2AsHex } from "@polkadot/util-crypto";
+import { u8aToHex, stringToHex, hexToU8a } from "@polkadot/util";
+
+export async function generateBabeEquivocationProof(
+ api: ApiPromise,
+ pair: KeyringPair
+): Promise {
+ const baseHeader = await api.rpc.chain.getHeader();
+ const baseHeader2 = await api.rpc.chain.getHeader();
+
+ const header1: SpRuntimeHeader = api.createType("SpRuntimeHeader", {
+ digest: baseHeader.digest,
+ extrinsicsRoot: baseHeader.extrinsicsRoot,
+ stateRoot: baseHeader.stateRoot,
+ parentHash: baseHeader.parentHash,
+ number: 1,
+ });
+
+ // we just change the block number
+ const header2: SpRuntimeHeader = api.createType("SpRuntimeHeader", {
+ digest: baseHeader2.digest,
+ extrinsicsRoot: baseHeader2.extrinsicsRoot,
+ stateRoot: baseHeader2.stateRoot,
+ parentHash: baseHeader2.parentHash,
+ number: 2,
+ });
+
+ const sig1 = pair.sign(blake2AsHex(header1.toU8a()));
+ const sig2 = pair.sign(blake2AsHex(header2.toU8a()));
+
+ const slot = await api.query.babe.currentSlot();
+
+ const digestItemSeal1: SpRuntimeDigestDigestItem = api.createType("SpRuntimeDigestDigestItem", {
+ Seal: [stringToHex("BABE"), u8aToHex(sig1)],
+ });
+
+ const digestItemSeal2: SpRuntimeDigestDigestItem = api.createType("SpRuntimeDigestDigestItem", {
+ Seal: [stringToHex("BABE"), u8aToHex(sig2)],
+ });
+
+ header1.digest.logs.push(digestItemSeal1);
+ header2.digest.logs.push(digestItemSeal2);
+
+ const doubleVotingProof: BabeEquivocationProof = api.createType("BabeEquivocationProof", {
+ offender: pair.publicKey,
+ slotNumber: slot,
+ firstHeader: header1,
+ secondHeader: header2,
+ });
+ return doubleVotingProof;
+}
+
+export async function generateGrandpaEquivocationProof(
+ api: ApiPromise,
+ pair: KeyringPair
+): Promise {
+ const prevote1: FinalityGrandpaPrevote = api.createType("FinalityGrandpaPrevote", {
+ targetHash: "0x0000000000000000000000000000000000000000000000000000000000000000",
+ targetNumber: 1,
+ });
+
+ const prevote2: FinalityGrandpaPrevote = api.createType("FinalityGrandpaPrevote", {
+ targetHash: "0x0000000000000000000000000000000000000000000000000000000000000000",
+ targetNumber: 2,
+ });
+
+ const roundNumber = api.createType("u64", 1);
+ const setId = await api.query.grandpa.currentSetId();
+
+ // I could not find the proper struct that holds all this into a singl message
+ // ergo I need to construct the signing payload myself
+ // the first 0 is because of this enum variant
+ // https://github.com/paritytech/finality-grandpa/blob/8c45a664c05657f0c71057158d3ba555ba7d20de/src/lib.rs#L228
+ // then we have the prevote message
+ // then the round number
+ // then the set id
+ const toSign1 = new Uint8Array([
+ ...hexToU8a("0x00"),
+ ...prevote1.toU8a(),
+ ...roundNumber.toU8a(),
+ ...setId.toU8a(),
+ ]);
+
+ const toSign2 = new Uint8Array([
+ ...hexToU8a("0x00"),
+ ...prevote2.toU8a(),
+ ...roundNumber.toU8a(),
+ ...setId.toU8a(),
+ ]);
+ const sig1 = pair.sign(toSign1);
+ const sig2 = pair.sign(toSign2);
+
+ const equivocationValue: GrandpaEquivocationValue = api.createType("GrandpaEquivocationValue", {
+ roundNumber,
+ identity: pair.address,
+ first: [prevote1, sig1],
+ second: [prevote2, sig2],
+ });
+
+ const equivocation: GrandpaEquivocation = api.createType("GrandpaEquivocation", {
+ Prevote: equivocationValue,
+ });
+
+ const doubleVotingProof: GrandpaEquivocationProof = api.createType("GrandpaEquivocationProof", {
+ setId,
+ equivocation,
+ });
+ return doubleVotingProof;
+}
diff --git a/typescript-api/src/dancelight/interfaces/augment-api-consts.ts b/typescript-api/src/dancelight/interfaces/augment-api-consts.ts
index dc0004bb4..411a2d61d 100644
--- a/typescript-api/src/dancelight/interfaces/augment-api-consts.ts
+++ b/typescript-api/src/dancelight/interfaces/augment-api-consts.ts
@@ -155,6 +155,19 @@ declare module "@polkadot/api-base/types/consts" {
/** Generic const */
[key: string]: Codec;
};
+ externalValidatorSlashes: {
+ /** Number of eras that staked funds must remain bonded for. */
+ bondingDuration: u32 & AugmentedConst;
+ /**
+ * 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.
+ */
+ slashDeferDuration: u32 & AugmentedConst;
+ /** Generic const */
+ [key: string]: Codec;
+ };
fellowshipReferenda: {
/**
* Quantization level for the referendum wakeup scheduler. A higher number will result in fewer storage
diff --git a/typescript-api/src/dancelight/interfaces/augment-api-errors.ts b/typescript-api/src/dancelight/interfaces/augment-api-errors.ts
index e41215365..9727ad457 100644
--- a/typescript-api/src/dancelight/interfaces/augment-api-errors.ts
+++ b/typescript-api/src/dancelight/interfaces/augment-api-errors.ts
@@ -243,6 +243,24 @@ declare module "@polkadot/api-base/types/errors" {
/** Generic error */
[key: string]: AugmentedError;
};
+ externalValidatorSlashes: {
+ /** The slash to be cancelled has already elapsed the DeferPeriod */
+ DeferPeriodIsOver: AugmentedError;
+ /** The era for which the slash wants to be cancelled has no slashes */
+ EmptyTargets: AugmentedError;
+ /** There was an error computing the slash */
+ ErrorComputingSlash: AugmentedError;
+ /** No slash was found to be cancelled at the given index */
+ InvalidSlashIndex: AugmentedError;
+ /** Slash indices to be cancelled are not sorted or unique */
+ NotSortedAndUnique: AugmentedError;
+ /** Provided an era in the future */
+ ProvidedFutureEra: AugmentedError;
+ /** Provided an era that is not slashable */
+ ProvidedNonSlashableEra: AugmentedError;
+ /** Generic error */
+ [key: string]: AugmentedError;
+ };
fellowshipCollective: {
/** Account is already a member. */
AlreadyMember: AugmentedError;
diff --git a/typescript-api/src/dancelight/interfaces/augment-api-events.ts b/typescript-api/src/dancelight/interfaces/augment-api-events.ts
index b96506f9e..bf0aa160b 100644
--- a/typescript-api/src/dancelight/interfaces/augment-api-events.ts
+++ b/typescript-api/src/dancelight/interfaces/augment-api-events.ts
@@ -8,7 +8,7 @@ import "@polkadot/api-base/types/events";
import type { ApiTypes, AugmentedEvent } from "@polkadot/api-base/types";
import type { Bytes, Null, Option, Result, U8aFixed, Vec, bool, u128, u16, u32, u64, u8 } from "@polkadot/types-codec";
import type { ITuple } from "@polkadot/types-codec/types";
-import type { AccountId32, H256 } from "@polkadot/types/interfaces/runtime";
+import type { AccountId32, H256, Perbill } from "@polkadot/types/interfaces/runtime";
import type {
DancelightRuntimeProxyType,
DancelightRuntimeRuntimeParametersKey,
@@ -228,6 +228,16 @@ declare module "@polkadot/api-base/types/events" {
/** Generic event */
[key: string]: AugmentedEvent;
};
+ externalValidatorSlashes: {
+ /** Removed author data */
+ SlashReported: AugmentedEvent<
+ ApiType,
+ [validator: AccountId32, fraction: Perbill, slashEra: u32],
+ { validator: AccountId32; fraction: Perbill; slashEra: u32 }
+ >;
+ /** Generic event */
+ [key: string]: AugmentedEvent;
+ };
fellowshipCollective: {
/** A member `who` has been added. */
MemberAdded: AugmentedEvent;
diff --git a/typescript-api/src/dancelight/interfaces/augment-api-query.ts b/typescript-api/src/dancelight/interfaces/augment-api-query.ts
index ad9e5579f..f10abf0a0 100644
--- a/typescript-api/src/dancelight/interfaces/augment-api-query.ts
+++ b/typescript-api/src/dancelight/interfaces/augment-api-query.ts
@@ -47,6 +47,7 @@ import type {
PalletConfigurationHostConfiguration,
PalletConvictionVotingVoteVoting,
PalletDataPreserversRegisteredProfile,
+ PalletExternalValidatorSlashesSlash,
PalletExternalValidatorsForcing,
PalletGrandpaStoredPendingChange,
PalletGrandpaStoredState,
@@ -711,6 +712,36 @@ declare module "@polkadot/api-base/types/storage" {
/** Generic query */
[key: string]: QueryableStorageEntry;
};
+ externalValidatorSlashes: {
+ /**
+ * A mapping from still-bonded eras to the first session index of that era.
+ *
+ * Must contains information for eras for the range: `[active_era - bounding_duration; active_era]`
+ */
+ bondedEras: AugmentedQuery Observable>>, []> &
+ QueryableStorageEntry;
+ /** A counter on the number of slashes we have performed */
+ nextSlashId: AugmentedQuery Observable, []> & QueryableStorageEntry;
+ /** All unapplied slashes that are queued for later. */
+ slashes: AugmentedQuery<
+ ApiType,
+ (arg: u32 | AnyNumber | Uint8Array) => Observable>,
+ [u32]
+ > &
+ QueryableStorageEntry;
+ /** All slashing events on validators, mapped by era to the highest slash proportion and slash value of the era. */
+ validatorSlashInEra: AugmentedQuery<
+ ApiType,
+ (
+ arg1: u32 | AnyNumber | Uint8Array,
+ arg2: AccountId32 | string | Uint8Array
+ ) => Observable