diff --git a/Cargo.lock b/Cargo.lock index 95bdf735f4f42..9746566206726 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1331,6 +1331,7 @@ dependencies = [ "frame-system-rpc-runtime-api", "frame-try-runtime", "hex-literal", + "log", "pallet-ah-ops", "pallet-asset-conversion", "pallet-asset-conversion-ops", @@ -1408,6 +1409,7 @@ dependencies = [ "sp-staking", "sp-std 14.0.0", "sp-storage 19.0.0", + "sp-tracing 16.0.0", "sp-transaction-pool", "sp-version", "staging-parachain-info", @@ -11223,6 +11225,7 @@ dependencies = [ "pallet-authorship", "pallet-balances", "pallet-election-provider-multi-block", + "pallet-offences", "pallet-root-offences", "pallet-session", "pallet-staking", @@ -13518,6 +13521,7 @@ dependencies = [ name = "pallet-staking-async-preset-store" version = "0.1.0" dependencies = [ + "pallet-staking-async-ah-client", "parity-scale-codec", "polkadot-sdk-frame", "scale-info", diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/Cargo.toml b/cumulus/parachains/runtimes/assets/asset-hub-westend/Cargo.toml index 7944e1ff40414..328d289b6ba1e 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/Cargo.toml +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] codec = { features = ["derive", "max-encoded-len"], workspace = true } hex-literal = { workspace = true, default-features = true } +log = { workspace = true } scale-info = { features = ["derive"], workspace = true } serde_json = { features = ["alloc"], workspace = true } tracing = { workspace = true } @@ -137,6 +138,7 @@ alloy-core = { workspace = true, features = ["sol-types"] } asset-test-utils = { workspace = true, default-features = true } pallet-revive-fixtures = { workspace = true, default-features = true } parachains-runtimes-test-utils = { workspace = true, default-features = true } +sp-tracing = { workspace = true, default-features = true } [build-dependencies] substrate-wasm-builder = { optional = true, workspace = true, default-features = true } @@ -301,6 +303,7 @@ std = [ "frame-system-rpc-runtime-api/std", "frame-system/std", "frame-try-runtime?/std", + "log/std", "pallet-ah-ops/std", "pallet-asset-conversion-ops/std", "pallet-asset-conversion-tx-payment/std", diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs index 631abecdc285a..1674e02f1c1cb 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs @@ -304,6 +304,7 @@ impl pallet_staking_async_rc_client::Config for Runtime { type RelayChainOrigin = EnsureRoot; type AHStakingInterface = Staking; type SendToRelayChain = StakingXcmToRelayChain; + type MaxValidatorSetRetries = ConstU32<64>; } #[derive(Encode, Decode)] @@ -350,13 +351,13 @@ pub struct StakingXcmToRelayChain; impl rc_client::SendToRelayChain for StakingXcmToRelayChain { type AccountId = AccountId; - fn validator_set(report: rc_client::ValidatorSetReport) { + fn validator_set(report: rc_client::ValidatorSetReport) -> Result<(), ()> { rc_client::XCMSender::< xcm_config::XcmRouter, RelayLocation, rc_client::ValidatorSetReport, ValidatorSetToXcm, - >::split_then_send(report, Some(8)); + >::send(report) } } diff --git a/polkadot/runtime/westend/src/lib.rs b/polkadot/runtime/westend/src/lib.rs index 36fb8d0b45313..1156f14502aa5 100644 --- a/polkadot/runtime/westend/src/lib.rs +++ b/polkadot/runtime/westend/src/lib.rs @@ -793,7 +793,7 @@ enum RcClientCalls { #[codec(index = 0)] RelaySessionReport(rc_client::SessionReport), #[codec(index = 1)] - RelayNewOffence(SessionIndex, Vec>), + RelayNewOffencePaged(Vec<(SessionIndex, rc_client::Offence)>), } pub struct AssetHubLocation; @@ -842,24 +842,12 @@ impl sp_runtime::traits::Convert, Xcm<()>> } } -pub struct StakingXcmToAssetHub; -impl ah_client::SendToAssetHub for StakingXcmToAssetHub { - type AccountId = AccountId; - - fn relay_session_report(session_report: rc_client::SessionReport) { - rc_client::XCMSender::< - xcm_config::XcmRouter, - AssetHubLocation, - rc_client::SessionReport, - SessionReportToXcm, - >::split_then_send(session_report, Some(8)); - } - - fn relay_new_offence( - session_index: SessionIndex, - offences: Vec>, - ) { - let message = Xcm(vec![ +pub struct QueuedOffenceToXcm; +impl sp_runtime::traits::Convert>, Xcm<()>> + for QueuedOffenceToXcm +{ + fn convert(offences: Vec>) -> Xcm<()> { + Xcm(vec![ Instruction::UnpaidExecution { weight_limit: WeightLimit::Unlimited, check_origin: None, @@ -867,17 +855,40 @@ impl ah_client::SendToAssetHub for StakingXcmToAssetHub { Instruction::Transact { origin_kind: OriginKind::Superuser, fallback_max_weight: None, - call: AssetHubRuntimePallets::RcClient(RcClientCalls::RelayNewOffence( - session_index, + call: AssetHubRuntimePallets::RcClient(RcClientCalls::RelayNewOffencePaged( offences, )) .encode() .into(), }, - ]); - if let Err(err) = send_xcm::(AssetHubLocation::get(), message) { - log::error!(target: "runtime::ah-client", "Failed to send relay offence message: {:?}", err); - } + ]) + } +} + +pub struct StakingXcmToAssetHub; +impl ah_client::SendToAssetHub for StakingXcmToAssetHub { + type AccountId = AccountId; + + fn relay_session_report( + session_report: rc_client::SessionReport, + ) -> Result<(), ()> { + rc_client::XCMSender::< + xcm_config::XcmRouter, + AssetHubLocation, + rc_client::SessionReport, + SessionReportToXcm, + >::send(session_report) + } + + fn relay_new_offence_paged( + offences: Vec>, + ) -> Result<(), ()> { + rc_client::XCMSender::< + xcm_config::XcmRouter, + AssetHubLocation, + Vec>, + QueuedOffenceToXcm, + >::send(offences) } } @@ -893,7 +904,8 @@ impl ah_client::Config for Runtime { type PointsPerBlock = ConstU32<20>; type MaxOffenceBatchSize = ConstU32<50>; type Fallback = Staking; - type WeightInfo = ah_client::weights::SubstrateWeight; + type MaximumValidatorsWithPoints = ConstU32<{ MaxActiveValidators::get() * 4 }>; + type MaxSessionReportRetries = ConstU32<5>; } impl pallet_fast_unstake::Config for Runtime { @@ -2143,7 +2155,6 @@ mod benches { [pallet_scheduler, Scheduler] [pallet_session, SessionBench::] [pallet_staking, Staking] - [pallet_staking_async_ah_client, StakingAhClient] [pallet_sudo, Sudo] [frame_system, SystemBench::] [frame_system_extensions, SystemExtensionsBench::] diff --git a/polkadot/runtime/westend/src/weights/pallet_staking_async_ah_client.rs b/polkadot/runtime/westend/src/weights/pallet_staking_async_ah_client.rs deleted file mode 100644 index a3280907a8f7d..0000000000000 --- a/polkadot/runtime/westend/src/weights/pallet_staking_async_ah_client.rs +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (C) Parity Technologies (UK) Ltd. -// This file is part of Polkadot. - -// Polkadot 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. - -// Polkadot 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 Polkadot. If not, see . - -//! Autogenerated weights for `pallet_staking_async_ah_client` -//! -//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 32.0.0 -//! DATE: 2025-07-02, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` -//! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `3a1783fee2d7`, CPU: `Intel(R) Xeon(R) CPU @ 2.60GHz` -//! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: 1024 - -// Executed Command: -// frame-omni-bencher -// v1 -// benchmark -// pallet -// --extrinsic=* -// --runtime=target/production/wbuild/westend-runtime/westend_runtime.wasm -// --pallet=pallet_staking_async_ah_client -// --header=/__w/polkadot-sdk/polkadot-sdk/polkadot/file_header.txt -// --output=./polkadot/runtime/westend/src/weights -// --wasm-execution=compiled -// --steps=50 -// --repeat=20 -// --heap-pages=4096 -// --no-storage-info -// --no-min-squares -// --no-median-slopes - -#![cfg_attr(rustfmt, rustfmt_skip)] -#![allow(unused_parens)] -#![allow(unused_imports)] -#![allow(missing_docs)] - -use frame_support::{traits::Get, weights::Weight}; -use core::marker::PhantomData; - -/// Weight functions for `pallet_staking_async_ah_client`. -pub struct WeightInfo(PhantomData); -impl pallet_staking_async_ah_client::WeightInfo for WeightInfo { - /// Storage: `StakingAhClient::BufferedOffences` (r:1 w:1) - /// Proof: `StakingAhClient::BufferedOffences` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Dmp::DeliveryFeeFactor` (r:1 w:0) - /// Proof: `Dmp::DeliveryFeeFactor` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `XcmPallet::SupportedVersion` (r:1 w:0) - /// Proof: `XcmPallet::SupportedVersion` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `Dmp::DownwardMessageQueues` (r:1 w:0) - /// Proof: `Dmp::DownwardMessageQueues` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `Paras::Heads` (r:1 w:0) - /// Proof: `Paras::Heads` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// The range of component `n` is `[1, 50]`. - fn process_buffered_offences(n: u32, ) -> Weight { - // Proof Size summary in bytes: - // Measured: `337 + n * (69 ±0)` - // Estimated: `3802 + n * (69 ±0)` - // Minimum execution time: 41_361_000 picoseconds. - Weight::from_parts(44_480_889, 0) - .saturating_add(Weight::from_parts(0, 3802)) - // Standard Error: 5_478 - .saturating_add(Weight::from_parts(636_743, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(5)) - .saturating_add(T::DbWeight::get().writes(1)) - .saturating_add(Weight::from_parts(0, 69).saturating_mul(n.into())) - } -} diff --git a/prdoc/pr_9619.prdoc b/prdoc/pr_9619.prdoc new file mode 100644 index 0000000000000..19953f1057764 --- /dev/null +++ b/prdoc/pr_9619.prdoc @@ -0,0 +1,45 @@ +title: '[AHM/Staking/VMP] Paginated Offence Reports + Retries for Validator Set' +doc: +- audience: Runtime Dev + description: |- + This PR makes the following changes: + + #### Common + + * `SendToRelayChain` and `SendToAssetHub` traits now return a result, allowing the caller to know if the underlying XCM was sent or not. + + #### Offences + + * `SendToAssetHub::relay_new_offence` is removed. Instead, we use the new `relay_new_offence_paged` which is a vector of self-contained offences, not requiring us to group offences per session in each message. + * Offences are not sent immediately anymore. + * Instead, they are stored in a paginated `OffenceSendQueue`. + * `on-init`, we grab one page of this storage map, and sent it. + + #### Session Report + * Session reports now also have a retry mechanism. + * Upon each failure, we emit an `UnexpectedEvent` + * If our retries run out and we still can't send the session report, we will emit a different `UnexpectedEvent`. We also retore the validator points that we meant to send, and merge them back, so that they are sent in the next session report. + + #### Validator Set + * Similar to offences, they are not sent immediately anymore. + * Instead, they are stored in a storage item, and are sent on subsequent on-inits. + * A maximum retry count is added. +crates: +- name: pallet-offences + bump: patch +- name: pallet-root-offences + bump: major +- name: pallet-staking-async-ah-client + bump: major +- name: pallet-staking-async + bump: patch +- name: westend-runtime + bump: minor +- name: pallet-staking-async-rc-client + bump: major +- name: pallet-staking-async-rc-runtime-constants + bump: patch +- name: asset-hub-westend-runtime + bump: minor +- name: pallet-election-provider-multi-block + bump: patch diff --git a/substrate/frame/election-provider-multi-block/src/types.rs b/substrate/frame/election-provider-multi-block/src/types.rs index 4b30dba9e6aad..41dffb49c7c4d 100644 --- a/substrate/frame/election-provider-multi-block/src/types.rs +++ b/substrate/frame/election-provider-multi-block/src/types.rs @@ -303,7 +303,6 @@ impl Phase { fn are_we_done() -> Self { let query = T::AreWeDone::get(); - log!(debug, "Are we done? {:?}", query); query } diff --git a/substrate/frame/root-offences/src/lib.rs b/substrate/frame/root-offences/src/lib.rs index c0a6187e837f1..7466414757576 100644 --- a/substrate/frame/root-offences/src/lib.rs +++ b/substrate/frame/root-offences/src/lib.rs @@ -28,19 +28,58 @@ mod mock; mod tests; extern crate alloc; - -use alloc::vec::Vec; +use alloc::{vec, vec::Vec}; pub use pallet::*; use pallet_session::historical::IdentificationTuple; use sp_runtime::{traits::Convert, Perbill}; -use sp_staking::offence::OnOffenceHandler; +use sp_staking::offence::{Kind, Offence, OnOffenceHandler}; #[frame_support::pallet] pub mod pallet { use super::*; use frame_support::pallet_prelude::*; use frame_system::pallet_prelude::*; - use sp_staking::SessionIndex; + use sp_staking::{offence::ReportOffence, SessionIndex}; + + /// Custom offence type for testing spam scenarios. + /// + /// This allows creating offences with arbitrary kinds and time slots. + #[derive(Clone, Debug, Encode, Decode, TypeInfo)] + pub struct TestSpamOffence { + /// The validator being slashed + pub offender: Offender, + /// The session in which the offence occurred + pub session_index: SessionIndex, + /// Custom time slot (allows unique offences within same session) + pub time_slot: u128, + /// Slash fraction to apply + pub slash_fraction: Perbill, + } + + impl Offence for TestSpamOffence { + const ID: Kind = *b"spamspamspamspam"; + type TimeSlot = u128; + + fn offenders(&self) -> Vec { + vec![self.offender.clone()] + } + + fn session_index(&self) -> SessionIndex { + self.session_index + } + + fn time_slot(&self) -> Self::TimeSlot { + self.time_slot + } + + fn slash_fraction(&self, _offenders_count: u32) -> Perbill { + self.slash_fraction + } + + fn validator_set_count(&self) -> u32 { + unreachable!() + } + } #[pallet::config] pub trait Config: @@ -51,8 +90,20 @@ pub mod pallet { { #[allow(deprecated)] type RuntimeEvent: From> + IsType<::RuntimeEvent>; + /// The offence handler provided by the runtime. + /// + /// This is a way to give the offence directly to the handling system (staking, ah-client). type OffenceHandler: OnOffenceHandler, Weight>; + + /// The offence report system provided by the runtime. + /// + /// This is a way to give the offence to the `pallet-offences` next. + type ReportOffence: ReportOffence< + Self::AccountId, + IdentificationTuple, + TestSpamOffence>, + >; } #[pallet::pallet] @@ -116,6 +167,37 @@ pub mod pallet { Self::deposit_event(Event::OffenceCreated { offenders }); Ok(()) } + + /// Same as [`Pallet::create_offence`], but it reports the offence directly to a + /// [`Config::ReportOffence`], aka pallet-offences first. + /// + /// This is useful for more accurate testing of the e2e offence processing pipeline, as it + /// won't skip the `pallet-offences` step. + /// + /// It generates an offence of type [`TestSpamOffence`], with cas a fixed `ID`, but can have + /// any `time_slot`, `session_index``, and `slash_fraction`. These values are the inputs of + /// transaction, int the same order, with an `IdentiticationTuple` coming first. + #[pallet::call_index(1)] + #[pallet::weight(T::DbWeight::get().reads(2))] + pub fn report_offence( + origin: OriginFor, + offences: Vec<(IdentificationTuple, SessionIndex, u128, u32)>, + ) -> DispatchResult { + ensure_root(origin)?; + + for (offender, session_index, time_slot, slash_ppm) in offences { + let slash_fraction = Perbill::from_parts(slash_ppm); + Self::deposit_event(Event::OffenceCreated { + offenders: vec![(offender.0.clone(), slash_fraction)], + }); + let offence = + TestSpamOffence { offender, session_index, time_slot, slash_fraction }; + + T::ReportOffence::report_offence(Default::default(), offence).unwrap(); + } + + Ok(()) + } } impl Pallet { diff --git a/substrate/frame/root-offences/src/mock.rs b/substrate/frame/root-offences/src/mock.rs index 1a3627fca7b38..312c157f8e916 100644 --- a/substrate/frame/root-offences/src/mock.rs +++ b/substrate/frame/root-offences/src/mock.rs @@ -182,6 +182,7 @@ impl pallet_timestamp::Config for Test { impl Config for Test { type RuntimeEvent = RuntimeEvent; type OffenceHandler = Staking; + type ReportOffence = (); } pub struct ExtBuilder { diff --git a/substrate/frame/staking-async/ah-client/src/benchmarking.rs b/substrate/frame/staking-async/ah-client/src/benchmarking.rs deleted file mode 100644 index 0f48794f043c1..0000000000000 --- a/substrate/frame/staking-async/ah-client/src/benchmarking.rs +++ /dev/null @@ -1,101 +0,0 @@ -// This file is part of Substrate. - -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Benchmarking setup for pallet-staking-async-ah-client - -use super::*; -use frame_benchmarking::v2::*; -use frame_support::traits::Get; - -use sp_staking::SessionIndex; - -const SEED: u32 = 0; - -fn create_offenders(n: u32) -> Vec { - (0..n).map(|i| frame_benchmarking::account("offender", i, SEED)).collect() -} - -fn create_buffered_offences( - _session: SessionIndex, - offenders: &[T::AccountId], -) -> BTreeMap> { - offenders - .iter() - .enumerate() - .map(|(i, offender)| { - let slash_fraction = sp_runtime::Perbill::from_percent(10 + (i % 90) as u32); - (offender.clone(), BufferedOffence { reporter: Some(offender.clone()), slash_fraction }) - }) - .collect() -} - -fn setup_buffered_offences(n: u32) -> SessionIndex { - // Set the pallet to Buffered mode - Mode::::put(OperatingMode::Buffered); - - // Create offenders - let offenders = create_offenders::(n); - - // Use a specific session for testing - let session: SessionIndex = 42; - - // Create buffered offences - let offences_map = create_buffered_offences::(session, &offenders); - - // Store the buffered offences - BufferedOffences::::mutate(|buffered| { - buffered.insert(session, offences_map); - }); - - session -} - -#[benchmarks] -mod benchmarks { - use super::*; - - #[benchmark] - fn process_buffered_offences(n: Linear<1, { T::MaxOffenceBatchSize::get() }>) { - // Setup: Create buffered offences and put pallet in Active mode - let session = setup_buffered_offences::(n); - - // Transition to Active mode to trigger processing - Mode::::put(OperatingMode::Active); - - // Verify offences exist before processing - assert!(BufferedOffences::::get().contains_key(&session)); - - #[block] - { - Pallet::::process_buffered_offences(); - } - - // Verify some offences were processed - // In a real scenario, either the session is gone or has fewer offences - let remaining_offences = - BufferedOffences::::get().get(&session).map(|m| m.len()).unwrap_or(0); - let expected_remaining = if n > T::MaxOffenceBatchSize::get() { - (n - T::MaxOffenceBatchSize::get()) as usize - } else { - 0 - }; - assert_eq!(remaining_offences, expected_remaining); - } - - #[cfg(test)] - impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test); -} diff --git a/substrate/frame/staking-async/ah-client/src/lib.rs b/substrate/frame/staking-async/ah-client/src/lib.rs index 8448d4d0ae3ca..b1c0e9d46894f 100644 --- a/substrate/frame/staking-async/ah-client/src/lib.rs +++ b/substrate/frame/staking-async/ah-client/src/lib.rs @@ -34,8 +34,9 @@ //! //! ## Outgoing Messages //! -//! All outgoing messages are handled by a single trait [`SendToAssetHub`]. They match the -//! incoming messages of the `ah-client` pallet. +//! All outgoing messages are handled by a single trait +//! [`pallet_staking_async_rc_client::SendToAssetHub`]. They match the incoming messages of the +//! `rc-client` pallet. //! //! ## Local Interfaces: //! @@ -58,16 +59,15 @@ pub use pallet::*; #[cfg(test)] pub mod mock; -#[cfg(feature = "runtime-benchmarks")] -pub mod benchmarking; -pub mod weights; - -pub use weights::WeightInfo; - extern crate alloc; -use alloc::{collections::BTreeMap, vec::Vec}; -use frame_support::{pallet_prelude::*, traits::RewardsReporter}; +use alloc::vec::Vec; +use frame_support::{ + pallet_prelude::*, + traits::{Defensive, DefensiveSaturating, RewardsReporter}, +}; +pub use pallet_staking_async_rc_client::SendToAssetHub; use pallet_staking_async_rc_client::{self as rc_client}; +use sp_runtime::SaturatedConversion; use sp_staking::{ offence::{OffenceDetails, OffenceSeverity}, SessionIndex, @@ -98,44 +98,6 @@ macro_rules! log { }; } -/// The interface to communicate to asset hub. -/// -/// This trait should only encapsulate our outgoing communications. Any incoming message is handled -/// with `Call`s. -/// -/// In a real runtime, this is implemented via XCM calls, much like how the coretime pallet works. -/// In a test runtime, it can be wired to direct function call. -pub trait SendToAssetHub { - /// The validator account ids. - type AccountId; - - /// Report a session change to AssetHub. - fn relay_session_report(session_report: rc_client::SessionReport); - - /// Report new offences. - fn relay_new_offence( - session_index: SessionIndex, - offences: Vec>, - ); -} - -/// A no-op implementation of [`SendToAssetHub`]. -#[cfg(feature = "std")] -impl SendToAssetHub for () { - type AccountId = u64; - - fn relay_session_report(_session_report: rc_client::SessionReport) { - panic!("relay_session_report not implemented"); - } - - fn relay_new_offence( - _session_index: SessionIndex, - _offences: Vec>, - ) { - panic!("relay_new_offence not implemented"); - } -} - /// Interface to talk to the local session pallet. pub trait SessionInterface { /// The validator id type of the session pallet @@ -238,23 +200,6 @@ impl } } -#[derive(Encode, Decode, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo, MaxEncodedLen)] -pub struct BufferedOffence { - // rc_client::Offence takes multiple reporters, but in practice there is only one. In this - // pallet, we assume this is the case and store only the first reporter or none if empty. - pub reporter: Option, - pub slash_fraction: sp_runtime::Perbill, -} - -/// A map of buffered offences, keyed by session index and then by offender account id. -pub type BufferedOffencesMap = BTreeMap< - SessionIndex, - BTreeMap< - ::AccountId, - BufferedOffence<::AccountId>, - >, ->; - #[frame_support::pallet] pub mod pallet { use crate::*; @@ -262,6 +207,7 @@ pub mod pallet { use frame_support::traits::{Hooks, UnixTime}; use frame_system::pallet_prelude::*; use pallet_session::{historical, SessionManager}; + use pallet_staking_async_rc_client::SessionReport; use sp_runtime::{Perbill, Saturating}; use sp_staking::{ offence::{OffenceSeverity, OnOffenceHandler}, @@ -299,24 +245,33 @@ pub mod pallet { /// A safety measure that asserts an incoming validator set must be at least this large. type MinimumValidatorSetSize: Get; + /// A safety measure that asserts when iterating over validator points (to be sent to AH), + /// we don't iterate too many times. + /// + /// Validator may change session to session, and if session reports are not sent, validator + /// points that we store may well grow beyond the size of the validator set. Yet, a too + /// large of an upper bound may also exceed the maximum size of a single DMP message. + /// Consult the test `message_queue_sizes` for more information. + /// + /// Note that in case a single session report is larger than a single DMP message, it might + /// still be sent over if we use + /// [`pallet_staking_async_rc_client::XCMSender::split_then_send`]. This will make the size + /// of each individual message smaller, yet, it will still try and push them all to the + /// queue at the same time. + type MaximumValidatorsWithPoints: Get; + /// A type that gives us a reliable unix timestamp. type UnixTime: UnixTime; /// Number of points to award a validator per block authored. type PointsPerBlock: Get; - /// Maximum number of offences to batch in a single message to AssetHub. + /// Maximum number of offences to batch in a single message to AssetHub. Actual sending + /// happens `on_initialize`. Offences get infinite "retries", and are never dropped. /// - /// Used during `Active` mode to limit batch size when processing buffered offences - /// in `on_initialize`. During `Buffered` mode, offences are accumulated without batching. - /// When transitioning from `Buffered` to `Active` mode (via `on_migration_end`), - /// buffered offences remain stored and are processed gradually by `on_initialize` - /// using this batch size limit to prevent block overload. - /// - /// **Performance characteristics** - /// - Base cost: ~30.9ms (XCM infrastructure overhead) - /// - Per-offence cost: ~0.073ms (linear scaling) - /// - At batch size 50: ~34.6ms total (~1.7% of 2-second compute allowance) + /// A sensible value should be such that sending this batch is small enough to not exhaust + /// the DMP queue. The size of a single offence is documented in `message_queue_sizes` test + /// (74 bytes). type MaxOffenceBatchSize: Get; /// Interface to talk to the local Session pallet. @@ -336,8 +291,9 @@ pub mod pallet { > + frame_support::traits::RewardsReporter + pallet_authorship::EventHandler>; - /// Information on runtime weights. - type WeightInfo: WeightInfo; + /// Maximum number of times we try to send a session report to AssetHub, after which, if + /// sending still fails, we drop it. + type MaxSessionReportRetries: Get; } #[pallet::pallet] @@ -390,19 +346,102 @@ pub mod pallet { #[pallet::storage] pub type ValidatorSetAppliedAt = StorageValue<_, SessionIndex, OptionQuery>; - /// Offences collected while in [`OperatingMode::Buffered`] mode. + /// A session report that is outgoing, and should be sent. /// - /// These are temporarily stored and sent once the pallet switches to [`OperatingMode::Active`]. - /// For each offender, only the highest `slash_fraction` is kept. + /// This will be attempted to be sent, possibly on every `on_initialize` call, until it is sent, + /// or the second value reaches zero, at which point we drop it. + #[pallet::storage] + #[pallet::unbounded] + pub type OutgoingSessionReport = + StorageValue<_, (SessionReport, u32), OptionQuery>; + + /// Wrapper struct for storing offences, and getting them back page by page. /// - /// Internally stores as a nested BTreeMap: - /// `session_index -> (offender -> (reporter, slash_fraction))`. + /// It has only two interfaces: /// - /// Note: While the [`rc_client::Offence`] type includes a list of reporters, in practice there - /// is only one. In this pallet, we assume this is the case and store only the first reporter. + /// * [`OffenceSendQueue::append`], to add a single offence. + /// * [`OffenceSendQueue::get_and_maybe_delete`] which retrieves the last page. Depending on the + /// closure, it may also delete that page. The returned value is indeed + /// [`Config::MaxOffenceBatchSize`] or less items. + /// + /// Internally, it manages `OffenceSendQueueOffences` and `OffenceSendQueueCursor`, both of + /// which should NEVER be used manually. + pub struct OffenceSendQueue(core::marker::PhantomData); + + /// A single buffered offence in [`OffenceSendQueue`]. + pub type QueuedOffenceOf = + (SessionIndex, rc_client::Offence<::AccountId>); + /// A page of buffered offences in [`OffenceSendQueue`]. + pub type QueuedOffencePageOf = + BoundedVec, ::MaxOffenceBatchSize>; + + impl OffenceSendQueue { + /// Add a single offence to the queue. + pub fn append(o: QueuedOffenceOf) { + let mut index = OffenceSendQueueCursor::::get(); + match OffenceSendQueueOffences::::try_mutate(index, |b| b.try_push(o.clone())) { + Ok(_) => { + // `index` had empty slot -- all good. + }, + Err(_) => { + debug_assert!( + !OffenceSendQueueOffences::::contains_key(index + 1), + "next page should be empty" + ); + index += 1; + OffenceSendQueueOffences::::insert( + index, + BoundedVec::<_, _>::try_from(vec![o]).defensive_unwrap_or_default(), + ); + OffenceSendQueueCursor::::mutate(|i| *i += 1); + }, + } + } + + // Get the last page of offences, and delete it if `op` returns `Ok(())`. + pub fn get_and_maybe_delete(op: impl FnOnce(QueuedOffencePageOf) -> Result<(), ()>) { + let index = OffenceSendQueueCursor::::get(); + let page = OffenceSendQueueOffences::::get(index); + let res = op(page); + match res { + Ok(_) => { + OffenceSendQueueOffences::::remove(index); + OffenceSendQueueCursor::::mutate(|i| *i = i.saturating_sub(1)) + }, + Err(_) => { + // nada + }, + } + } + + #[cfg(feature = "std")] + pub fn pages() -> u32 { + let last_page = if Self::last_page_empty() { 0 } else { 1 }; + OffenceSendQueueCursor::::get().saturating_add(last_page) + } + + #[cfg(feature = "std")] + pub fn count() -> u32 { + let last_index = OffenceSendQueueCursor::::get(); + let last_page = OffenceSendQueueOffences::::get(last_index); + let last_page_count = last_page.len() as u32; + last_index.saturating_mul(T::MaxOffenceBatchSize::get()) + last_page_count + } + + #[cfg(feature = "std")] + fn last_page_empty() -> bool { + OffenceSendQueueOffences::::get(OffenceSendQueueCursor::::get()).is_empty() + } + } + + /// Internal storage item of [`OffenceSendQueue`]. Should not be used manually. #[pallet::storage] #[pallet::unbounded] - pub type BufferedOffences = StorageValue<_, BufferedOffencesMap, ValueQuery>; + pub(crate) type OffenceSendQueueOffences = + StorageMap<_, Twox64Concat, u32, QueuedOffencePageOf, ValueQuery>; + /// Internal storage item of [`OffenceSendQueue`]. Should not be used manually. + #[pallet::storage] + pub(crate) type OffenceSendQueueCursor = StorageValue<_, u32, ValueQuery>; #[pallet::genesis_config] #[derive(frame_support::DefaultNoBound, frame_support::DebugNoBound)] @@ -463,6 +502,30 @@ pub mod pallet { /// /// Expected transitions are linear and forward-only: `Passive` → `Buffered` → `Active`. UnexpectedModeTransition, + + /// A session report failed to be sent. + /// + /// We will store, and retry it for a number of more block. + SessionReportSendFailed, + + /// A session report failed enough times that we should drop it. + /// + /// We will retain the validator points, and send them over in the next session we receive + /// from pallet-session. + SessionReportDropped, + + /// An offence report failed to be sent. + /// + /// It will be retried again in the next block. We never drop them. + OffenceSendFailed, + + /// Some validator points didn't make it to be included in the session report. Should + /// never happen, and means: + /// + /// * a too low of a value is assigned to [`Config::MaximumValidatorsWithPoints`] + /// * Those who are calling into our `RewardsReporter` likely have a bad view of the + /// validator set, and are spamming us. + ValidatorPointDropped, } #[pallet::call] @@ -583,18 +646,55 @@ pub mod pallet { return weight; } - // Check if we have any buffered offences to send - let buffered_offences = BufferedOffences::::get(); - weight = weight.saturating_add(T::DbWeight::get().reads(1)); - if buffered_offences.is_empty() { - return weight; + // if we have any pending session reports, send it. + weight.saturating_accrue(T::DbWeight::get().reads(1)); + if let Some((session_report, retries_left)) = OutgoingSessionReport::::take() { + match T::SendToAssetHub::relay_session_report(session_report.clone()) { + Ok(()) => { + // report was sent, all good, it is already deleted. + }, + Err(()) => { + log!(error, "Failed to send session report to assethub"); + Self::deposit_event(Event::::Unexpected( + UnexpectedKind::SessionReportSendFailed, + )); + if let Some(new_retries_left) = retries_left.checked_sub(One::one()) { + OutgoingSessionReport::::put((session_report, new_retries_left)) + } else { + // recreate the validator points, so they will be sent in the next + // report. + session_report.validator_points.into_iter().for_each(|(v, p)| { + ValidatorPoints::::mutate(v, |existing_points| { + *existing_points = existing_points.defensive_saturating_add(p) + }); + }); + + Self::deposit_event(Event::::Unexpected( + UnexpectedKind::SessionReportDropped, + )); + } + }, + } } - let processing_weight = Self::process_buffered_offences(); - weight = weight.saturating_add(processing_weight); + // then, take a page from our send queue, and if present, send it. + weight.saturating_accrue(T::DbWeight::get().reads(2)); + OffenceSendQueue::::get_and_maybe_delete(|page| { + if page.is_empty() { + return Ok(()) + } + // send the page if not empty. If sending returns `Ok`, we delete this page. + T::SendToAssetHub::relay_new_offence_paged(page.into_inner()).inspect_err(|_| { + Self::deposit_event(Event::Unexpected(UnexpectedKind::OffenceSendFailed)); + }) + }); weight } + + fn integrity_test() { + assert!(T::MaxOffenceBatchSize::get() > 0, "Offence Batch size must be at least 1"); + } } impl @@ -777,25 +877,35 @@ pub mod pallet { }) } - fn do_end_session(session_index: u32) { - use sp_runtime::SaturatedConversion; + fn do_end_session(end_index: u32) { + // take and delete all validator points, limited by `MaximumValidatorsWithPoints`. + let validator_points = ValidatorPoints::::iter() + .drain() + .take(T::MaximumValidatorsWithPoints::get() as usize) + .collect::>(); + + // If there were more validators than `MaximumValidatorsWithPoints`.. + if ValidatorPoints::::iter().next().is_some() { + // ..not much more we can do about it other than an event. + Self::deposit_event(Event::::Unexpected(UnexpectedKind::ValidatorPointDropped)) + } - let validator_points = ValidatorPoints::::iter().drain().collect::>(); let activation_timestamp = NextSessionChangesValidators::::take().map(|id| { // keep track of starting session index at which the validator set was applied. - ValidatorSetAppliedAt::::put(session_index + 1); + ValidatorSetAppliedAt::::put(end_index + 1); // set the timestamp and the identifier of the validator set. (T::UnixTime::now().as_millis().saturated_into::(), id) }); let session_report = pallet_staking_async_rc_client::SessionReport { - end_index: session_index, + end_index, validator_points, activation_timestamp, leftover: false, }; - T::SendToAssetHub::relay_session_report(session_report); + // queue the session report to be sent. + OutgoingSessionReport::::put((session_report, T::MaxSessionReportRetries::get())); } fn do_reward_by_ids(rewards: impl IntoIterator) { @@ -812,61 +922,6 @@ pub mod pallet { }); } - /// Process buffered offences and send them to AssetHub in batches. - pub(crate) fn process_buffered_offences() -> Weight { - let max_batch_size = T::MaxOffenceBatchSize::get() as usize; - - // Process and remove offences one session at a time - let offences_sent = BufferedOffences::::mutate(|buffered| { - let first_session_key = buffered.keys().next().copied()?; - - let session_map = buffered.get_mut(&first_session_key)?; - - // Take up to max_batch_size offences from this session - let keys_to_drain: Vec<_> = - session_map.keys().take(max_batch_size).cloned().collect(); - - let offences_to_send: Vec<_> = keys_to_drain - .into_iter() - .filter_map(|key| { - session_map.remove(&key).map(|offence| rc_client::Offence { - offender: key, - reporters: offence.reporter.into_iter().collect(), - slash_fraction: offence.slash_fraction, - }) - }) - .collect(); - - if !offences_to_send.is_empty() { - // Remove the entire session if it's now empty - if session_map.is_empty() { - buffered.remove(&first_session_key); - log!(debug, "Cleared all offences for session {}", first_session_key); - } - - Some((first_session_key, offences_to_send)) - } else { - None - } - }); - - if let Some((slash_session, offences_to_send)) = offences_sent { - log!( - info, - "Sending {} buffered offences for session {} to AssetHub", - offences_to_send.len(), - slash_session - ); - - let batch_size = offences_to_send.len(); - T::SendToAssetHub::relay_new_offence(slash_session, offences_to_send); - - T::WeightInfo::process_buffered_offences(batch_size as u32) - } else { - Weight::zero() - } - } - /// Check if an offence is from the active validator set. fn is_ongoing_offence(slash_session: SessionIndex) -> bool { ValidatorSetAppliedAt::::get() @@ -882,48 +937,30 @@ pub mod pallet { ) -> Weight { let ongoing_offence = Self::is_ongoing_offence(slash_session); - let _: Vec<_> = offenders - .iter() - .cloned() - .zip(slash_fraction) - .map(|(offence, fraction)| { - if ongoing_offence { - // report the offence to the session pallet. - T::SessionInterface::report_offence( - offence.offender.0.clone(), - OffenceSeverity(*fraction), - ); - } - - let (offender, _full_identification) = offence.offender; - let reporters = offence.reporters; - - // In `Buffered` mode, we buffer the offences for later processing. - // We only keep the highest slash fraction for each offender per session. - BufferedOffences::::mutate(|buffered| { - let session_offences = buffered.entry(slash_session).or_default(); - let entry = session_offences.entry(offender); - - entry - .and_modify(|existing| { - if existing.slash_fraction < *fraction { - *existing = BufferedOffence { - reporter: reporters.first().cloned(), - slash_fraction: *fraction, - }; - } - }) - .or_insert(BufferedOffence { - reporter: reporters.first().cloned(), - slash_fraction: *fraction, - }); - }); + offenders.iter().cloned().zip(slash_fraction).for_each(|(offence, fraction)| { + if ongoing_offence { + // report the offence to the session pallet. + T::SessionInterface::report_offence( + offence.offender.0.clone(), + OffenceSeverity(*fraction), + ); + } - // Return unit for the map operation - }) - .collect(); + let (offender, _full_identification) = offence.offender; + let reporters = offence.reporters; + + // In `Buffered` mode, we buffer the offences for later processing. + OffenceSendQueue::::append(( + slash_session, + rc_client::Offence { + offender: offender.clone(), + reporters: reporters.into_iter().take(1).collect(), + slash_fraction: *fraction, + }, + )); + }); - Weight::zero() + T::DbWeight::get().reads_writes(1, 1) } /// Handle offences in Active mode. @@ -934,35 +971,167 @@ pub mod pallet { ) -> Weight { let ongoing_offence = Self::is_ongoing_offence(slash_session); - let offenders_and_slashes_message: Vec<_> = offenders - .iter() - .cloned() - .zip(slash_fraction) - .map(|(offence, fraction)| { - if ongoing_offence { - // report the offence to the session pallet. - T::SessionInterface::report_offence( - offence.offender.0.clone(), - OffenceSeverity(*fraction), - ); - } - - let (offender, _full_identification) = offence.offender; - let reporters = offence.reporters; - - // prepare an `Offence` instance for the XCM message. Note that we drop - // the identification. - rc_client::Offence { offender, reporters, slash_fraction: *fraction } - }) - .collect(); + offenders.iter().cloned().zip(slash_fraction).for_each(|(offence, fraction)| { + if ongoing_offence { + // report the offence to the session pallet. + T::SessionInterface::report_offence( + offence.offender.0.clone(), + OffenceSeverity(*fraction), + ); + } - // Send offence report to Asset Hub - if !offenders_and_slashes_message.is_empty() { - log!(info, "sending offence report to AH"); - T::SendToAssetHub::relay_new_offence(slash_session, offenders_and_slashes_message); - } + let (offender, _full_identification) = offence.offender; + let reporters = offence.reporters; + + // prepare an `Offence` instance for the XCM message. Note that we drop + // the identification. + let offence = rc_client::Offence { + offender, + reporters: reporters.into_iter().take(1).collect(), + slash_fraction: *fraction, + }; + OffenceSendQueue::::append((slash_session, offence)) + }); - Weight::zero() + T::DbWeight::get().reads_writes(2, 2) } } } + +#[cfg(test)] +mod send_queue_tests { + use frame_support::hypothetically; + use sp_runtime::Perbill; + + use super::*; + use crate::mock::*; + + // (cursor, len_of_pages) + fn status() -> (u32, Vec) { + let mut sorted = OffenceSendQueueOffences::::iter().collect::>(); + sorted.sort_by(|x, y| x.0.cmp(&y.0)); + ( + OffenceSendQueueCursor::::get(), + sorted.into_iter().map(|(_, v)| v.len() as u32).collect(), + ) + } + + #[test] + fn append_and_take() { + new_test_ext().execute_with(|| { + let o = ( + 42, + rc_client::Offence { + offender: 42, + reporters: vec![], + slash_fraction: Perbill::from_percent(10), + }, + ); + let page_size = ::MaxOffenceBatchSize::get(); + assert_eq!(page_size % 2, 0, "page size should be even"); + + assert_eq!(status(), (0, vec![])); + + // --- when empty + + assert_eq!(OffenceSendQueue::::count(), 0); + assert_eq!(OffenceSendQueue::::pages(), 0); + + // get and keep + hypothetically!({ + OffenceSendQueue::::get_and_maybe_delete(|page| { + assert_eq!(page.len(), 0); + Err(()) + }); + assert_eq!(status(), (0, vec![])); + }); + + // get and delete + hypothetically!({ + OffenceSendQueue::::get_and_maybe_delete(|page| { + assert_eq!(page.len(), 0); + Ok(()) + }); + assert_eq!(status(), (0, vec![])); + }); + + // -------- when 1 page half filled + for _ in 0..page_size / 2 { + OffenceSendQueue::::append(o.clone()); + } + assert_eq!(status(), (0, vec![page_size / 2])); + assert_eq!(OffenceSendQueue::::count(), page_size / 2); + assert_eq!(OffenceSendQueue::::pages(), 1); + + // get and keep + hypothetically!({ + OffenceSendQueue::::get_and_maybe_delete(|page| { + assert_eq!(page.len() as u32, page_size / 2); + Err(()) + }); + assert_eq!(status(), (0, vec![page_size / 2])); + }); + + // get and delete + hypothetically!({ + OffenceSendQueue::::get_and_maybe_delete(|page| { + assert_eq!(page.len() as u32, page_size / 2); + Ok(()) + }); + assert_eq!(status(), (0, vec![])); + assert_eq!(OffenceSendQueue::::count(), 0); + assert_eq!(OffenceSendQueue::::pages(), 0); + }); + + // -------- when 1 page full + for _ in 0..page_size / 2 { + OffenceSendQueue::::append(o.clone()); + } + assert_eq!(status(), (0, vec![page_size])); + assert_eq!(OffenceSendQueue::::count(), page_size); + assert_eq!(OffenceSendQueue::::pages(), 1); + + // get and keep + hypothetically!({ + OffenceSendQueue::::get_and_maybe_delete(|page| { + assert_eq!(page.len() as u32, page_size); + Err(()) + }); + assert_eq!(status(), (0, vec![page_size])); + }); + + // get and delete + hypothetically!({ + OffenceSendQueue::::get_and_maybe_delete(|page| { + assert_eq!(page.len() as u32, page_size); + Ok(()) + }); + assert_eq!(status(), (0, vec![])); + }); + + // -------- when more than 1 page full + OffenceSendQueue::::append(o.clone()); + assert_eq!(status(), (1, vec![page_size, 1])); + assert_eq!(OffenceSendQueue::::count(), page_size + 1); + assert_eq!(OffenceSendQueue::::pages(), 2); + + // get and keep + hypothetically!({ + OffenceSendQueue::::get_and_maybe_delete(|page| { + assert_eq!(page.len(), 1); + Err(()) + }); + assert_eq!(status(), (1, vec![page_size, 1])); + }); + + // get and delete + hypothetically!({ + OffenceSendQueue::::get_and_maybe_delete(|page| { + assert_eq!(page.len(), 1); + Ok(()) + }); + assert_eq!(status(), (0, vec![page_size])); + }); + }) + } +} diff --git a/substrate/frame/staking-async/ah-client/src/mock.rs b/substrate/frame/staking-async/ah-client/src/mock.rs index 4d3504c1544f9..063d5ee5b2ff2 100644 --- a/substrate/frame/staking-async/ah-client/src/mock.rs +++ b/substrate/frame/staking-async/ah-client/src/mock.rs @@ -19,7 +19,6 @@ use crate::*; use frame_support::{derive_impl, parameter_types, weights::Weight}; -use pallet_staking_async_rc_client as rc_client; use sp_runtime::{BuildStorage, Perbill}; use sp_staking::offence::{OffenceSeverity, OnOffenceHandler}; @@ -39,13 +38,6 @@ impl frame_system::Config for Test { type AccountData = (); } -pub struct MockSendToAssetHub; -impl SendToAssetHub for MockSendToAssetHub { - type AccountId = u64; - fn relay_session_report(_session_report: rc_client::SessionReport) {} - fn relay_new_offence(_session: u32, _offences: Vec>) {} -} - pub struct MockSessionInterface; impl SessionInterface for MockSessionInterface { type ValidatorId = u64; @@ -103,14 +95,15 @@ impl Config for Test { type CurrencyBalance = u128; type AssetHubOrigin = frame_system::EnsureRoot; type AdminOrigin = frame_system::EnsureRoot; - type SendToAssetHub = MockSendToAssetHub; + type SendToAssetHub = (); type MinimumValidatorSetSize = MinimumValidatorSetSize; + type MaximumValidatorsWithPoints = ConstU32<128>; type UnixTime = MockUnixTime; type PointsPerBlock = PointsPerBlock; type MaxOffenceBatchSize = MaxOffenceBatchSize; type SessionInterface = MockSessionInterface; type Fallback = MockFallback; - type WeightInfo = (); + type MaxSessionReportRetries = ConstU32<3>; } #[cfg(test)] diff --git a/substrate/frame/staking-async/ah-client/src/weights.rs b/substrate/frame/staking-async/ah-client/src/weights.rs deleted file mode 100644 index fc831f92d25f7..0000000000000 --- a/substrate/frame/staking-async/ah-client/src/weights.rs +++ /dev/null @@ -1,136 +0,0 @@ -// This file is part of Substrate. - -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// This file is part of Substrate. - -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Autogenerated weights for `pallet_staking_async_ah_client` -//! -//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 32.0.0 -//! DATE: 2025-07-02, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[1]`, HIGH RANGE: `[1000]` -//! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `PAR03662`, CPU: `` -//! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` - -// Executed Command: -// frame-omni-bencher -// v1 -// benchmark -// pallet -// --runtime -// ./target/release/wbuild/westend-runtime/westend_runtime.compact.compressed.wasm -// --pallet -// pallet_staking_async_ah_client -// --extrinsic -// process_buffered_offences -// --steps -// 50 -// --repeat -// 20 -// --low -// 1 -// --high -// 1000 -// --output -// ./substrate/frame/staking-async/ah-client/src/weights.rs -// --header -// ./substrate/HEADER-APACHE2 -// --template -// ./substrate/.maintain/frame-weight-template.hbs - -#![cfg_attr(rustfmt, rustfmt_skip)] -#![allow(unused_parens)] -#![allow(unused_imports)] -#![allow(missing_docs)] -#![allow(dead_code)] - -use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; -use core::marker::PhantomData; - -/// Weight functions needed for `pallet_staking_async_ah_client`. -pub trait WeightInfo { - fn process_buffered_offences(n: u32, ) -> Weight; -} - -/// Weights for `pallet_staking_async_ah_client` using the Substrate node and recommended hardware. -pub struct SubstrateWeight(PhantomData); -impl WeightInfo for SubstrateWeight { - /// Storage: `StakingAhClient::BufferedOffences` (r:1 w:1) - /// Proof: `StakingAhClient::BufferedOffences` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Dmp::DeliveryFeeFactor` (r:1 w:0) - /// Proof: `Dmp::DeliveryFeeFactor` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `XcmPallet::SupportedVersion` (r:1 w:0) - /// Proof: `XcmPallet::SupportedVersion` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `Dmp::DownwardMessageQueues` (r:1 w:0) - /// Proof: `Dmp::DownwardMessageQueues` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `Paras::Heads` (r:1 w:0) - /// Proof: `Paras::Heads` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// The range of component `n` is `[1, 1000]`. - fn process_buffered_offences(n: u32, ) -> Weight { - // Proof Size summary in bytes: - // Measured: `340 + n * (69 ±0)` - // Estimated: `3803 + n * (69 ±0)` - // Minimum execution time: 31_000_000 picoseconds. - Weight::from_parts(48_047_906, 3803) - // Standard Error: 638 - .saturating_add(Weight::from_parts(112_664, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(5_u64)) - .saturating_add(T::DbWeight::get().writes(1_u64)) - .saturating_add(Weight::from_parts(0, 69).saturating_mul(n.into())) - } -} - -// For backwards compatibility and tests. -impl WeightInfo for () { - /// Storage: `StakingAhClient::BufferedOffences` (r:1 w:1) - /// Proof: `StakingAhClient::BufferedOffences` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Dmp::DeliveryFeeFactor` (r:1 w:0) - /// Proof: `Dmp::DeliveryFeeFactor` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `XcmPallet::SupportedVersion` (r:1 w:0) - /// Proof: `XcmPallet::SupportedVersion` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `Dmp::DownwardMessageQueues` (r:1 w:0) - /// Proof: `Dmp::DownwardMessageQueues` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `Paras::Heads` (r:1 w:0) - /// Proof: `Paras::Heads` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// The range of component `n` is `[1, 1000]`. - fn process_buffered_offences(n: u32, ) -> Weight { - // Proof Size summary in bytes: - // Measured: `340 + n * (69 ±0)` - // Estimated: `3803 + n * (69 ±0)` - // Minimum execution time: 31_000_000 picoseconds. - Weight::from_parts(48_047_906, 3803) - // Standard Error: 638 - .saturating_add(Weight::from_parts(112_664, 0).saturating_mul(n.into())) - .saturating_add(RocksDbWeight::get().reads(5_u64)) - .saturating_add(RocksDbWeight::get().writes(1_u64)) - .saturating_add(Weight::from_parts(0, 69).saturating_mul(n.into())) - } -} diff --git a/substrate/frame/staking-async/ahm-test/Cargo.toml b/substrate/frame/staking-async/ahm-test/Cargo.toml index 7c434eb42de9f..424a8d93f66c3 100644 --- a/substrate/frame/staking-async/ahm-test/Cargo.toml +++ b/substrate/frame/staking-async/ahm-test/Cargo.toml @@ -41,6 +41,7 @@ pallet-session = { workspace = true, default-features = true } pallet-staking-async-ah-client = { workspace = true, default-features = true } pallet-timestamp = { workspace = true, default-features = true } # staking classic which will be replaced by ah-client +pallet-offences = { workspace = true, default-features = true } pallet-root-offences = { workspace = true, default-features = true } pallet-staking = { workspace = true, default-features = true } @@ -61,6 +62,7 @@ try-runtime = [ "frame/try-runtime", "pallet-authorship/try-runtime", "pallet-election-provider-multi-block/try-runtime", + "pallet-offences/try-runtime", "pallet-root-offences/try-runtime", "pallet-session/try-runtime", "pallet-staking-async-ah-client/try-runtime", diff --git a/substrate/frame/staking-async/ahm-test/src/ah/mock.rs b/substrate/frame/staking-async/ahm-test/src/ah/mock.rs index dbfdab839db11..462e9ef41e515 100644 --- a/substrate/frame/staking-async/ahm-test/src/ah/mock.rs +++ b/substrate/frame/staking-async/ahm-test/src/ah/mock.rs @@ -32,6 +32,10 @@ construct_runtime! { System: frame_system, Balances: pallet_balances, + // NOTE: the validator set is given by pallet-staking to rc-client on-init, and rc-client + // will not send it immediately, but rather store it and sends it over on its own next + // on-init call. Yet, because staking comes first here, its on-init is called before + // rc-client, so under normal conditions, the message is sent immediately. Staking: pallet_staking_async, RcClient: pallet_staking_async_rc_client, @@ -365,13 +369,33 @@ impl pallet_staking_async_rc_client::Config for Runtime { type AHStakingInterface = Staking; type SendToRelayChain = DeliverToRelay; type RelayChainOrigin = EnsureRoot; + type MaxValidatorSetRetries = ConstU32<3>; +} + +parameter_types! { + pub static NextRelayDeliveryFails: bool = false; } pub struct DeliverToRelay; + +impl DeliverToRelay { + fn ensure_delivery_guard() -> Result<(), ()> { + // `::take` will set it back to the default value, `false`. + if NextRelayDeliveryFails::take() { + Err(()) + } else { + Ok(()) + } + } +} + impl pallet_staking_async_rc_client::SendToRelayChain for DeliverToRelay { type AccountId = AccountId; - fn validator_set(report: pallet_staking_async_rc_client::ValidatorSetReport) { + fn validator_set( + report: pallet_staking_async_rc_client::ValidatorSetReport, + ) -> Result<(), ()> { + Self::ensure_delivery_guard()?; if let Some(mut local_queue) = LocalQueue::get() { local_queue.push((System::block_number(), OutgoingMessages::ValidatorSet(report))); LocalQueue::set(Some(local_queue)); @@ -386,6 +410,7 @@ impl pallet_staking_async_rc_client::SendToRelayChain for DeliverToRelay { .unwrap(); }); } + Ok(()) } } diff --git a/substrate/frame/staking-async/ahm-test/src/ah/test.rs b/substrate/frame/staking-async/ahm-test/src/ah/test.rs index 2894de24b5a2e..3b2838b29e8e8 100644 --- a/substrate/frame/staking-async/ahm-test/src/ah/test.rs +++ b/substrate/frame/staking-async/ahm-test/src/ah/test.rs @@ -23,7 +23,9 @@ use pallet_staking_async::{ self as staking_async, session_rotation::Rotator, ActiveEra, ActiveEraInfo, CurrentEra, Event as StakingEvent, }; -use pallet_staking_async_rc_client::{self as rc_client, UnexpectedKind, ValidatorSetReport}; +use pallet_staking_async_rc_client::{ + self as rc_client, OutgoingValidatorSet, UnexpectedKind, ValidatorSetReport, +}; // Tests that are specific to Asset Hub. #[test] @@ -162,6 +164,7 @@ fn on_receive_session_report() { // no xcm message sent yet. assert_eq!(LocalQueue::get().unwrap(), vec![]); + // normal conditions, validator set can be sent. // next 3 block exports the election result to staking. roll_many(3); @@ -199,6 +202,179 @@ fn on_receive_session_report() { }) } +#[test] +fn validator_set_send_fail_retries() { + ExtBuilder::default().local_queue().build().execute_with(|| { + // GIVEN genesis state of ah + assert_eq!(System::block_number(), 1); + assert_eq!(CurrentEra::::get(), Some(0)); + assert_eq!(Rotator::::active_era_start_session_index(), 0); + assert_eq!(ActiveEra::::get(), Some(ActiveEraInfo { index: 0, start: Some(0) })); + + // first session comes in. + let session_report = rc_client::SessionReport { + end_index: 0, + validator_points: (1..9).into_iter().map(|v| (v as AccountId, v * 10)).collect(), + activation_timestamp: None, + leftover: false, + }; + + assert_ok!(rc_client::Pallet::::relay_session_report( + RuntimeOrigin::root(), + session_report.clone(), + )); + + // flush some events. + let _ = staking_events_since_last_call(); + + // roll two more sessions... + for i in 1..3 { + // roll some random number of blocks. + roll_many(10); + + // send the session report. + assert_ok!(rc_client::Pallet::::relay_session_report( + RuntimeOrigin::root(), + rc_client::SessionReport { + end_index: i, + validator_points: vec![(1, 10)], + activation_timestamp: None, + leftover: false, + } + )); + + let era_points = staking_async::ErasRewardPoints::::get(&0); + assert_eq!(era_points.total, 360 + i * 10); + assert_eq!(era_points.individual.get(&1), Some(&(10 + i * 10))); + + assert_eq!( + staking_events_since_last_call(), + vec![StakingEvent::SessionRotated { + starting_session: i + 1, + active_era: 0, + planned_era: 0 + }] + ); + } + + // Next session we will begin election. + assert_ok!(rc_client::Pallet::::relay_session_report( + RuntimeOrigin::root(), + rc_client::SessionReport { + end_index: 3, + validator_points: vec![(1, 10)], + activation_timestamp: None, + leftover: false, + } + )); + + assert_eq!( + staking_events_since_last_call(), + vec![StakingEvent::SessionRotated { + starting_session: 4, + active_era: 0, + // planned era 1 indicates election start signal is sent. + planned_era: 1 + }] + ); + + assert_eq!( + election_events_since_last_call(), + // Snapshot phase has started which will run for 3 blocks + vec![ElectionEvent::PhaseTransitioned { from: Phase::Off, to: Phase::Snapshot(3) }] + ); + + // roll 3 blocks for signed phase, and one for the transition. + roll_many(3 + 1); + assert_eq!( + election_events_since_last_call(), + // Signed phase has started which will run for 3 blocks. + vec![ElectionEvent::PhaseTransitioned { + from: Phase::Snapshot(0), + to: Phase::Signed(3) + }] + ); + + // roll some blocks until election result is exported. + roll_many(15); + assert_eq!( + election_events_since_last_call(), + vec![ + ElectionEvent::PhaseTransitioned { + from: Phase::Signed(0), + to: Phase::SignedValidation(6) + }, + ElectionEvent::PhaseTransitioned { + from: Phase::SignedValidation(0), + to: Phase::Unsigned(3) + }, + ElectionEvent::PhaseTransitioned { from: Phase::Unsigned(0), to: Phase::Done }, + ] + ); + + // no staking event while election ongoing. + assert_eq!(staking_events_since_last_call(), vec![]); + // no xcm message sent yet. + assert_eq!(LocalQueue::get().unwrap(), vec![]); + + // bad condition -- validator set cannot be sent. + // assume the next validator set cannot be sent. + NextRelayDeliveryFails::set(true); + let _ = rc_client_events_since_last_call(); + + roll_many(3); + assert_eq!( + staking_events_since_last_call(), + vec![ + StakingEvent::PagedElectionProceeded { page: 2, result: Ok(4) }, + StakingEvent::PagedElectionProceeded { page: 1, result: Ok(0) }, + StakingEvent::PagedElectionProceeded { page: 0, result: Ok(0) } + ] + ); + + assert_eq!( + election_events_since_last_call(), + vec![ + ElectionEvent::PhaseTransitioned { from: Phase::Done, to: Phase::Export(1) }, + ElectionEvent::PhaseTransitioned { from: Phase::Export(0), to: Phase::Off } + ] + ); + + // but.. + + // nothing is queued + assert!(LocalQueue::get().unwrap().is_empty()); + + // rc-client has an event + assert_eq!( + rc_client_events_since_last_call(), + vec![rc_client::Event::Unexpected(UnexpectedKind::ValidatorSetSendFailed)] + ); + + // the buffer is set + assert!(matches!(OutgoingValidatorSet::::get(), Some((_, 2)))); + + // next block it is retried and sent fine + roll_next(); + assert_eq!( + LocalQueue::get().unwrap(), + vec![( + // this is the block number at which the message was sent. + 44, + OutgoingMessages::ValidatorSet(ValidatorSetReport { + new_validator_set: vec![3, 5, 6, 8], + id: 1, + prune_up_to: None, + leftover: false + }) + )] + ); + + // buffer is clear + assert!(OutgoingValidatorSet::::get().is_none()); + }); +} + #[test] fn roll_many_eras() { // todo: @@ -475,20 +651,25 @@ fn on_offence_current_era() { // flush the events. let _ = staking_events_since_last_call(); - assert_ok!(rc_client::Pallet::::relay_new_offence( + assert_ok!(rc_client::Pallet::::relay_new_offence_paged( RuntimeOrigin::root(), - 5, vec![ - rc_client::Offence { - offender: 5, - reporters: vec![], - slash_fraction: Perbill::from_percent(50), - }, - rc_client::Offence { - offender: 3, - reporters: vec![], - slash_fraction: Perbill::from_percent(50), - } + ( + 5, + rc_client::Offence { + offender: 5, + reporters: vec![], + slash_fraction: Perbill::from_percent(50), + } + ), + ( + 5, + rc_client::Offence { + offender: 3, + reporters: vec![], + slash_fraction: Perbill::from_percent(50), + } + ) ] )); @@ -567,20 +748,25 @@ fn on_offence_current_era_instant_apply() { // flush the events. let _ = staking_events_since_last_call(); - assert_ok!(rc_client::Pallet::::relay_new_offence( + assert_ok!(rc_client::Pallet::::relay_new_offence_paged( RuntimeOrigin::root(), - 5, vec![ - rc_client::Offence { - offender: 5, - reporters: vec![], - slash_fraction: Perbill::from_percent(50), - }, - rc_client::Offence { - offender: 3, - reporters: vec![], - slash_fraction: Perbill::from_percent(50), - } + ( + 5, + rc_client::Offence { + offender: 5, + reporters: vec![], + slash_fraction: Perbill::from_percent(50), + } + ), + ( + 5, + rc_client::Offence { + offender: 3, + reporters: vec![], + slash_fraction: Perbill::from_percent(50), + } + ) ] )); @@ -645,15 +831,17 @@ fn on_offence_non_validator() { // flush the events. let _ = staking_events_since_last_call(); - assert_ok!(rc_client::Pallet::::relay_new_offence( + assert_ok!(rc_client::Pallet::::relay_new_offence_paged( RuntimeOrigin::root(), - 5, - vec![rc_client::Offence { - // this offender is unknown to the staking pallet. - offender: 666, - reporters: vec![], - slash_fraction: Perbill::from_percent(50), - }] + vec![( + 5, + rc_client::Offence { + // this offender is unknown to the staking pallet. + offender: 666, + reporters: vec![], + slash_fraction: Perbill::from_percent(50), + } + )] )); // nada @@ -683,15 +871,17 @@ fn on_offence_previous_era() { assert_eq!(oldest_reportable_era, 2); // WHEN we report an offence older than Era 2 (oldest reportable era). - assert_ok!(rc_client::Pallet::::relay_new_offence( + assert_ok!(rc_client::Pallet::::relay_new_offence_paged( RuntimeOrigin::root(), // offence is in era 1 - 5, - vec![rc_client::Offence { - offender: 3, - reporters: vec![], - slash_fraction: Perbill::from_percent(30), - }] + vec![( + 5, + rc_client::Offence { + offender: 3, + reporters: vec![], + slash_fraction: Perbill::from_percent(30), + } + )] )); // THEN offence is ignored. @@ -706,15 +896,17 @@ fn on_offence_previous_era() { // WHEN: report an offence for the session belonging to the previous era assert_eq!(Rotator::::era_start_session_index(2), Some(10)); - assert_ok!(rc_client::Pallet::::relay_new_offence( + assert_ok!(rc_client::Pallet::::relay_new_offence_paged( RuntimeOrigin::root(), // offence is in era 2 - 10, - vec![rc_client::Offence { - offender: 3, - reporters: vec![], - slash_fraction: Perbill::from_percent(50), - }] + vec![( + 10, + rc_client::Offence { + offender: 3, + reporters: vec![], + slash_fraction: Perbill::from_percent(50), + } + )] )); // THEN: offence is reported. @@ -779,15 +971,17 @@ fn on_offence_previous_era_instant_apply() { // report an offence for the session belonging to the previous era assert_eq!(Rotator::::era_start_session_index(1), Some(5)); - assert_ok!(rc_client::Pallet::::relay_new_offence( + assert_ok!(rc_client::Pallet::::relay_new_offence_paged( RuntimeOrigin::root(), // offence is in era 1 - 5, - vec![rc_client::Offence { - offender: 3, - reporters: vec![], - slash_fraction: Perbill::from_percent(50), - }] + vec![( + 5, + rc_client::Offence { + offender: 3, + reporters: vec![], + slash_fraction: Perbill::from_percent(50), + } + )] )); // reported diff --git a/substrate/frame/staking-async/ahm-test/src/lib.rs b/substrate/frame/staking-async/ahm-test/src/lib.rs index bdeec435d9acd..4c7e8128d74aa 100644 --- a/substrate/frame/staking-async/ahm-test/src/lib.rs +++ b/substrate/frame/staking-async/ahm-test/src/lib.rs @@ -27,14 +27,17 @@ pub mod shared; #[cfg(test)] mod tests { use super::*; - use crate::rc::RootOffences; + use crate::{ + ah::{rc_client_events_since_last_call, staking_events_since_last_call}, + rc::RootOffences, + }; use ah_client::OperatingMode; use frame::testing_prelude::*; use frame_support::traits::Get; use pallet_election_provider_multi_block as multi_block; use pallet_staking as staking_classic; use pallet_staking_async::{ActiveEra, ActiveEraInfo, Forcing}; - use pallet_staking_async_ah_client as ah_client; + use pallet_staking_async_ah_client::{self as ah_client, OffenceSendQueue}; use pallet_staking_async_rc_client as rc_client; #[test] @@ -123,7 +126,7 @@ mod tests { .pre_migration() // set session keys for all "potential" validators .session_keys(vec![1, 2, 3, 4, 5, 6, 7, 8]) - // set a very low MaxOffenceBatchSize to test batching behavior + // set a very low `MaxOffenceBatchSize` to test batching behavior .max_offence_batch_size(2) .build(), ); @@ -178,7 +181,7 @@ mod tests { assert_eq!(ah_client::Mode::::get(), OperatingMode::Buffered); // get current session - let mut current_session = pallet_session::CurrentIndex::::get(); + let current_session = pallet_session::CurrentIndex::::get(); pre_migration_block_number = frame_system::Pallet::::block_number(); // assume migration takes at least one era @@ -191,7 +194,6 @@ mod tests { }, true, ); - current_session = pallet_session::CurrentIndex::::get(); let migration_start_block_number = frame_system::Pallet::::block_number(); // ensure era is still 1 on RC. @@ -222,25 +224,22 @@ mod tests { // Verify buffered mode doesn't send anything to AH let offence_counter_before = shared::CounterRCAHNewOffence::get(); - // Create multiple offences for same validator (2) to test "keep highest" - // behavior. - // First create an offence with 50% slash + // ---------- Offences in session 12 (6 total) ---------- + assert_eq!(pallet_session::CurrentIndex::::get(), 12); + + // 3 for validator 2 assert_ok!(RootOffences::create_offence( rc::RuntimeOrigin::root(), vec![(2, Perbill::from_percent(50))], None, None, )); - - // Create second offence for validator 2 with higher slash - should be kept assert_ok!(RootOffences::create_offence( rc::RuntimeOrigin::root(), vec![(2, Perbill::from_percent(100))], None, None, )); - - // Create third offence for validator 2 with lower slash - should be ignored assert_ok!(RootOffences::create_offence( rc::RuntimeOrigin::root(), vec![(2, Perbill::from_percent(25))], @@ -248,15 +247,13 @@ mod tests { None, )); - // Create offences for validator 1 in the same session to test multiple validators + // 2 for validator 1 assert_ok!(RootOffences::create_offence( rc::RuntimeOrigin::root(), vec![(1, Perbill::from_percent(75))], None, None, )); - - // Create another offence for validator 1 with lower slash - should be ignored assert_ok!(RootOffences::create_offence( rc::RuntimeOrigin::root(), vec![(1, Perbill::from_percent(60))], @@ -264,8 +261,7 @@ mod tests { None, )); - // Add a third validator (validator 5) to test MaxOffenceBatchSize=2 behavior - // when we have more than 2 offences in a single session + // one for validator 4 assert_ok!(RootOffences::create_offence( rc::RuntimeOrigin::root(), vec![(5, Perbill::from_percent(55))], @@ -275,34 +271,31 @@ mod tests { // Move to the next session to create offences in different sessions for batching test rc::roll_to_next_session(false); - let next_session = pallet_session::CurrentIndex::::get(); - // Create offences for validator 2 in the new session to test batching + // ---------- Offences in session 13 (4 total) ---------- + assert_eq!(pallet_session::CurrentIndex::::get(), 13); + + // 2 for validator 2 assert_ok!(RootOffences::create_offence( rc::RuntimeOrigin::root(), vec![(2, Perbill::from_percent(90))], None, None, )); - - // Create another offence for validator 2 in same session (should be discarded as it's - // lower than the 90% one) assert_ok!(RootOffences::create_offence( rc::RuntimeOrigin::root(), vec![(2, Perbill::from_percent(80))], None, None, )); - - // Create offences for validator 1 in the new session + // 1 for validator 1 assert_ok!(RootOffences::create_offence( rc::RuntimeOrigin::root(), vec![(1, Perbill::from_percent(85))], None, None, )); - - // Create offences for validator 5 in the new session + // 1 for validator 5 assert_ok!(RootOffences::create_offence( rc::RuntimeOrigin::root(), vec![(5, Perbill::from_percent(45))], @@ -312,25 +305,25 @@ mod tests { // Move to another session and create more offences rc::roll_to_next_session(false); - let third_session = pallet_session::CurrentIndex::::get(); - // Create offences for validator 2 in third session + // ---------- Offences in session 14 (3 total) ---------- + assert_eq!(pallet_session::CurrentIndex::::get(), 14); + + // 1 for validator 2 assert_ok!(RootOffences::create_offence( rc::RuntimeOrigin::root(), vec![(2, Perbill::from_percent(70))], None, None, )); - - // Create offences for validator 1 in third session + // 1 for validator 1 assert_ok!(RootOffences::create_offence( rc::RuntimeOrigin::root(), vec![(1, Perbill::from_percent(65))], None, None, )); - - // Create offences for validator 5 in third session + // 1 for validator 5 assert_ok!(RootOffences::create_offence( rc::RuntimeOrigin::root(), vec![(5, Perbill::from_percent(40))], @@ -338,6 +331,8 @@ mod tests { None, )); + // ---------- End of offences created so far (13 total) ---------- + // Verify nothing was sent to AH in buffered mode assert_eq!( shared::CounterRCAHNewOffence::get(), @@ -349,46 +344,9 @@ mod tests { // created). assert_eq!(staking_classic::UnappliedSlashes::::get(4).len(), 1); - // Verify buffered offences are stored correctly - let buffered_offences = ah_client::BufferedOffences::::get(); - assert_eq!( - buffered_offences.len(), - 3, - "Should have buffered offences for exactly 3 sessions" - ); - assert!(buffered_offences.contains_key(¤t_session)); - - // Count total offences across all sessions - let total_offences: usize = - buffered_offences.values().map(|session_map| session_map.len()).sum(); - assert_eq!( - total_offences, 9, - "Should have 9 offences total (three per session for validators 1, 2, and 5)" - ); - - // Verify all sessions have the correct buffered offences with their highest slash - // fractions - assert_eq!( - buffered_offences - .iter() - .flat_map(|(session, offences)| offences.iter().map(move |(id, offence)| ( - *session, - *id, - offence.slash_fraction - ))) - .collect::>(), - vec![ - (current_session, 1, Perbill::from_percent(75)), // highest of 75%, 60% - (current_session, 2, Perbill::from_percent(100)), // highest of 50%, 100%, 25% - (current_session, 5, Perbill::from_percent(55)), // single offence - (next_session, 1, Perbill::from_percent(85)), // single offence - (next_session, 2, Perbill::from_percent(90)), // highest of 90% and 80% - (next_session, 5, Perbill::from_percent(45)), // single offence - (third_session, 1, Perbill::from_percent(65)), // single offence - (third_session, 2, Perbill::from_percent(70)), // single offence - (third_session, 5, Perbill::from_percent(40)), // single offence - ] - ); + // we have stored a total of 13 offences, in 7 pages + assert_eq!(OffenceSendQueue::::count(), 13); + assert_eq!(OffenceSendQueue::::pages(), 7); }); // Ensure AH still does not receive any offence while migration is ongoing. @@ -405,47 +363,59 @@ mod tests { // SCENE (3): AHM migration ends. shared::in_rc(|| { // Before migration ends, verify we have 9 buffered offences across multiple sessions - let buffered_before = ah_client::BufferedOffences::::get(); - let total_offences_before: usize = - buffered_before.values().map(|session_map| session_map.len()).sum(); - assert_eq!(total_offences_before, 9); + assert_eq!(OffenceSendQueue::::count(), 13); ah_client::Pallet::::on_migration_end(); assert_eq!(ah_client::Mode::::get(), OperatingMode::Active); - // We have 3 sessions containing offences (3 validators per session = 9 total offences). - // Since we have 3 offences per session but MaxOffenceBatchSize = 2, only the first 2 - // offences from each session will be sent in the first batch, and the remaining 1 - // offence per session will be sent in subsequent batches. + // `MaxOffenceBatchSize` is set to 2 in this test, so we will send over the 13 offences + // in 7 next blocks. + assert_eq!(crate::rc::MaxOffenceBatchSize::get(), 2); - // After migration ends, buffered offences should start being processed. - // Let's advance to trigger on_initialize processing + // in the first block we process the half finished page. rc::roll_next(); + assert_eq!(OffenceSendQueue::::count(), 12); + assert_eq!(shared::CounterRCAHNewOffence::get(), 1); - // With MaxOffenceBatchSize = 2 and 3 offences per session, each session will be - // processed in multiple batches (2 + 1 offences per session) We have 3 sessions, each - // requiring 2 batches = 6 total batches to process. - // Roll 6 blocks to process all 6 batches - for _ in 0..6 { - rc::roll_next(); - } - - let total_calls = shared::CounterRCAHNewOffence::get(); - assert_eq!( - total_calls, 6, - "Expected exactly 6 calls total (3 sessions × 2 calls per session), got {}", - total_calls - ); - - // All buffered offences should be cleared now - assert!( - ah_client::BufferedOffences::::get().is_empty(), - "All buffered offences should be processed" - ); + // the rest are full pages of 2 offences each. + rc::roll_next(); + assert_eq!(OffenceSendQueue::::count(), 10); + assert_eq!(shared::CounterRCAHNewOffence::get(), 3); + rc::roll_next(); + assert_eq!(OffenceSendQueue::::count(), 8); + assert_eq!(shared::CounterRCAHNewOffence::get(), 5); + rc::roll_next(); + assert_eq!(OffenceSendQueue::::count(), 6); + assert_eq!(shared::CounterRCAHNewOffence::get(), 7); + rc::roll_next(); + assert_eq!(OffenceSendQueue::::count(), 4); + assert_eq!(shared::CounterRCAHNewOffence::get(), 9); + rc::roll_next(); + assert_eq!(OffenceSendQueue::::count(), 2); + assert_eq!(shared::CounterRCAHNewOffence::get(), 11); + rc::roll_next(); + assert_eq!(OffenceSendQueue::::count(), 0); + assert_eq!(shared::CounterRCAHNewOffence::get(), 13); }); let mut post_migration_era_reward_points = 0; shared::in_ah(|| { + // all offences are received by rc-client. The count of these events are not 13, because + // in each batch we group offenders by session. Note that the sum of offenders count is + // indeed 13. + assert_eq!( + rc_client_events_since_last_call(), + vec![ + rc_client::Event::OffenceReceived { slash_session: 14, offences_count: 1 }, + rc_client::Event::OffenceReceived { slash_session: 14, offences_count: 2 }, + rc_client::Event::OffenceReceived { slash_session: 13, offences_count: 2 }, + rc_client::Event::OffenceReceived { slash_session: 13, offences_count: 2 }, + rc_client::Event::OffenceReceived { slash_session: 12, offences_count: 2 }, + rc_client::Event::OffenceReceived { slash_session: 12, offences_count: 2 }, + rc_client::Event::OffenceReceived { slash_session: 12, offences_count: 2 } + ] + ); + post_migration_era_reward_points = pallet_staking_async::ErasRewardPoints::::get(1).total; // staking async has always been in NotForcing, not doing anything since no session @@ -453,7 +423,8 @@ mod tests { assert_eq!(pallet_staking_async::ForceEra::::get(), Forcing::NotForcing); // Verify all offences were properly queued in staking-async. - // Should have offences for validators 1, 2, and 5 from different sessions + // Should have offences for validators 1, 2, and 5 from different sessions (all map to + // era 1) assert!(pallet_staking_async::OffenceQueue::::get(1, 1).is_some()); assert!(pallet_staking_async::OffenceQueue::::get(1, 2).is_some()); assert!(pallet_staking_async::OffenceQueue::::get(1, 5).is_some()); @@ -502,11 +473,11 @@ mod tests { ); // NOTE: - // - We sent 9 total offences across 3 sessions (3 offences per session) + // - We sent 13 total offences across 3 sessions and 7 messages (3 offences per session) // - Each session's offences trigger OffenceReported events when received // - But only the highest slash fraction per validator per era gets queued for // processing - // - So we see 9 OffenceReported events but only 3 offences in the processing queue + // - So we see 13 OffenceReported events but only 3 offences in the processing queue // - The queue processing happens one offence per block in staking-async pallet. // Process all queued offences (one offence per block) @@ -535,6 +506,27 @@ mod tests { }) .collect(); + // Verify all OffenceReported events + assert_eq!( + offence_reported_events, + vec![ + // 13 offences total, no deduplication here. + (&1, &5, &Perbill::from_percent(40)), + (&1, &2, &Perbill::from_percent(70)), + (&1, &1, &Perbill::from_percent(65)), + (&1, &1, &Perbill::from_percent(85)), + (&1, &5, &Perbill::from_percent(45)), + (&1, &2, &Perbill::from_percent(90)), + (&1, &2, &Perbill::from_percent(80)), + (&1, &1, &Perbill::from_percent(60)), + (&1, &5, &Perbill::from_percent(55)), + (&1, &2, &Perbill::from_percent(25)), + (&1, &1, &Perbill::from_percent(75)), + (&1, &2, &Perbill::from_percent(50)), + (&1, &2, &Perbill::from_percent(100)) + ] + ); + // Verify that SlashComputed events were emitted for all three validators let slash_computed_events: Vec<_> = staking_events .iter() @@ -553,9 +545,9 @@ mod tests { }) .collect(); - // Should have SlashComputed events for all three validators - // Note: OffenceQueue uses StorageDoubleMap with Twox64Concat hasher, so iteration order - // depends on hash(validator_id). + // Should have SlashComputed events for all three validators. Here we deduplicate for + // maximum per session. Note: OffenceQueue uses StorageDoubleMap with Twox64Concat + // hasher, so iteration order depends on hash(validator_id). assert_eq!( slash_computed_events, vec![ @@ -568,26 +560,6 @@ mod tests { ] ); - // Verify all OffenceReported events (9 total: 3 sessions × 3 validators) - // Note: order follows the sequence of offence processing [1, 2, 5] within each session - assert_eq!( - offence_reported_events, - vec![ - (&1, &1, &Perbill::from_percent(75)), /* validator 1, session 1 (highest of - * 75%, 60%) */ - (&1, &2, &Perbill::from_percent(100)), /* validator 2, session 1 (highest of - * 50%, 100%, 25%) */ - (&1, &5, &Perbill::from_percent(55)), // validator 5, session 1 - (&1, &1, &Perbill::from_percent(85)), // validator 1, session 2 - (&1, &2, &Perbill::from_percent(90)), /* validator 2, session 2 (highest of - * 90%, 80%) */ - (&1, &5, &Perbill::from_percent(45)), // validator 5, session 2 - (&1, &1, &Perbill::from_percent(65)), // validator 1, session 3 - (&1, &2, &Perbill::from_percent(70)), // validator 2, session 3 - (&1, &5, &Perbill::from_percent(40)), // validator 5, session 3 - ] - ); - // Verify that all offences have been processed (no longer in queue) assert!( !pallet_staking_async::OffenceQueue::::contains_key(1, 1), @@ -717,56 +689,24 @@ mod tests { assert_eq!(pallet_staking_async::CurrentEra::::get(), Some(1 + 1)); // by now one session report should have been received in staking - let rc_events = ah::rc_client_events_since_last_call(); - // We expect 7 events: 6 separate OffenceReceived events (due to MaxOffenceBatchSize=2 - // with 3 offences per session = 2 batches per session × 3 sessions) + 1 - // SessionReportReceived - assert_eq!(rc_events.len(), 7); - - // Check that we have 6 separate OffenceReceived events due to MaxOffenceBatchSize=2 - let offence_events: Vec<_> = rc_events - .iter() - .filter(|event| matches!(event, rc_client::Event::OffenceReceived { .. })) - .collect(); assert_eq!( - offence_events.len(), - 6, - "Should have 6 separate offence events due to batch size limit" - ); - - // With MaxOffenceBatchSize=2 and 3 offences per session, we expect: - // - 3 events with 2 offences each (first batch from each session) - // - 3 events with 1 offence each (second batch from each session) - let mut two_offence_events = 0; - let mut one_offence_events = 0; - for event in &offence_events { - if let rc_client::Event::OffenceReceived { offences_count, .. } = event { - match *offences_count { - 2 => two_offence_events += 1, - 1 => one_offence_events += 1, - _ => panic!("Unexpected offence count: {}", offences_count), - } - } - } - assert_eq!(two_offence_events, 3, "Should have 3 events with 2 offences each"); - assert_eq!(one_offence_events, 3, "Should have 3 events with 1 offence each"); - - // The last event should be the session report - assert!(matches!( - rc_events.last().unwrap(), - rc_client::Event::SessionReportReceived { + ah::rc_client_events_since_last_call(), + vec![rc_client::Event::SessionReportReceived { + end_index: 14, + activation_timestamp: None, validator_points_counts: 1, - leftover: false, - .. - } - )); + leftover: false + }] + ); - let staking_events = ah::mock::staking_events_since_last_call(); - assert_eq!(staking_events.len(), 1); - assert!(matches!( - staking_events[0], - pallet_staking_async::Event::SessionRotated { active_era: 1, planned_era: 2, .. } - )); + assert_eq!( + staking_events_since_last_call(), + vec![pallet_staking_async::Event::SessionRotated { + starting_session: 15, + active_era: 1, + planned_era: 2 + }] + ); // all expected era reward points are here assert_eq!( @@ -869,4 +809,159 @@ mod tests { // AH is offline for a while, and it suddenly receives 3 eras worth of session reports. What // do we do? } + + mod message_queue_sizes { + use super::*; + use sp_core::crypto::AccountId32; + + #[test] + fn normal_session_report() { + assert_eq!( + rc_client::SessionReport:: { + end_index: 0, + activation_timestamp: Some((0, 0)), + leftover: false, + validator_points: (0..1000) + .map(|i| (AccountId32::from([i as u8; 32]), 1000)) + .collect(), + } + .encoded_size(), + 36_020 + ); + } + + #[test] + fn normal_validator_set() { + assert_eq!( + rc_client::ValidatorSetReport:: { + id: 42, + leftover: false, + new_validator_set: (0..1000) + .map(|i| AccountId32::from([i as u8; 32])) + .collect(), + prune_up_to: Some(69), + } + .encoded_size(), + 32_012 + ); + } + + #[test] + fn offence() { + // when one validator had an offence + let offences = (0..1) + .map(|i| rc_client::Offence:: { + offender: AccountId32::from([i as u8; 32]), + reporters: vec![AccountId32::from([42; 32])], + slash_fraction: Perbill::from_percent(50), + }) + .collect::>(); + + // offence + session-index + let encoded_size = offences.encoded_size() + 42u32.encoded_size(); + assert_eq!(encoded_size, 74); + } + + // Kusama has the same configurations as of now. + const POLKADOT_MAX_DOWNWARD_MESSAGE_SIZE: u32 = 51200; // 50 Kib + const POLKADOT_MAX_UPWARD_MESSAGE_SIZE: u32 = 65531; // 64 Kib + + #[test] + fn maximum_session_report() { + let mut num_validator_points = 1; + loop { + let session_report = rc_client::SessionReport:: { + end_index: 0, + activation_timestamp: Some((0, 0)), + leftover: false, + validator_points: (0..num_validator_points) + .map(|i| (AccountId32::from([i as u8; 32]), 1000)) + .collect(), + }; + + // Note: the real encoded size of the message will be a few bytes more, due to call + // indices and XCM instructions, but not significant. + let encoded_size = session_report.encoded_size() as u32; + + if encoded_size > POLKADOT_MAX_DOWNWARD_MESSAGE_SIZE { + println!( + "SessionReport: num_validator_points: {}, encoded len: {}, max: {:?}, largest session report: {}", + num_validator_points, encoded_size, POLKADOT_MAX_DOWNWARD_MESSAGE_SIZE, num_validator_points - 1 + ); + break; + } + num_validator_points += 1; + } + + // We can send up to 1422 32-octet validators + u32 points in a single message. This + // should inform the configuration `MaximumValidatorsWithPoints`. + assert_eq!(num_validator_points, 1422); + } + + #[test] + fn maximum_validator_set() { + let mut num_validators = 1; + loop { + let validator_set_report = rc_client::ValidatorSetReport:: { + id: 42, + leftover: false, + new_validator_set: (0..num_validators) + .map(|i| AccountId32::from([i as u8; 32])) + .collect(), + prune_up_to: Some(69), + }; + + // Note: the real encoded size of the message will be a few bytes more, due to call + // indices and XCM instructions, but not significant. + let encoded_size = validator_set_report.encoded_size() as u32; + if encoded_size > POLKADOT_MAX_DOWNWARD_MESSAGE_SIZE { + println!( + "ValidatorSetReport: num_validators: {}, encoded len: {}, max: {:?}, largest validator set: {}", + num_validators, encoded_size, POLKADOT_MAX_DOWNWARD_MESSAGE_SIZE, num_validators - 1 + ); + break; + } + num_validators += 1; + } + + // We can send up to 1599 32-octet validator keys (+ other small metadata) in a single + // validator set report. + assert_eq!(num_validators, 1600); + } + + #[test] + fn maximum_offence_batched() { + let mut num_offences = 1; + let session_index: u32 = 42; + loop { + let offences = (0..num_offences) + .map(|i| { + ( + session_index, + rc_client::Offence:: { + offender: AccountId32::from([i as u8; 32]), + reporters: vec![AccountId32::from([42; 32])], + slash_fraction: Perbill::from_percent(50), + }, + ) + }) + .collect::>(); + let encoded_size = offences.encoded_size(); + + if encoded_size as u32 > POLKADOT_MAX_UPWARD_MESSAGE_SIZE { + println!( + "Offence (batched): num_offences: {}, encoded len: {}, max: {:?}, largest offence batch: {}", + num_offences, encoded_size, POLKADOT_MAX_UPWARD_MESSAGE_SIZE, num_offences - 1 + ); + break; + } + + num_offences += 1; + } + + // expectedly, this is a bit less than `offence_legacy` since we encode the session + // index over and over again. + assert_eq!(num_offences, 898); + } + } } diff --git a/substrate/frame/staking-async/ahm-test/src/rc/mock.rs b/substrate/frame/staking-async/ahm-test/src/rc/mock.rs index 124c67320f491..70eaf9e5fc36e 100644 --- a/substrate/frame/staking-async/ahm-test/src/rc/mock.rs +++ b/substrate/frame/staking-async/ahm-test/src/rc/mock.rs @@ -15,6 +15,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use crate::shared; use ah_client::OperatingMode; use frame::{ deps::sp_runtime::testing::UintAuthorityId, testing_prelude::*, traits::fungible::Mutate, @@ -25,10 +26,9 @@ use frame_election_provider_support::{ }; use frame_support::traits::FindAuthor; use pallet_staking_async_ah_client as ah_client; +use pallet_staking_async_rc_client::{self as rc_client, ValidatorSetReport}; use sp_staking::SessionIndex; -use crate::shared; - pub type T = Runtime; construct_runtime! { @@ -41,8 +41,13 @@ construct_runtime! { Session: pallet_session, SessionHistorical: pallet_session::historical, Staking: pallet_staking, + // NOTE: the session report is given by pallet-session to ah-client on-init, and ah-client + // will not send it immediately, but rather store it and sends it over on its own next + // on-init call. Yet, because session comes first here, its on-init is called before + // ah-client, so under normal conditions, the message is sent immediately. StakingAhClient: pallet_staking_async_ah_client, RootOffences: pallet_root_offences, + Offences: pallet_offences, } } @@ -244,19 +249,27 @@ impl pallet_staking::Config for Runtime { type BondingDuration = ConstU32<3>; } +impl pallet_offences::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type IdentificationTuple = pallet_session::historical::IdentificationTuple; + type OnOffenceHandler = StakingAhClient; +} + impl pallet_root_offences::Config for Runtime { type RuntimeEvent = RuntimeEvent; type OffenceHandler = StakingAhClient; + type ReportOffence = Offences; } #[derive(Clone, Debug, PartialEq)] pub enum OutgoingMessages { SessionReport(rc_client::SessionReport), - OffenceReport(SessionIndex, Vec>), + OffenceReportPaged(Vec<(SessionIndex, rc_client::Offence)>), } parameter_types! { pub static MinimumValidatorSetSize: u32 = 4; + pub static MaximumValidatorsWithPoints: u32 = 32; pub static MaxOffenceBatchSize: u32 = 50; pub static LocalQueue: Option> = None; pub static LocalQueueLastIndex: usize = 0; @@ -285,57 +298,75 @@ impl ah_client::Config for Runtime { type AssetHubOrigin = EnsureSigned; type UnixTime = Timestamp; type MinimumValidatorSetSize = MinimumValidatorSetSize; + type MaximumValidatorsWithPoints = MaximumValidatorsWithPoints; type PointsPerBlock = ConstU32<20>; type MaxOffenceBatchSize = MaxOffenceBatchSize; type SessionInterface = Self; - type WeightInfo = (); type Fallback = Staking; + type MaxSessionReportRetries = ConstU32<3>; +} + +parameter_types! { + pub static NextAhDeliveryFails: bool = false; } -use pallet_staking_async_rc_client::{self as rc_client, ValidatorSetReport}; pub struct DeliverToAH; +impl DeliverToAH { + fn ensure_delivery_guard() -> Result<(), ()> { + // `::take` will set it back to the default value, `false`. + if NextAhDeliveryFails::take() { + Err(()) + } else { + Ok(()) + } + } +} + impl ah_client::SendToAssetHub for DeliverToAH { type AccountId = AccountId; - fn relay_new_offence( - session_index: SessionIndex, - offences: Vec>, - ) { + + fn relay_session_report( + session_report: rc_client::SessionReport, + ) -> Result<(), ()> { + Self::ensure_delivery_guard()?; if let Some(mut local_queue) = LocalQueue::get() { - local_queue.push(( - System::block_number(), - OutgoingMessages::OffenceReport(session_index, offences), - )); + local_queue + .push((System::block_number(), OutgoingMessages::SessionReport(session_report))); LocalQueue::set(Some(local_queue)); } else { - shared::CounterRCAHNewOffence::mutate(|x| *x += 1); + shared::CounterRCAHSessionReport::mutate(|x| *x += 1); shared::in_ah(|| { let origin = crate::ah::RuntimeOrigin::root(); - rc_client::Pallet::::relay_new_offence( + rc_client::Pallet::::relay_session_report( origin, - session_index, - offences.clone(), + session_report.clone(), ) .unwrap(); }); } + Ok(()) } - fn relay_session_report(session_report: rc_client::SessionReport) { + fn relay_new_offence_paged( + offences: Vec<(SessionIndex, pallet_staking_async_rc_client::Offence)>, + ) -> Result<(), ()> { + Self::ensure_delivery_guard()?; if let Some(mut local_queue) = LocalQueue::get() { local_queue - .push((System::block_number(), OutgoingMessages::SessionReport(session_report))); + .push((System::block_number(), OutgoingMessages::OffenceReportPaged(offences))); LocalQueue::set(Some(local_queue)); } else { - shared::CounterRCAHSessionReport::mutate(|x| *x += 1); shared::in_ah(|| { + crate::shared::CounterRCAHNewOffence::mutate(|x| *x += offences.len() as u32); let origin = crate::ah::RuntimeOrigin::root(); - rc_client::Pallet::::relay_session_report( + rc_client::Pallet::::relay_new_offence_paged( origin, - session_report.clone(), + offences.clone(), ) .unwrap(); }); } + Ok(()) } } @@ -343,6 +374,7 @@ parameter_types! { pub static SessionEventsIndex: usize = 0; pub static HistoricalEventsIndex: usize = 0; pub static AhClientEventsIndex: usize = 0; + pub static OffenceEventsIndex: usize = 0; } pub fn historical_events_since_last_call() -> Vec> { @@ -354,6 +386,13 @@ pub fn historical_events_since_last_call() -> Vec Vec { + let all = frame_system::Pallet::::read_events_for_pallet::(); + let seen = OffenceEventsIndex::get(); + OffenceEventsIndex::set(all.len()); + all.into_iter().skip(seen).collect() +} + pub fn session_events_since_last_call() -> Vec> { let all = frame_system::Pallet::::read_events_for_pallet::>(); @@ -543,3 +582,10 @@ pub(crate) fn receive_validator_set_at( assert_eq!(pallet_session::Validators::::get(), new_validator_set); } } + +/// The queued validator points in the ah-client sorted by account id. +pub(crate) fn validator_points() -> Vec<(AccountId, u32)> { + let mut points = ah_client::ValidatorPoints::::iter().collect::>(); + points.sort_by(|a, b| a.0.cmp(&b.0)); + points +} diff --git a/substrate/frame/staking-async/ahm-test/src/rc/test.rs b/substrate/frame/staking-async/ahm-test/src/rc/test.rs index 32cabcd696514..ddd8c00731f91 100644 --- a/substrate/frame/staking-async/ahm-test/src/rc/test.rs +++ b/substrate/frame/staking-async/ahm-test/src/rc/test.rs @@ -17,7 +17,9 @@ use crate::rc::mock::*; use frame::testing_prelude::*; -use pallet_staking_async_ah_client::{self as ah_client, Mode, OperatingMode}; +use pallet_staking_async_ah_client::{ + self as ah_client, Mode, OffenceSendQueue, OperatingMode, OutgoingSessionReport, UnexpectedKind, +}; use pallet_staking_async_rc_client::{ self as rc_client, Offence, SessionReport, ValidatorSetReport, }; @@ -374,6 +376,125 @@ fn cleans_validator_points_upon_session_report() { }) } +#[test] +fn session_report_send_fails_after_retries() { + // if a session report cannot be sent, first we retry. If we still fail and retries are out, we + // restore the points. + ExtBuilder::default().local_queue().build().execute_with(|| { + // insert a custom validator point for easier tracking + ah_client::ValidatorPoints::::insert(1, 100); + + assert_eq!(pallet_session::CurrentIndex::::get(), 0); + assert!(ah_client::OutgoingSessionReport::::get().is_none()); + + // when roll forward, but next message will fail to be sent + NextAhDeliveryFails::set(true); + roll_until_matches(|| pallet_session::CurrentIndex::::get() == 1, false); + + // these are the points that are saved in the outgoing report + assert_eq!( + OutgoingSessionReport::::get().unwrap().0.validator_points, + vec![(1, 100), (11, 580)] + ); + + // now we have 2 retries left + assert!(matches!(ah_client::OutgoingSessionReport::::get(), Some((_, 2)))); + // validator points are drained, since we have the session report. + assert_eq!(validator_points(), vec![]); + // event emitted + assert_eq!( + ah_client_events_since_last_call(), + vec![ah_client::Event::Unexpected(UnexpectedKind::SessionReportSendFailed)] + ); + + // again + NextAhDeliveryFails::set(true); + roll_next(); + assert!(matches!(ah_client::OutgoingSessionReport::::get(), Some((_, 1)))); + // this is registered by our mock setup + assert_eq!(validator_points(), vec![(11, 20)]); + assert_eq!( + ah_client_events_since_last_call(), + vec![ah_client::Event::Unexpected(UnexpectedKind::SessionReportSendFailed)] + ); + + // in the meantime, we receive some new validator points. + ah_client::ValidatorPoints::::insert(1, 50); + + // again + NextAhDeliveryFails::set(true); + roll_next(); + assert!(matches!(ah_client::OutgoingSessionReport::::get(), Some((_, 0)))); + assert_eq!(validator_points(), vec![(1, 50), (11, 40)]); + assert_eq!( + ah_client_events_since_last_call(), + vec![ah_client::Event::Unexpected(UnexpectedKind::SessionReportSendFailed)] + ); + + // last time, we will drop it now. + NextAhDeliveryFails::set(true); + roll_next(); + assert!(matches!(ah_client::OutgoingSessionReport::::get(), None)); + assert_eq!( + ah_client_events_since_last_call(), + vec![ + ah_client::Event::Unexpected(UnexpectedKind::SessionReportSendFailed), + ah_client::Event::Unexpected(UnexpectedKind::SessionReportDropped) + ] + ); + + // validator points are restored and merged with what we have noted in the meantime. + assert_eq!(validator_points(), vec![(1, 150), (11, 640)]); + }) +} + +#[test] +fn reports_unexpected_event_if_too_many_validator_points() { + ExtBuilder::default().local_queue().build().execute_with(|| { + // create 1 too many validator points + for v in 0..=MaximumValidatorsWithPoints::get() { + ah_client::ValidatorPoints::::insert(v as AccountId, 100); + } + + // roll until next session + roll_until_matches(|| pallet_session::CurrentIndex::::get() == 1, false); + + // message is placed in the outbox + assert!(matches!( + &LocalQueue::get_since_last_call()[..], + [( + 30, + OutgoingMessages::SessionReport(SessionReport { + validator_points, .. + }) + )] if validator_points.len() as u32 == MaximumValidatorsWithPoints::get() + )); + + // but there is an unexpected event for us + assert_eq!( + ah_client_events_since_last_call(), + vec![ah_client::Event::Unexpected(UnexpectedKind::ValidatorPointDropped)] + ); + + // and one validator point is left; + assert_eq!(ah_client::ValidatorPoints::::iter().count(), 1); + + // it will be sent in the next session report + roll_until_matches(|| pallet_session::CurrentIndex::::get() == 2, false); + + assert!(matches!( + &LocalQueue::get_since_last_call()[..], + [( + 60, + OutgoingMessages::SessionReport(SessionReport { + validator_points, .. + }) + )] if validator_points.len() as u32 == 1 + 1 + // 1 more validator point added by the authorship pallet in our test setup + )); + }) +} + #[test] fn drops_too_small_validator_set() { ExtBuilder::default().local_queue().build().execute_with(|| { @@ -389,7 +510,7 @@ fn drops_too_small_validator_set() { assert_ok!(ah_client::Pallet::::validator_set(RuntimeOrigin::root(), report),); assert_eq!( ah_client_events_since_last_call(), - vec![ah_client::Event::SetTooSmallAndDropped,] + vec![ah_client::Event::SetTooSmallAndDropped] ); assert!(ah_client::ValidatorSet::::get().is_none()); @@ -464,19 +585,22 @@ fn on_offence_non_validator() { None )); + // roll 1 block to process + roll_next(); + // we nonetheless have sent the offence report to AH assert_eq!( LocalQueue::get_since_last_call(), vec![( - 150, - OutgoingMessages::OffenceReport( + 151, + OutgoingMessages::OffenceReportPaged(vec![( 5, - vec![Offence { + Offence { offender: 5, reporters: vec![], slash_fraction: Perbill::from_percent(50) - }] - ) + } + )]) )] ); @@ -485,6 +609,146 @@ fn on_offence_non_validator() { }) } +#[test] +fn offences_first_queued_and_then_sent() { + ExtBuilder::default().local_queue().build().execute_with(|| { + // flush some relevant data + LocalQueue::flush(); + let _ = session_events_since_last_call(); + + // submit an offence for two accounts + assert_ok!(pallet_root_offences::Pallet::::create_offence( + RuntimeOrigin::root(), + vec![(4, Perbill::from_percent(50)), (5, Perbill::from_percent(50))], + Some(vec![Default::default(), Default::default()]), + None + )); + + // Nothing is in our local outgoing queue yet + assert_eq!(LocalQueue::get_since_last_call(), vec![]); + + // But we have it in our internal buffer + assert_eq!(OffenceSendQueue::::count(), 2); + + // roll one block forward + roll_next(); + + // now it is in outbox. + assert_eq!( + LocalQueue::get_since_last_call(), + vec![( + 2, + OutgoingMessages::OffenceReportPaged(vec![ + ( + 0, + Offence { + offender: 4, + reporters: vec![], + slash_fraction: Perbill::from_percent(50) + } + ), + ( + 0, + Offence { + offender: 5, + reporters: vec![], + slash_fraction: Perbill::from_percent(50) + } + ) + ]) + )] + ); + }) +} + +#[test] +fn offences_spam_sent_page_by_page() { + ExtBuilder::default().local_queue().build().execute_with(|| { + // flush some relevant data + LocalQueue::flush(); + let _ = session_events_since_last_call(); + + let onchain_batch_size = MaxOffenceBatchSize::get(); + // fill 2.5 pages worth of offecnces all at once + let offence_count = 5 * onchain_batch_size / 2; + + let offences = (0..offence_count) + .map(|i| { + ( + // identification tuple, + (i as AccountId, Default::default()), + // session index + 0, + // time-slot, opaque number, just to make sure we create lots of unique + // offences. + i as u128, + // slash fraction + Perbill::from_percent(50).deconstruct(), + ) + }) + .collect::>(); + assert_ok!(pallet_root_offences::Pallet::::report_offence(RuntimeOrigin::root(), offences)); + + // all offences reported to the offence pallet. + assert_eq!(offence_events_since_last_call().len() as u32, offence_count); + + // offence pallet will try and deduplicate them, but they all have the same time-slot, + // therefore are all reported to ah-client. + assert_eq!(ah_client::OffenceSendQueue::::count(), offence_count); + // 2.5 pages worth of offences + assert_eq!(ah_client::OffenceSendQueue::::pages(), 3); + + // Nothing is in our local (outgoing queue yet) + assert_eq!(LocalQueue::get_since_last_call(), vec![]); + + // roll one block forward, a page is sent. + roll_next(); + + // we have set 1 message in our outbox, which is consisted of a batch of offences. First page is `onchain_batch_size / 2` + assert!(matches!( + &LocalQueue::get_since_last_call()[..], + [(_, OutgoingMessages::OffenceReportPaged(ref offences))] if offences.len() as u32 == onchain_batch_size / 2 + )); + assert_eq!(ah_client::OffenceSendQueue::::count(), 2 * onchain_batch_size); + assert_eq!(ah_client::OffenceSendQueue::::pages(), 2); + + // To spice it up, we simulate 1 failed attempt in the next page as well. This is equivalent to the DMP queue being too busy to receive this message from us. + NextAhDeliveryFails::set(true); + roll_next(); + + // offence queue has not changed, we didn't send anyhting. + assert!(LocalQueue::get_since_last_call().is_empty()); + assert_eq!(ah_client::OffenceSendQueue::::count(), 2 * onchain_batch_size); + assert_eq!(ah_client::OffenceSendQueue::::pages(), 2); + + // even to warn us is emitted + assert_eq!(ah_client_events_since_last_call(), vec![ah_client::Event::Unexpected(UnexpectedKind::OffenceSendFailed)]); + + // Now let's make real progress again + roll_next(); + assert!(matches!( + &LocalQueue::get_since_last_call()[..], + [(_, OutgoingMessages::OffenceReportPaged(ref offences))] if offences.len() as u32 == onchain_batch_size + )); + assert_eq!(ah_client::OffenceSendQueue::::count(), onchain_batch_size); + assert_eq!(ah_client::OffenceSendQueue::::pages(), 1); + + + roll_next(); + assert!(matches!( + &LocalQueue::get_since_last_call()[..], + [(_, OutgoingMessages::OffenceReportPaged(ref offences))] if offences.len() as u32 == onchain_batch_size + )); + assert_eq!(ah_client::OffenceSendQueue::::count(), 0); + assert_eq!(ah_client::OffenceSendQueue::::pages(), 0); + + // nothing more is set to outbox. + roll_next(); + assert!(LocalQueue::get_since_last_call().is_empty()); + + }) +} + #[test] fn on_offence_non_validator_and_active() { ExtBuilder::default() @@ -507,26 +771,32 @@ fn on_offence_non_validator_and_active() { None )); + // roll 1 block to process it + roll_next(); + // we nonetheless have sent the offence report to AH assert_eq!( LocalQueue::get_since_last_call(), vec![( - 150, - OutgoingMessages::OffenceReport( - 5, - vec![ + 151, + OutgoingMessages::OffenceReportPaged(vec![ + ( + 5, Offence { offender: 4, reporters: vec![], slash_fraction: Perbill::from_percent(50) - }, + } + ), + ( + 5, Offence { offender: 5, reporters: vec![], slash_fraction: Perbill::from_percent(50) } - ] - ) + ) + ]) )] ); @@ -566,19 +836,22 @@ fn wont_disable_past_session_offence() { Some(5) )); + // roll 1 block to process it + roll_next(); + // we nonetheless have sent the offence report to AH assert_eq!( LocalQueue::get_since_last_call(), vec![( - 240, - OutgoingMessages::OffenceReport( + 241, + OutgoingMessages::OffenceReportPaged(vec![( 5, - vec![Offence { + Offence { offender: 1, reporters: vec![], slash_fraction: Perbill::from_percent(50) - },] - ) + } + )]) )] ); @@ -609,23 +882,7 @@ fn on_offence_disable_and_re_enabled_next_set() { None )); - // offence dispatched to AH - assert_eq!( - LocalQueue::get_since_last_call(), - vec![( - 150, - OutgoingMessages::OffenceReport( - 5, - vec![Offence { - offender: 4, - reporters: vec![], - slash_fraction: Perbill::from_percent(50) - },] - ) - )] - ); - - // session disables 4 + // session disables 4 immediately assert_eq!( session_events_since_last_call(), vec![pallet_session::Event::ValidatorDisabled { validator: 4 }] @@ -638,6 +895,25 @@ fn on_offence_disable_and_re_enabled_next_set() { vec![3] ); + // roll 1 block to process it. + roll_next(); + + // offence dispatched to AH + assert_eq!( + LocalQueue::get_since_last_call(), + vec![( + 151, + OutgoingMessages::OffenceReportPaged(vec![( + 5, + Offence { + offender: 4, + reporters: vec![], + slash_fraction: Perbill::from_percent(50) + } + )]) + )] + ); + // now receive the same validator set, again receive_validator_set_at(6, 2, vec![1, 2, 3, 4], true); assert_eq!(pallet_session::CurrentIndex::::get(), 8); diff --git a/substrate/frame/staking-async/rc-client/src/lib.rs b/substrate/frame/staking-async/rc-client/src/lib.rs index a76aab2c593aa..49cd8437e8ffc 100644 --- a/substrate/frame/staking-async/rc-client/src/lib.rs +++ b/substrate/frame/staking-async/rc-client/src/lib.rs @@ -42,7 +42,7 @@ //! > Note that in the code, due to historical reasons, planning of a new session is called //! > `new_session`. //! -//! * [`Call::relay_new_offence`]: A report of one or more offences on the relay chain. +//! * [`Call::relay_new_offence_paged`]: A report of one or more offences on the relay chain. //! //! ## Outgoing Messages //! @@ -118,8 +118,8 @@ extern crate alloc; use alloc::{vec, vec::Vec}; use core::fmt::Display; -use frame_support::pallet_prelude::*; -use sp_runtime::{traits::Convert, Perbill}; +use frame_support::{pallet_prelude::*, storage::transactional::with_transaction_opaque_err}; +use sp_runtime::{traits::Convert, Perbill, TransactionOutcome}; use sp_staking::SessionIndex; use xcm::latest::{send_xcm, Location, SendError, SendXcm, Xcm}; @@ -151,7 +151,55 @@ pub trait SendToRelayChain { type AccountId; /// Send a new validator set report to relay chain. - fn validator_set(report: ValidatorSetReport); + #[allow(clippy::result_unit_err)] + fn validator_set(report: ValidatorSetReport) -> Result<(), ()>; +} + +#[cfg(feature = "std")] +impl SendToRelayChain for () { + type AccountId = u64; + fn validator_set(_report: ValidatorSetReport) -> Result<(), ()> { + unimplemented!(); + } +} + +/// The interface to communicate to asset hub. +/// +/// This trait should only encapsulate our outgoing communications. Any incoming message is handled +/// with `Call`s. +/// +/// In a real runtime, this is implemented via XCM calls, much like how the coretime pallet works. +/// In a test runtime, it can be wired to direct function call. +pub trait SendToAssetHub { + /// The validator account ids. + type AccountId; + + /// Report a session change to AssetHub. + /// + /// Returning `Err(())` means the DMP queue is full, and you should try again in the next block. + #[allow(clippy::result_unit_err)] + fn relay_session_report(session_report: SessionReport) -> Result<(), ()>; + + #[allow(clippy::result_unit_err)] + fn relay_new_offence_paged( + offences: Vec<(SessionIndex, Offence)>, + ) -> Result<(), ()>; +} + +/// A no-op implementation of [`SendToAssetHub`]. +#[cfg(feature = "std")] +impl SendToAssetHub for () { + type AccountId = u64; + + fn relay_session_report(_session_report: SessionReport) -> Result<(), ()> { + unimplemented!(); + } + + fn relay_new_offence_paged( + _offences: Vec<(SessionIndex, Offence)>, + ) -> Result<(), ()> { + unimplemented!() + } } #[derive(Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, TypeInfo)] @@ -370,6 +418,26 @@ pub struct XCMSender( core::marker::PhantomData<(Sender, Destination, Message, ToXcm)>, ); +impl XCMSender +where + Sender: SendXcm, + Destination: Get, + Message: Clone + Encode, + ToXcm: Convert>, +{ + /// Send the message single-shot; no splitting. + /// + /// Useful for sending messages that are already paged/chunked, so we are sure that they fit in + /// one message. + #[allow(clippy::result_unit_err)] + pub fn send(message: Message) -> Result<(), ()> { + let xcm = ToXcm::convert(message); + let dest = Destination::get(); + // send_xcm already calls validate internally + send_xcm::(dest, xcm).map(|_| ()).map_err(|_| ()) + } +} + impl XCMSender where Sender: SendXcm, @@ -381,30 +449,38 @@ where /// split it into smaller pieces if XCM validation fails with `ExceedsMaxMessageSize`. It will /// fail on other errors. /// - /// It will only emit some logs, and has no return value. This is used in the runtime, so it - /// cannot deposit any events at this level. - pub fn split_then_send(message: Message, maybe_max_steps: Option) { + /// Returns `Ok()` if the message was sent using `XCM`, potentially with splitting up to + /// `maybe_max_step` times, `Err(())` otherwise. + #[deprecated( + note = "all staking related VMP messages should fit the single message limits. Should not be used." + )] + #[allow(clippy::result_unit_err)] + pub fn split_then_send(message: Message, maybe_max_steps: Option) -> Result<(), ()> { let message_type_name = core::any::type_name::(); let dest = Destination::get(); - let xcms = match Self::prepare(message, maybe_max_steps) { - Ok(x) => x, - Err(e) => { - log::error!(target: "runtime::rc-client", "📨 Failed to split message {}: {:?}", message_type_name, e); - return; - }, - }; - - for (idx, xcm) in xcms.into_iter().enumerate() { - log::debug!(target: "runtime::rc-client", "📨 sending {} message index {}, size: {:?}", message_type_name, idx, xcm.encoded_size()); - let result = send_xcm::(dest.clone(), xcm); - match result { - Ok(_) => { - log::debug!(target: "runtime::rc-client", "📨 Successfully sent {} message part {} to relay chain", message_type_name, idx) - }, - Err(e) => { - log::error!(target: "runtime::rc-client", "📨 Failed to send {} message to relay chain: {:?}", message_type_name, e) - }, + let xcms = Self::prepare(message, maybe_max_steps).map_err(|e| { + log::error!(target: "runtime::staking-async::rc-client", "📨 Failed to split message {}: {:?}", message_type_name, e); + })?; + + match with_transaction_opaque_err(|| { + let all_sent = xcms.into_iter().enumerate().try_for_each(|(idx, xcm)| { + log::debug!(target: "runtime::staking-async::rc-client", "📨 sending {} message index {}, size: {:?}", message_type_name, idx, xcm.encoded_size()); + send_xcm::(dest.clone(), xcm).map(|_| { + log::debug!(target: "runtime::staking-async::rc-client", "📨 Successfully sent {} message part {} to relay chain", message_type_name, idx); + }).inspect_err(|e| { + log::error!(target: "runtime::staking-async::rc-client", "📨 Failed to send {} message to relay chain: {:?}", message_type_name, e); + }) + }); + + match all_sent { + Ok(()) => TransactionOutcome::Commit(Ok(())), + Err(send_err) => TransactionOutcome::Rollback(Err(send_err)), } + }) { + // just like https://doc.rust-lang.org/src/core/result.rs.html#1746 which I cannot use yet because not in 1.89 + Ok(inner) => inner.map_err(|_| ()), + // unreachable; `with_transaction_opaque_err` always returns `Ok(inner)` + Err(_) => Err(()), } } @@ -491,10 +567,7 @@ pub trait AHStakingInterface { /// /// This will return the worst case estimate of the weight. The actual execution will return the /// accurate amount. - fn weigh_on_new_offences( - slash_session: SessionIndex, - offences: &[Offence], - ) -> Weight; + fn weigh_on_new_offences(offence_count: u32) -> Weight; } /// The communication trait of `pallet-staking-async` -> `pallet-staking-async-rc-client`. @@ -521,7 +594,7 @@ pub struct Offence { pub mod pallet { use super::*; use alloc::vec; - use frame_system::pallet_prelude::*; + use frame_system::pallet_prelude::{BlockNumberFor, *}; /// The in-code storage version. const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); @@ -545,10 +618,48 @@ pub mod pallet { #[pallet::storage] pub type LastSessionReportEndingIndex = StorageValue<_, SessionIndex, OptionQuery>; + /// A validator set that is outgoing, and should be sent. + /// + /// This will be attempted to be sent, possibly on every `on_initialize` call, until it is sent, + /// or the second value reaches zero, at which point we drop it. + #[pallet::storage] + // TODO: for now we know this ValidatorSetReport is at most validator-count * 32, and we don't + // need its MEL critically. + #[pallet::unbounded] + pub type OutgoingValidatorSet = + StorageValue<_, (ValidatorSetReport, u32), OptionQuery>; + #[pallet::pallet] #[pallet::storage_version(STORAGE_VERSION)] pub struct Pallet(_); + #[pallet::hooks] + impl Hooks> for Pallet { + fn on_initialize(_: BlockNumberFor) -> Weight { + if let Some((report, retries_left)) = OutgoingValidatorSet::::take() { + match T::SendToRelayChain::validator_set(report.clone()) { + Ok(()) => { + // report was sent, all good, it is already deleted. + }, + Err(()) => { + log!(error, "Failed to send validator set report to relay chain"); + Self::deposit_event(Event::::Unexpected( + UnexpectedKind::ValidatorSetSendFailed, + )); + if let Some(new_retries_left) = retries_left.checked_sub(One::one()) { + OutgoingValidatorSet::::put((report, new_retries_left)) + } else { + Self::deposit_event(Event::::Unexpected( + UnexpectedKind::ValidatorSetDropped, + )); + } + }, + } + } + T::DbWeight::get().reads_writes(1, 1) + } + } + #[pallet::config] pub trait Config: frame_system::Config { /// An origin type that allows us to be sure a call is being dispatched by the relay chain. @@ -561,6 +672,11 @@ pub mod pallet { /// Our communication handle to the relay chain. type SendToRelayChain: SendToRelayChain; + + /// Maximum number of times that we retry sending a validator set to RC, after which, if + /// sending still fails, we emit an [`UnexpectedKind::ValidatorSetDropped`] event and drop + /// it. + type MaxValidatorSetRetries: Get; } #[pallet::event] @@ -596,6 +712,12 @@ pub mod pallet { /// A session in the past was received. This will not raise any errors, just emit an event /// and stop processing the report. SessionAlreadyProcessed, + /// A validator set failed to be sent to RC. + /// + /// We will store, and retry it for [`Config::MaxValidatorSetRetries`] future blocks. + ValidatorSetSendFailed, + /// A validator set was dropped. + ValidatorSetDropped, } impl RcClientInterface for Pallet { @@ -607,7 +729,8 @@ pub mod pallet { prune_up_tp: Option, ) { let report = ValidatorSetReport::new_terminal(new_validator_set, id, prune_up_tp); - T::SendToRelayChain::validator_set(report); + // just store the report to be outgoing, it will be sent in the next on-init. + OutgoingValidatorSet::::put((report, T::MaxValidatorSetRetries::get())); } } @@ -693,27 +816,33 @@ pub mod pallet { } } - /// Called to report one or more new offenses on the relay chain. #[pallet::call_index(1)] #[pallet::weight( - // events are free - // origin check is negligible. - T::AHStakingInterface::weigh_on_new_offences(*slash_session, offences) + T::AHStakingInterface::weigh_on_new_offences(offences.len() as u32) )] - pub fn relay_new_offence( + pub fn relay_new_offence_paged( origin: OriginFor, - slash_session: SessionIndex, - offences: Vec>, + offences: Vec<(SessionIndex, Offence)>, ) -> DispatchResultWithPostInfo { - log!(info, "Received new offence at slash_session: {:?}", slash_session); T::RelayChainOrigin::ensure_origin_or_root(origin)?; + log!(info, "Received new page of {} offences", offences.len()); - Self::deposit_event(Event::OffenceReceived { - slash_session, - offences_count: offences.len() as u32, - }); + let mut offences_by_session = + alloc::collections::BTreeMap::>>::new(); + for (session_index, offence) in offences { + offences_by_session.entry(session_index).or_default().push(offence); + } + + let mut weight: Weight = Default::default(); + for (slash_session, offences) in offences_by_session { + Self::deposit_event(Event::OffenceReceived { + slash_session, + offences_count: offences.len() as u32, + }); + let new_weight = T::AHStakingInterface::on_new_offences(slash_session, offences); + weight.saturating_accrue(new_weight) + } - let weight = T::AHStakingInterface::on_new_offences(slash_session, offences); Ok(Some(weight).into()) } } diff --git a/substrate/frame/staking-async/runtimes/papi-tests/src/cmd.ts b/substrate/frame/staking-async/runtimes/papi-tests/src/cmd.ts index ff907ff6d519e..200675d7ac895 100644 --- a/substrate/frame/staking-async/runtimes/papi-tests/src/cmd.ts +++ b/substrate/frame/staking-async/runtimes/papi-tests/src/cmd.ts @@ -163,8 +163,8 @@ function cmd(cmd: string, args: string[], stdio: string = "ignore"): void { logger.info(`Running command: ${cmd} ${args.join(" ")}`); // @ts-ignore const result = spawnSync(cmd, args, { stdio: stdio, cwd: __dirname }); - if (result.error) { - logger.error(`Error running command: ${cmd} ${args.join(" ")}`, result.error); - throw result.error; + if (result.error || result.status !== 0) { + logger.error(`Error running command: ${cmd} ${args.join(" ")}`); + logger.error(`Status: ${result.status}`); } } diff --git a/substrate/frame/staking-async/runtimes/papi-tests/src/index.ts b/substrate/frame/staking-async/runtimes/papi-tests/src/index.ts index 44b20c121eb50..6ad703f04a7b7 100644 --- a/substrate/frame/staking-async/runtimes/papi-tests/src/index.ts +++ b/substrate/frame/staking-async/runtimes/papi-tests/src/index.ts @@ -1,5 +1,6 @@ import { rcPresetFor, runPreset } from "./cmd"; import { logger } from "./utils"; +import { monitorVmpQueues } from "./vmp-monitor"; import { Command } from "commander"; export enum Presets { @@ -30,5 +31,37 @@ if (require.main === module) { runPreset(paraPreset); }); + program + .command("monitor-vmp") + .description("Monitor VMP (Vertical Message Passing) - both DMP and UMP queues") + .option( + "--relay-port ", + "Relay chain WebSocket port", + "9944" + ) + .option( + "--para-port ", + "Parachain WebSocket port (optional)", + "9946" + ) + .option( + "-r, --refresh ", + "Refresh interval in seconds", + "3" + ) + .option( + "--para-id ", + "Specific parachain ID to monitor (default: all)" + ) + .action(async (options) => { + const { relayPort, paraPort, refresh, paraId } = options; + await monitorVmpQueues({ + relayPort: parseInt(relayPort), + paraPort: paraPort ? parseInt(paraPort) : undefined, + refreshInterval: parseInt(refresh), + paraId: paraId ? parseInt(paraId) : undefined + }); + }); + program.parse(process.argv); } diff --git a/substrate/frame/staking-async/runtimes/papi-tests/src/utils.ts b/substrate/frame/staking-async/runtimes/papi-tests/src/utils.ts index e44c130e6b174..4a0db0c822fb8 100644 --- a/substrate/frame/staking-async/runtimes/papi-tests/src/utils.ts +++ b/substrate/frame/staking-async/runtimes/papi-tests/src/utils.ts @@ -6,11 +6,12 @@ import { type PolkadotSigner, type TypedApi, } from "polkadot-api"; +import { fromBufferToBase58 } from "@polkadot-api/substrate-bindings"; import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat"; import { getWsProvider } from "polkadot-api/ws-provider/web"; -import { createLogger, format, log, transports } from "winston"; +import { createLogger, format, transports } from "winston"; import { sr25519CreateDerive } from "@polkadot-labs/hdkd"; -import { DEV_PHRASE, entropyToMiniSecret, mnemonicToEntropy } from "@polkadot-labs/hdkd-helpers"; +import { DEV_PHRASE, entropyToMiniSecret, mnemonicToEntropy, type KeyPair } from "@polkadot-labs/hdkd-helpers"; import { getPolkadotSigner } from "polkadot-api/signer"; export const GlobalTimeout = 30 * 60 * 1000; @@ -27,8 +28,27 @@ export const logger = createLogger({ const miniSecret = entropyToMiniSecret(mnemonicToEntropy(DEV_PHRASE)); const derive = sr25519CreateDerive(miniSecret); const aliceKeyPair = derive("//Alice"); + export const alice = getPolkadotSigner(aliceKeyPair.publicKey, "Sr25519", aliceKeyPair.sign); +export function deriveFrom(s: string, d: string): KeyPair { + const miniSecret = entropyToMiniSecret(mnemonicToEntropy(s)); + const derive = sr25519CreateDerive(miniSecret); + return derive(d); +} + +export function derivePubkeyFrom(d: string): string { + const miniSecret = entropyToMiniSecret(mnemonicToEntropy(DEV_PHRASE)); + const derive = sr25519CreateDerive(miniSecret); + const keyPair = derive(d); + // Convert to SS58 address using Substrate format (42) + return ss58(keyPair.publicKey); +} + +export function ss58(key: Uint8Array): string { + return fromBufferToBase58(42)(key); +} + export type ApiDeclarations = { rcClient: PolkadotClient; paraClient: PolkadotClient; diff --git a/substrate/frame/staking-async/runtimes/papi-tests/src/vmp-monitor.ts b/substrate/frame/staking-async/runtimes/papi-tests/src/vmp-monitor.ts new file mode 100644 index 0000000000000..642d5f6e05923 --- /dev/null +++ b/substrate/frame/staking-async/runtimes/papi-tests/src/vmp-monitor.ts @@ -0,0 +1,502 @@ +/* + * Vertical Message Passing (VMP) Monitor + * + * This tool monitors both Downward Message Passing (DMP) and Upward Message Passing (UMP) queues + * in the Polkadot relay chain and parachains. + * + * ## Message Flow Overview + * + * ### Downward Message Passing (DMP): Relay Chain → Parachain + * + * 1. **Message Creation**: Messages are created on the relay chain (e.g., XCM messages from governance) + * 2. **Queueing**: Messages are stored in `Dmp::DownwardMessageQueues` storage on the relay chain + * - Each parachain has its own queue indexed by ParaId + * - Messages include the actual message bytes and the block number when sent + * 3. **Delivery**: During parachain validation, these messages are included in the parachain's + * inherent data and delivered to the parachain + * 4. **Processing**: The parachain processes these messages in `parachain-system` pallet + * - Messages are passed to the configured `DmpQueue` handler + * - In modern implementations, this is typically the `message-queue` pallet + * 5. **Fee Management**: `Dmp::DeliveryFeeFactor` tracks fee multipliers per parachain + * + * ### Upward Message Passing (UMP): Parachain → Relay Chain + * + * 1. **Message Creation**: Messages are created on the parachain (e.g., XCM messages) + * 2. **Parachain Queueing**: Messages are first stored in `ParachainSystem::PendingUpwardMessages` + * - The parachain tracks bandwidth limits and adjusts fee factors based on queue size + * 3. **Commitment**: In `on_finalize`, pending messages are moved to `ParachainSystem::UpwardMessages` + * - These are included in the parachain block's proof of validity + * 4. **Relay Chain Reception**: When the relay chain validates the parachain block: + * - UMP messages are extracted from the proof + * - Messages are processed by `inclusion` pallet's `receive_upward_messages` + * 5. **Processing**: Messages are enqueued into the `message-queue` pallet with origin `Ump(ParaId)` + * - The message-queue pallet handles actual execution with weight limits + * - Messages can be temporarily or permanently overweight + * + * ## Key Components + * + * ### Relay Chain Pallets + * - `dmp`: Manages downward message queues and delivery fees + * - `inclusion`: Handles parachain block validation and UMP message reception + * - `message-queue`: Generic message queue processor for various origins (UMP, DMP, HRMP) + * + * ### Parachain Pallets + * - `parachain-system`: Manages UMP message sending and DMP message reception + * - `message-queue`: Processes received DMP messages (and other message types) + * + * ## Storage Layout + * + * ### Relay Chain + * - `Dmp::DownwardMessageQueues`: Map> + * - `Dmp::DeliveryFeeFactor`: Map + * - Well-known keys for UMP queue sizes (relay_dispatch_queue_size) + * + * ### Parachain + * - `ParachainSystem::PendingUpwardMessages`: Vec + * - `ParachainSystem::UpwardMessages`: Vec (cleared each block) + * - `ParachainSystem::UpwardDeliveryFeeFactor`: FixedU128 + * + * ## Message Queue Pallet + * + * The `message-queue` pallet is a generic, paginated message processor that: + * - Stores messages in "books" organized by origin (e.g., Ump(ParaId), Dmp) + * - Each book contains pages of messages to handle large message volumes efficiently + * - Processes messages with strict weight limits to ensure block production + * - Handles overweight messages that exceed processing limits + * - Emits events for processed, overweight, and failed messages + * + * ## Bandwidth and Fee Management + * + * - Both DMP and UMP implement dynamic fee mechanisms + * - Fees increase when queues grow large (deterring spam) + * - Fees decrease when queues are small (encouraging usage) + * - Bandwidth limits prevent any single parachain from monopolizing message passing + * + * ## VMP Message Limits and Risk Analysis + * + * There are 4 key categories of limits in the VMP system: + * + * ### 1. Single Message Size Limit + * + * **DMP (Downward):** + * - Enforced at: `polkadot/runtime/parachains/src/dmp.rs:189` in `can_queue_downward_message()` + * - Configuration: `max_downward_message_size` + * - Check: Rejects if `serialized_len > config.max_downward_message_size` + * + * **UMP (Upward):** + * - Parachain enforcement: `cumulus/pallets/parachain-system/src/lib.rs:1665` in `send_upward_message()` + * - Relay validation: `polkadot/runtime/parachains/src/inclusion/mod.rs:967` in `check_upward_messages()` + * - Configuration: `max_upward_message_size` (hard bound: 128KB defined as MAX_UPWARD_MESSAGE_SIZE_BOUND) + * + * ### 2. Queue Total Size (Bytes) + * + * **DMP:** + * - Max capacity: `MAX_POSSIBLE_ALLOCATION / max_downward_message_size` + * - Calculated in: `polkadot/runtime/parachains/src/dmp.rs:318-319` in `dmq_max_length()` + * - Enforced at: `polkadot/runtime/parachains/src/dmp.rs:194` in `can_queue_downward_message()` + * + * **UMP:** + * - Parachain check: `cumulus/pallets/parachain-system/src/lib.rs:369-373` (respects relay's remaining capacity) + * - Relay limit: `max_upward_queue_size` enforced at `polkadot/runtime/parachains/src/inclusion/mod.rs:977-980` + * + * ### 3. Queue Total Count (Messages) + * + * **DMP:** + * - No explicit total message count limit + * - Only implicitly limited by total queue size + * + * **UMP:** + * - Relay limit: `max_upward_queue_count` at `polkadot/runtime/parachains/src/inclusion/mod.rs:958-961` + * - Parachain respects relay's `remaining_count` from `relay_dispatch_queue_remaining_capacity` + * + * ### 4. Per-Block Append Limit + * + * **DMP:** + * - No explicit per-block limit for senders + * - Receivers process up to `processed_downward_messages` per block + * + * **UMP:** + * - Configuration: `max_upward_message_num_per_candidate` + * - Parachain limit: `cumulus/pallets/parachain-system/src/lib.rs:386` in `on_finalize()` + * - Relay validation: `polkadot/runtime/parachains/src/inclusion/mod.rs:949-952` + * - Max bound: 16,384 messages (MAX_UPWARD_MESSAGE_NUM in `polkadot/parachain/src/primitives.rs:436`) + * + * ### Receiver-side Risk: Weight Exhaustion + * + * Both DMP and UMP messages are processed through the message-queue pallet: + * - Weight check: substrate/frame/message-queue/src/lib.rs:1591 in `process_message_payload()` + * - Messages exceeding `overweight_limit` are marked as overweight + * - Configuration: `ServiceWeight` and `IdleMaxServiceWeight` + * - Overweight handling: Permanently overweight messages require manual execution via `execute_overweight()` + * + * **Key Insight**: DMP is less restrictive with only size-based limits, while UMP implements all four types of limits, + * providing more granular control over message flow. + */ + +import { createClient, type PolkadotClient, type TypedApi } from "polkadot-api"; +import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat"; +import { getWsProvider } from "polkadot-api/ws-provider/web"; +import { rc, parachain } from "@polkadot-api/descriptors"; +import { logger } from "./utils"; + +interface MonitorOptions { + relayPort: number; + paraPort?: number; + refreshInterval: number; + paraId?: number; +} + +interface DmpQueueInfo { + paraId: number; + messageCount: number; + totalSize: number; + avgMessageSize: number; + feeFactor: string; + messages: Array<{ + size: number; + sentAt: number; + }>; +} + +interface UmpQueueInfo { + paraId: number; + relayQueueCount: number; + relayQueueSize: number; + pendingCount?: number; + pendingSize?: number; +} + +interface MessageStats { + dmp: { + totalQueues: number; + totalMessages: number; + totalSize: number; + avgMessagesPerQueue: number; + avgSizePerMessage: number; + queues: DmpQueueInfo[]; + }; + ump: { + totalParas: number; + totalMessages: number; + totalSize: number; + queues: UmpQueueInfo[]; + }; +} + +export async function monitorVmpQueues(options: MonitorOptions): Promise { + const relayWsUrl = `ws://127.0.0.1:${options.relayPort}`; + const paraWsUrl = options.paraPort ? `ws://127.0.0.1:${options.paraPort}` : null; + + logger.info(`🚀 Connecting to relay chain at ${relayWsUrl}`); + if (paraWsUrl) { + logger.info(`🚀 Connecting to parachain at ${paraWsUrl}`); + } + logger.info(`📊 Monitoring VMP queues${options.paraId ? ` for parachain ${options.paraId}` : ' for all parachains'}`); + logger.info(`⏱️ Refresh interval: ${options.refreshInterval}s`); + logger.info(""); + + try { + // Connect to relay chain + const relayWsProvider = getWsProvider(relayWsUrl); + const relayClient = createClient(withPolkadotSdkCompat(relayWsProvider)); + const relayApi = relayClient.getTypedApi(rc); + + // Test relay connection + const relayChainSpec = await relayClient.getChainSpecData(); + logger.info(`✅ Connected to relay chain: ${relayChainSpec.name}`); + + // Connect to parachain if port provided + let paraClient: PolkadotClient | null = null; + let paraApi: any | null = null; + if (paraWsUrl) { + const paraWsProvider = getWsProvider(paraWsUrl); + paraClient = createClient(withPolkadotSdkCompat(paraWsProvider)); + // Use parachain descriptor for the parachain API + paraApi = paraClient.getTypedApi(parachain); + + const paraChainSpec = await paraClient.getChainSpecData(); + logger.info(`✅ Connected to parachain: ${paraChainSpec.name}`); + } + + const version = await relayApi.constants.System.Version(); + logger.info(`Relay chain: ${version.spec_name} v${version.spec_version}`); + logger.info(""); + + // Start monitoring loop + while (true) { + try { + await displayMessageStatus(relayApi, paraApi, options.paraId); + } catch (error) { + logger.error("Error fetching message data:", error); + } + + await sleep(options.refreshInterval * 1000); + + // Clear screen for next update + if (process.stdout.isTTY) { + process.stdout.write('\x1Bc'); + } + } + + } catch (error) { + logger.error("Failed to initialize monitoring:", error); + process.exit(1); + } +} + +async function displayMessageStatus(relayApi: TypedApi, paraApi: any | null, specificParaId?: number): Promise { + const timestamp = new Date().toLocaleString(); + + console.log("╔═══════════════════════════════════════════════════════════════╗"); + console.log("║ Vertical Message Passing Monitor ║"); + console.log(`║ Last updated: ${timestamp.padEnd(45)} ║`); + console.log("╚═══════════════════════════════════════════════════════════════╝"); + console.log(); + + try { + const stats = await fetchMessageStats(relayApi, paraApi, specificParaId); + + // Display DMP Statistics + console.log("📥 DMP (Downward Message Passing) Statistics:"); + console.log(` Active Queues: ${stats.dmp.totalQueues}`); + console.log(` Total Messages: ${stats.dmp.totalMessages}`); + console.log(` Total Size: ${formatBytes(stats.dmp.totalSize)}`); + if (stats.dmp.totalQueues > 0) { + console.log(` Avg Messages/Queue: ${stats.dmp.avgMessagesPerQueue.toFixed(1)}`); + } + if (stats.dmp.totalMessages > 0) { + console.log(` Avg Message Size: ${formatBytes(stats.dmp.avgSizePerMessage)}`); + } + console.log(); + + // Display UMP Statistics + console.log("📤 UMP (Upward Message Passing) Statistics:"); + console.log(` Active Paras: ${stats.ump.totalParas}`); + console.log(` Total Messages: ${stats.ump.totalMessages}`); + console.log(` Total Size: ${formatBytes(stats.ump.totalSize)}`); + console.log(); + + // Display DMP queue details + if (stats.dmp.queues.length > 0) { + console.log("📋 DMP Queue Details:"); + console.log("┌─────────────┬───────────┬─────────────┬─────────────┬─────────────┐"); + console.log("│ Para ID │ Messages │ Total Size │ Avg Size │ Fee Factor │"); + console.log("├─────────────┼───────────┼─────────────┼─────────────┼─────────────┤"); + + for (const queue of stats.dmp.queues.slice(0, 10)) { + console.log( + `│ ${queue.paraId.toString().padEnd(11)} │ ${queue.messageCount.toString().padEnd(9)} │ ${formatBytes(queue.totalSize).padEnd(11)} │ ${formatBytes(queue.avgMessageSize).padEnd(11)} │ ${queue.feeFactor.padEnd(11)} │` + ); + } + + console.log("└─────────────┴───────────┴─────────────┴─────────────┴─────────────┘"); + + if (stats.dmp.queues.length > 10) { + console.log(`... and ${stats.dmp.queues.length - 10} more DMP queues`); + } + console.log(); + } + + // Display UMP queue details + if (stats.ump.queues.length > 0) { + console.log("📋 UMP Queue Details:"); + console.log("┌─────────────┬─────────────┬─────────────┬──────────────┬──────────────┐"); + console.log("│ Para ID │ Relay Msgs │ Relay Size │ Pending Msgs │ Pending Size │"); + console.log("├─────────────┼─────────────┼─────────────┼──────────────┼──────────────┤"); + + for (const queue of stats.ump.queues.slice(0, 10)) { + const pendingStr = queue.pendingCount !== undefined ? queue.pendingCount.toString() : "N/A"; + const pendingSizeStr = queue.pendingSize !== undefined ? formatBytes(queue.pendingSize) : "N/A"; + console.log( + `│ ${queue.paraId.toString().padEnd(11)} │ ${queue.relayQueueCount.toString().padEnd(11)} │ ${formatBytes(queue.relayQueueSize).padEnd(11)} │ ${pendingStr.padEnd(12)} │ ${pendingSizeStr.padEnd(12)} │` + ); + } + + console.log("└─────────────┴─────────────┴─────────────┴──────────────┴──────────────┘"); + + if (stats.ump.queues.length > 10) { + console.log(`... and ${stats.ump.queues.length - 10} more UMP queues`); + } + console.log(); + } + + // Show most active queues + const topDmpQueues = stats.dmp.queues + .filter(q => q.messages.length > 0) + .sort((a, b) => b.messageCount - a.messageCount) + .slice(0, 3); + + if (topDmpQueues.length > 0) { + console.log("🔥 Most Active DMP Queues:"); + for (const queue of topDmpQueues) { + console.log(` Para ${queue.paraId}: ${queue.messageCount} messages, latest at block ${Math.max(...queue.messages.map(m => m.sentAt))}`); + } + console.log(); + } + + // Warning thresholds + const totalMessages = stats.dmp.totalMessages + stats.ump.totalMessages; + const totalSize = stats.dmp.totalSize + stats.ump.totalSize; + + if (totalMessages > 1000) { + console.log("⚠️ WARNING: High message count detected!"); + } + if (totalSize > 10 * 1024 * 1024) { // 10MB + console.log("⚠️ WARNING: High memory usage detected!"); + } + + // Note about parachain connection + if (!paraApi && specificParaId) { + console.log("ℹ️ Note: Connect to parachain with --para-port to see pending UMP messages"); + } + + } catch (error) { + console.log("❌ Error fetching message statistics:"); + console.log(` ${error}`); + } + + console.log(); + console.log("Press Ctrl+C to stop monitoring"); + console.log(); +} + +async function fetchMessageStats(relayApi: TypedApi, paraApi: any | null, specificParaId?: number): Promise { + // Fetch DMP stats from relay chain + const [downwardMessageQueues, deliveryFeeFactors] = await Promise.all([ + relayApi.query.Dmp.DownwardMessageQueues.getEntries(), + relayApi.query.Dmp.DeliveryFeeFactor.getEntries() + ]); + + const dmpQueues: DmpQueueInfo[] = []; + let dmpTotalMessages = 0; + let dmpTotalSize = 0; + + // Process DMP queues + for (const { keyArgs: [paraId], value: messages } of downwardMessageQueues) { + if (specificParaId !== undefined && paraId !== specificParaId) { + continue; + } + + const messageCount = messages.length; + if (messageCount === 0) continue; + + const messageSizes = messages.map((msg) => { + return msg.msg.asBytes().length + }); + + const queueTotalSize = messageSizes.reduce((sum: number, size: number) => sum + size, 0); + const avgMessageSize = messageCount > 0 ? queueTotalSize / messageCount : 0; + + const feeFactorEntry = deliveryFeeFactors.find(entry => entry.keyArgs[0] === paraId); + const feeFactorRaw = feeFactorEntry?.value || 1_000_000_000_000_000_000n; + const feeFactorValue = typeof feeFactorRaw === 'bigint' ? + Number(feeFactorRaw) / 1_000_000_000_000_000_000 : + typeof feeFactorRaw === 'number' ? + feeFactorRaw / 1_000_000_000_000_000_000 : + 1.0; + const feeFactor = feeFactorValue.toFixed(6); + + dmpQueues.push({ + paraId, + messageCount, + totalSize: queueTotalSize, + avgMessageSize, + feeFactor, + messages: messages.map((msg: any, idx: number) => ({ + size: messageSizes[idx]!, + sentAt: msg.sent_at || 0 + })) + }); + + dmpTotalMessages += messageCount; + dmpTotalSize += queueTotalSize; + } + + // Sort DMP queues by message count + dmpQueues.sort((a, b) => b.messageCount - a.messageCount); + + // Fetch UMP stats + const umpQueues: UmpQueueInfo[] = []; + let umpTotalMessages = 0; + let umpTotalSize = 0; + + // Only check UMP for specified paraId when monitoring a specific parachain + const paraIds = specificParaId ? [specificParaId] : []; + + for (const paraId of paraIds) { + try { + // Try to get the relay dispatch queue size from well-known key + // This is stored by the inclusion pallet when processing UMP messages + const wellKnownKey = `0x` + + `3a6865617070616765735f73746f726167653a` + // :heappages_storage: + `0000` + // twox128("Parachains") + `0000` + // twox128("RelayDispatchQueueSize") + `0000` + // twox64(paraId) - simplified, would need proper encoding + paraId.toString(16).padStart(8, '0'); + + // For now, we'll check if the para has any activity in message queue + // This is a simplified approach - in production you'd query the actual storage + const umpQueueInfo: UmpQueueInfo = { + paraId, + relayQueueCount: 0, + relayQueueSize: 0 + }; + + // If we have parachain connection and it matches our paraId, get pending messages + if (paraApi && paraId === specificParaId) { + try { + const pendingMessages = await paraApi.query.ParachainSystem.PendingUpwardMessages(); + if (pendingMessages) { + umpQueueInfo.pendingCount = pendingMessages.length; + umpQueueInfo.pendingSize = pendingMessages.reduce((sum: number, msg: any) => { + return sum + (Array.isArray(msg) ? msg.length : 0); + }, 0); + } + } catch (error) { + // Parachain might not have this storage item + } + } + + // Only add if there's any activity + if (umpQueueInfo.relayQueueCount > 0 || umpQueueInfo.pendingCount) { + umpQueues.push(umpQueueInfo); + umpTotalMessages += umpQueueInfo.relayQueueCount + (umpQueueInfo.pendingCount || 0); + umpTotalSize += umpQueueInfo.relayQueueSize + (umpQueueInfo.pendingSize || 0); + } + + } catch (error) { + // Continue with next para if this one fails + } + } + + return { + dmp: { + totalQueues: dmpQueues.length, + totalMessages: dmpTotalMessages, + totalSize: dmpTotalSize, + avgMessagesPerQueue: dmpQueues.length > 0 ? dmpTotalMessages / dmpQueues.length : 0, + avgSizePerMessage: dmpTotalMessages > 0 ? dmpTotalSize / dmpTotalMessages : 0, + queues: dmpQueues + }, + ump: { + totalParas: umpQueues.length, + totalMessages: umpTotalMessages, + totalSize: umpTotalSize, + queues: umpQueues + } + }; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/substrate/frame/staking-async/runtimes/papi-tests/tests/prune-full-era.test.ts b/substrate/frame/staking-async/runtimes/papi-tests/tests/prune-full-era.test.ts index 70b786d12ed05..7a8f3ffba3967 100644 --- a/substrate/frame/staking-async/runtimes/papi-tests/tests/prune-full-era.test.ts +++ b/substrate/frame/staking-async/runtimes/papi-tests/tests/prune-full-era.test.ts @@ -1 +1,52 @@ -// TODO +import { test, expect } from "bun:test"; +import { Presets } from "../src"; +import { runPresetUntilLaunched, spawnMiner } from "../src/cmd"; +import { Chain, EventOutcome, Observe, runTest, TestCase } from "../src/test-case"; +import { getApis, GlobalTimeout, logger, nullifyUnsigned } from "../src/utils"; +import { commonSignedSteps } from "./common"; + +const PRESET: Presets = Presets.FakeDot; + +test( + `pruning era with signed (full solution) on ${PRESET}`, + async () => { + const { killZn, paraLog } = await runPresetUntilLaunched(PRESET); + const apis = await getApis(); + const killMiner = await spawnMiner(); + + // This test has no real assertions. Change the `HistoryDepth` to 1 in the runtime, run it, + // and observe the logs and PoV sizes. + const steps = [ + // first relay session change at block 11 + Observe.on(Chain.Relay, "Session", "NewSession").byBlock(11) + .onPass(() => { + nullifyUnsigned(apis.paraApi).then((ok) => { + logger.verbose("Nullified signed phase:", ok); + }); + }), + Observe.on(Chain.Parachain, "Staking", "EraPruned") + .withDataCheck((x) => x.index == 0), + Observe.on(Chain.Parachain, "Staking", "EraPruned") + .withDataCheck((x) => x.index == 1), + // Observe.on(Chain.Parachain, "Staking", "EraPruned") + // .withDataCheck((x) => x.index == 2), + // Observe.on(Chain.Parachain, "Staking", "EraPruned") + // .withDataCheck((x) => x.index == 3), + // Observe.on(Chain.Parachain, "Staking", "EraPruned") + // .withDataCheck((x) => x.index == 4), + ].map((s) => s.build()) + + const testCase = new TestCase( + steps, + true, + () => { + killMiner(); + killZn(); + } + ); + + const outcome = await runTest(testCase, apis, paraLog); + expect(outcome).toEqual(EventOutcome.Done); + }, + { timeout: GlobalTimeout * 10 } +); diff --git a/substrate/frame/staking-async/runtimes/papi-tests/tests/slashing-spam.test.ts b/substrate/frame/staking-async/runtimes/papi-tests/tests/slashing-spam.test.ts new file mode 100644 index 0000000000000..7ddffa960c054 --- /dev/null +++ b/substrate/frame/staking-async/runtimes/papi-tests/tests/slashing-spam.test.ts @@ -0,0 +1,97 @@ +import { test, expect } from "bun:test"; +import { Presets } from "../src"; +import { runPresetUntilLaunched } from "../src/cmd"; +import { Chain, EventOutcome, Observe, runTest, TestCase } from "../src/test-case"; +import { alice, getApis, GlobalTimeout, logger, nullifySigned, aliceStash, derivePubkeyFrom, ss58 } from "../src/utils"; + +const PRESET: Presets = Presets.RealS; + +test( + `slashing spam test on ${PRESET}`, + async () => { + const { killZn, paraLog } = await runPresetUntilLaunched(PRESET); + const apis = await getApis(); + // total number of offences to send. + const target = 1000; + // the size of each batch. + const batchSize = 100; + const numBatches = Math.ceil(target / batchSize); + let sent = 0; + let received = 0; + // onchain-page-size for offence queueing in RC is 50, so we expect 20 pages for 1000 offences. + + const steps = [ + // first relay session change at block 11, just a sanity check + Observe.on(Chain.Relay, "Session", "NewSession") + .byBlock(11) + .onPass(async () => { + logger.info(`Submitting ${target} offences in batches of ${batchSize}`); + + // Calculate number of batches needed + + let nonce = await apis.rcApi.apis.AccountNonceApi.account_nonce(ss58(alice.publicKey)); + logger.info(`Alice nonce at start: ${nonce}`); + + for (let batchIndex = 0; batchIndex < numBatches; batchIndex++) { + const start = batchIndex * batchSize; + const end = Math.min(start + batchSize, target); + const currentBatchSize = end - start; + + logger.info(`Processing batch ${batchIndex + 1}/${numBatches}: offences ${start} to ${end - 1}`); + + // Create batch of offence calls + const offenceCalls = Array.from({ length: currentBatchSize }, (_, i) => { + const offenceIndex = start + i; + logger.debug(`Preparing offence ${offenceIndex}: ${derivePubkeyFrom(`//${offenceIndex}`)}`); + + return apis.rcApi.tx.RootOffences.report_offence({ + offences: [[ + [derivePubkeyFrom(`//${offenceIndex}`), { total: BigInt(0), own: BigInt(0), others: [] }], + 0, // session index + BigInt(offenceIndex), // time slot, each being unique + 100000000 // slash ppm + ]] + }).decodedCall; + }); + + // Submit this batch as a single transaction + try { + const batchCall = apis.rcApi.tx.Utility.force_batch({ calls: offenceCalls }).decodedCall; + const result = apis.rcApi.tx.Sudo.sudo({ call: batchCall }) + .signAndSubmit(alice, { at: "best", nonce: nonce }); + + logger.info(`Batch ${batchIndex + 1} submitted`); + nonce += 1; + sent += currentBatchSize; + + } catch (error) { + logger.error(`Batch ${batchIndex + 1} failed:`, error); + // Continue with next batch even if this one fails + } + } + }), + + // in the meantime, we expect to see on the AH side: + ...Array.from({ length: 20 }, (_, __) => + Observe.on(Chain.Parachain, "StakingRcClient", "OffenceReceived").withDataCheck((x) => { + received += x.offences_count; + return true + }), + ), + ]; + + const testCase = new TestCase( + steps.map((s) => s.build()), + true, + () => { + logger.info(`Test completed. Created ${sent} offences, processed ${received} in parachain`); + killZn(); + } + ); + + const outcome = await runTest(testCase, apis, paraLog); + expect(outcome).toEqual(EventOutcome.Done); + expect(sent).toEqual(received); + }, + { timeout: GlobalTimeout * 2 } // Double timeout for this complex test +); diff --git a/substrate/frame/staking-async/runtimes/papi-tests/tests/vmp-force-splitting.test.ts b/substrate/frame/staking-async/runtimes/papi-tests/tests/vmp-force-splitting.test.ts new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/substrate/frame/staking-async/runtimes/papi-tests/tests/vmp-spamming.test.ts b/substrate/frame/staking-async/runtimes/papi-tests/tests/vmp-spamming.test.ts new file mode 100644 index 0000000000000..5184baa966b02 --- /dev/null +++ b/substrate/frame/staking-async/runtimes/papi-tests/tests/vmp-spamming.test.ts @@ -0,0 +1,225 @@ +import { test, expect } from "bun:test"; +import { Presets } from "../src"; +import { runPresetUntilLaunched } from "../src/cmd"; +import { Chain, EventOutcome, Observe, runTest, TestCase } from "../src/test-case"; +import { alice, aliceStash, deriveFrom, getApis, GlobalTimeout, logger, safeJsonStringify, ss58, type ApiDeclarations } from "../src/utils"; +import { DEV_PHRASE } from "@polkadot-labs/hdkd-helpers"; +import { FixedSizeBinary, type PolkadotSigner, type TxCall, type TxCallData, type TypedApi } from "polkadot-api"; +import { parachain, rc } from "@polkadot-api/descriptors"; + +const PRESET: Presets = Presets.FakeDev; + +async function sendUp(api: TypedApi, count: number) { + const calls: TxCallData[] = []; + const ed = await api.constants.Balances.ExistentialDeposit(); + for (let i = 0; i < count; i++) { + const account = deriveFrom(DEV_PHRASE, `//up_${i}`); + const endowment = BigInt(1000) * ed; + const teleport = endowment / BigInt(10); + + const forceSetBalance = api.tx.Balances.force_set_balance({ + new_free: endowment, + who: { type: "Id", value: ss58(account.publicKey) } + }); + + const xcm = api.tx.PolkadotXcm.teleport_assets({ + dest: { + type: "V5", + value: { + parents: 1, + interior: { + type: "Here", + value: undefined + } + } + }, + beneficiary: { + type: "V5", + value: { + parents: 0, + interior: { + type: "X1", + value: { + type: "AccountId32", + value: { id: new FixedSizeBinary(account.publicKey) } + } + } + } + }, + assets: { + type: "V5", + value: [ + { + id: { + parents: 0, + interior: { + type: "Here", + value: undefined + } + }, + fun: { + type: "Fungible", + "value": teleport + } + } + ] + }, + fee_asset_item: 0, + }) + const dispatchAs = api.tx.Utility.dispatch_as({ + as_origin: { type: "system", value: { type: "Signed", value: ss58(account.publicKey) } }, + call: xcm.decodedCall + }); + + calls.push(forceSetBalance.decodedCall); + calls.push(dispatchAs.decodedCall); + } + + const finalBatch = api.tx.Utility.batch_all({ calls }); + const finalSudo = api.tx.Sudo.sudo({ call: finalBatch.decodedCall }); + try { + const res = await finalSudo.signAndSubmit(alice, { at: "best" }); + let success = 0; + let failure = 0; + res.events.forEach((e) => { + logger.debug(safeJsonStringify(e.value)); + if (e.value.type === "DispatchedAs") { + // @ts-ignore + if (e.value.value.result.success) { + success += 1 + } else { + failure += 1 + } + } + }); + logger.info(`Sent ${count} upward messages, intercepted ${success + failure} events, ${success} succeeded, ${failure} failed`); + } catch(e) { + logger.warn(`Error sending upward messages: ${e}`); + } +} + +async function sendDown(api: TypedApi, count: number) { + const calls: TxCallData[] = []; + const ed = await api.constants.Balances.ExistentialDeposit(); + for (let i = 0; i < count; i++) { + const account = deriveFrom(DEV_PHRASE, `//down_${i}`) + const endowment = BigInt(1000) * ed; + const teleport = endowment / BigInt(10); + + const forceSetBalance = api.tx.Balances.force_set_balance({ + new_free: endowment, + who: { type: "Id", value: ss58(account.publicKey) } + }) + + const xcm = api.tx.XcmPallet.teleport_assets({ + dest: { + type: "V5", + value: { + parents: 0, + interior: { + type: "X1", + value: { type: "Parachain", value: 1100 } + } + } + }, + beneficiary: { + type: "V5", + value: { + parents: 0, + interior: { + type: "X1", + value: { + type: "AccountId32", + value: { id: new FixedSizeBinary(account.publicKey) } + } + } + } + }, + assets: { + type: "V5", + value: [ + { + id: { + parents: 0, + interior: { + type: "Here", + value: undefined + } + }, + fun: { + type: "Fungible", + value: teleport + } + } + ] + }, + fee_asset_item: 0, + }) + const dispatchAs = api.tx.Utility.dispatch_as({ + as_origin: { type: "system", value: { type: "Signed", value: ss58(account.publicKey) } }, + call: xcm.decodedCall + }); + calls.push(forceSetBalance.decodedCall); + calls.push(dispatchAs.decodedCall); + } + + const finalBatch = api.tx.Utility.batch_all({ calls }); + const finalSudo = api.tx.Sudo.sudo({ call: finalBatch.decodedCall }); + try { + const res = await finalSudo.signAndSubmit(alice, { at: "best" }); + let success = 0; + let failure = 0; + res.events.forEach((e) => { + logger.verbose(safeJsonStringify(e.value)); + if (e.value.type === "DispatchedAs") { + // @ts-ignore + if (e.value.value.result.success) { + success += 1 + } else { + failure += 1 + } + } + }); + logger.info(`Sent ${count} downward messages, intercepted ${success + failure} events, ${success} succeeded, ${failure} failed`); + } catch(e) { + logger.warn(`Error sending downward messages: ${e}`); + } +} + +test( + `${PRESET} preset with vmp queues being spammed af`, + async () => { + const { killZn, paraLog } = await runPresetUntilLaunched(PRESET); + + const apis = await getApis(); + // This test is meant to not run automatically, so most things are commented out. + + // const downSub = apis.rcClient.blocks$.subscribe((block) => { + // if (block.number > 10) { + // logger.verbose(`spammer::down spamming at height ${block.number}`); + // sendDown(apis.rcApi, (block.number * 10) + 50); + // } + // }); + // const upSub = apis.paraClient.blocks$.subscribe((block) => { + // if (block.number > 0) { + // logger.verbose(`spammer::up spamming at height ${block.number}`); + // sendUp(apis.paraApi, 40); + // } + // }); + const steps: Observe[] = [ + Observe.on(Chain.Relay, "Session", "NewSession") + .byBlock(11), + // Observe.on(Chain.Relay, "WontReach", "WontReach") + ].map((s) => s.build()); + + const testCase = new TestCase(steps, true, () => { + killZn(); + // downSub.unsubscribe(); + // upSub.unsubscribe() + }); + + const outcome = await runTest(testCase, apis, paraLog); + expect(outcome).toEqual(EventOutcome.Done); + }, + { timeout: GlobalTimeout } +); diff --git a/substrate/frame/staking-async/runtimes/papi-tests/tests/vmp.spamming.test.ts b/substrate/frame/staking-async/runtimes/papi-tests/tests/vmp.spamming.test.ts deleted file mode 100644 index 70b786d12ed05..0000000000000 --- a/substrate/frame/staking-async/runtimes/papi-tests/tests/vmp.spamming.test.ts +++ /dev/null @@ -1 +0,0 @@ -// TODO diff --git a/substrate/frame/staking-async/runtimes/papi-tests/zn-m.toml b/substrate/frame/staking-async/runtimes/papi-tests/zn-m.toml index 817b46ba3121e..fbcf63376ebf0 100644 --- a/substrate/frame/staking-async/runtimes/papi-tests/zn-m.toml +++ b/substrate/frame/staking-async/runtimes/papi-tests/zn-m.toml @@ -7,12 +7,13 @@ name = "alice" validator = true rpc_port = 9942 + [[relaychain.nodes]] name = "bob" validator = true rpc_port = 9943 args = [ - "-lruntime::system=debug,runtime::session=trace,runtime::staking::ah-client=trace,runtime::ah-client=debug", + "-lruntime::system=debug,runtime::session=trace,runtime::staking-async::ah-client=trace,runtime::ah-client=debug", ] [[relaychain.nodes]] diff --git a/substrate/frame/staking-async/runtimes/papi-tests/zn-s.toml b/substrate/frame/staking-async/runtimes/papi-tests/zn-s.toml index 57d8b7a6da872..bb360ae7f0a9b 100644 --- a/substrate/frame/staking-async/runtimes/papi-tests/zn-s.toml +++ b/substrate/frame/staking-async/runtimes/papi-tests/zn-s.toml @@ -6,13 +6,16 @@ chain_spec_path = "./rc.json" name = "alice" validator = true rpc_port = 9944 +args = [ + "-lruntime::system=debug,runtime::session=trace,runtime::staking-async::ah-client=trace,runtime::ah-client=debug,xcm=trace", +] [[relaychain.nodes]] name = "bob" validator = true rpc_port = 9945 args = [ - "-lruntime::system=debug,runtime::session=trace,runtime::staking::ah-client=trace,runtime::ah-client=debug", + "-lruntime::system=debug,runtime::session=trace,runtime::staking-async::ah-client=trace,runtime::ah-client=debug", ] [[parachains]] @@ -23,5 +26,5 @@ chain_spec_path = "./parachain.json" name = "charlie" rpc_port = 9946 args = [ - "-lruntime::system=debug,runtime::multiblock-election=trace,runtime::staking=debug,runtime::staking::rc-client=trace,runtime::rc-client=debug", + "-lruntime::system=debug,runtime::multiblock-election=trace,runtime::staking=debug,runtime::staking::rc-client=trace,runtime::rc-client=debug,xcm=trace,parachain-system=debug,runtime=info", ] diff --git a/substrate/frame/staking-async/runtimes/parachain/src/lib.rs b/substrate/frame/staking-async/runtimes/parachain/src/lib.rs index 42cd2fc22d33e..9a597c2de584e 100644 --- a/substrate/frame/staking-async/runtimes/parachain/src/lib.rs +++ b/substrate/frame/staking-async/runtimes/parachain/src/lib.rs @@ -821,7 +821,10 @@ type ConsensusHook = cumulus_pallet_aura_ext::FixedVelocityConsensusHook< impl parachain_info::Config for Runtime {} parameter_types! { - pub MessageQueueServiceWeight: Weight = Perbill::from_percent(35) * RuntimeBlockWeights::get().max_block; + // TODO: note that this value is different from most system chains, and is changed here only + // until lazy era pruning is implemented. The actual weight of the era pruning is not huge due + // to the fact that deletion is not expensive in `state_version: 1`. + pub MessageQueueServiceWeight: Weight = Perbill::from_percent(70) * RuntimeBlockWeights::get().max_block; } impl pallet_message_queue::Config for Runtime { diff --git a/substrate/frame/staking-async/runtimes/parachain/src/staking.rs b/substrate/frame/staking-async/runtimes/parachain/src/staking.rs index 228482faead73..3e06f263432e0 100644 --- a/substrate/frame/staking-async/runtimes/parachain/src/staking.rs +++ b/substrate/frame/staking-async/runtimes/parachain/src/staking.rs @@ -47,7 +47,7 @@ pub(crate) fn enable_ksm_preset(fast: bool) { Pages::set(&16); MinerPages::set(&4); MaxElectingVoters::set(&12_500); - TargetSnapshotPerBlock::set(&4000); + TargetSnapshotPerBlock::set(&2500); if !fast { SignedValidationPhase::set(&(4 * Pages::get())); SignedPhase::set(&(20 * MINUTES)); @@ -216,8 +216,8 @@ parameter_types! { /// Fixed deposit for invulnerable accounts. pub InvulnerableDeposit: Balance = UNITS; - /// * Polkadot: 20% - /// * Kusama: 10% + /// * Polkadot: 10% (more restrictive, don't bail!) + /// * Kusama: 25% /// /// Reasoning: The weight/fee of the `bail` transaction is already assuming you delete all pages /// of your solution while bailing, and charges you accordingly. So the chain is being @@ -449,7 +449,7 @@ impl pallet_staking_async::Config for Runtime { type MaxValidatorSet = MaxValidatorSet; type NominationsQuota = pallet_staking_async::FixedNominationsQuota<{ MaxNominations::get() }>; type MaxUnlockingChunks = frame_support::traits::ConstU32<32>; - type HistoryDepth = frame_support::traits::ConstU32<84>; + type HistoryDepth = ConstU32<1>; type MaxControllersInDeprecationBatch = MaxControllersInDeprecationBatch; type EventListeners = (NominationPools, DelegatedStaking); type WeightInfo = pallet_staking_async::weights::SubstrateWeight; @@ -465,6 +465,7 @@ impl pallet_staking_async_rc_client::Config for Runtime { type RelayChainOrigin = EnsureRoot; type AHStakingInterface = Staking; type SendToRelayChain = StakingXcmToRelayChain; + type MaxValidatorSetRetries = ConstU32<5>; } parameter_types! { @@ -507,13 +508,13 @@ pub struct StakingXcmToRelayChain; impl rc_client::SendToRelayChain for StakingXcmToRelayChain { type AccountId = AccountId; - fn validator_set(report: rc_client::ValidatorSetReport) { + fn validator_set(report: rc_client::ValidatorSetReport) -> Result<(), ()> { rc_client::XCMSender::< xcm_config::XcmRouter, StakingXcmDestination, rc_client::ValidatorSetReport, ValidatorSetToXcm, - >::split_then_send(report, Some(8)); + >::send(report) } } diff --git a/substrate/frame/staking-async/runtimes/preset-store/Cargo.toml b/substrate/frame/staking-async/runtimes/preset-store/Cargo.toml index 95f5520d0d58b..a2565ac60035c 100644 --- a/substrate/frame/staking-async/runtimes/preset-store/Cargo.toml +++ b/substrate/frame/staking-async/runtimes/preset-store/Cargo.toml @@ -13,6 +13,7 @@ repository.workspace = true [dependencies] codec = { workspace = true } frame = { workspace = true, features = ["runtime"] } +pallet-staking-async-ah-client = { workspace = true } scale-info = { workspace = true } [features] @@ -20,5 +21,6 @@ default = ["std"] std = [ "codec/std", "frame/std", + "pallet-staking-async-ah-client/std", "scale-info/std", ] diff --git a/substrate/frame/staking-async/runtimes/rc/constants/src/lib.rs b/substrate/frame/staking-async/runtimes/rc/constants/src/lib.rs index 7a2663677e9e4..3c99da6af48c7 100644 --- a/substrate/frame/staking-async/runtimes/rc/constants/src/lib.rs +++ b/substrate/frame/staking-async/runtimes/rc/constants/src/lib.rs @@ -102,7 +102,7 @@ pub mod system_parachain { use xcm_builder::IsChildSystemParachain; /// Network's Asset Hub parachain ID. - pub const ASSET_HUB_ID: u32 = 1000; + pub const ASSET_HUB_ID: u32 = 1100; /// Collectives parachain ID. pub const COLLECTIVES_ID: u32 = 1001; /// BridgeHub parachain ID. diff --git a/substrate/frame/staking-async/runtimes/rc/src/genesis_config_presets.rs b/substrate/frame/staking-async/runtimes/rc/src/genesis_config_presets.rs index 33eb3791efb0a..59cf75f87ce43 100644 --- a/substrate/frame/staking-async/runtimes/rc/src/genesis_config_presets.rs +++ b/substrate/frame/staking-async/runtimes/rc/src/genesis_config_presets.rs @@ -96,18 +96,20 @@ fn default_parachains_host_configuration( }; polkadot_runtime_parachains::configuration::HostConfiguration { + // Important configs are equal to what is on Polkadot. These configs can be tweaked to mimic + // different VMP congestion scenarios. + max_downward_message_size: 51200, + max_upward_message_size: 65531, + max_upward_message_num_per_candidate: 16, + max_upward_queue_count: 174762, + max_upward_queue_size: 1048576, + validation_upgrade_cooldown: 2u32, validation_upgrade_delay: 2, code_retention_period: 1200, max_code_size: MAX_CODE_SIZE, max_pov_size: MAX_POV_SIZE, max_head_data_size: 32 * 1024, - max_upward_queue_count: 8, - max_upward_queue_size: 1024 * 1024, - // NOTE: these can be tweaked to mimic the XCM message splitting. - max_downward_message_size: 1024 * 1024, - max_upward_message_size: 50 * 1024, - max_upward_message_num_per_candidate: 5, hrmp_sender_deposit: 0, hrmp_recipient_deposit: 0, hrmp_channel_max_capacity: 8, diff --git a/substrate/frame/staking-async/runtimes/rc/src/lib.rs b/substrate/frame/staking-async/runtimes/rc/src/lib.rs index 99c3f0633a08c..52c1beb89cbfe 100644 --- a/substrate/frame/staking-async/runtimes/rc/src/lib.rs +++ b/substrate/frame/staking-async/runtimes/rc/src/lib.rs @@ -134,6 +134,53 @@ use pallet_staking_async_rc_runtime_constants::{ time::*, }; +pub mod pallet_reward_point_filler { + use frame_support::pallet_prelude::*; + use frame_system::pallet_prelude::*; + + #[frame_support::pallet] + pub mod pallet { + use super::*; + + #[pallet::config] + pub trait Config: frame_system::Config + pallet_staking_async_ah_client::Config { + type FillValidatorPointsTo: Get; + } + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::hooks] + impl Hooks> for Pallet + where + T::AccountId: From<[u8; 32]>, + { + fn on_initialize(_: BlockNumberFor) -> Weight { + let current = + pallet_staking_async_ah_client::ValidatorPoints::::iter().count() as u32; + if let Some(deficit) = T::FillValidatorPointsTo::get().checked_sub(current) { + for index in 0..deficit { + let unique = index.to_le_bytes(); + let mut key = [0u8; 32]; + // first 4 bytes should be `unique`, rest 0 + key[..4].copy_from_slice(&unique); + pallet_staking_async_ah_client::ValidatorPoints::::insert( + T::AccountId::from(key), + 42, + ); + } + } + Default::default() + } + } + } +} + +impl pallet_reward_point_filler::pallet::Config for Runtime { + // we may have 2/4 validators by default, so let's fill it up to 994. + type FillValidatorPointsTo = ConstU32<994>; +} + mod genesis_config_presets; mod weights; pub mod xcm_config; @@ -623,6 +670,7 @@ impl session_historical::Config for Runtime { impl pallet_root_offences::Config for Runtime { type RuntimeEvent = RuntimeEvent; type OffenceHandler = StakingAhClient; + type ReportOffence = Offences; } pub struct AssetHubLocation; @@ -651,24 +699,10 @@ impl Convert, Xcm<()>> for SessionReportToXc } } -pub struct StakingXcmToAssetHub; -impl ah_client::SendToAssetHub for StakingXcmToAssetHub { - type AccountId = AccountId; - - fn relay_session_report(session_report: rc_client::SessionReport) { - rc_client::XCMSender::< - xcm_config::XcmRouter, - AssetHubLocation, - rc_client::SessionReport, - SessionReportToXcm, - >::split_then_send(session_report, Some(8)); - } - - fn relay_new_offence( - session_index: SessionIndex, - offences: Vec>, - ) { - let message = Xcm(vec![ +pub struct QueuedOffenceToXcm; +impl Convert>, Xcm<()>> for QueuedOffenceToXcm { + fn convert(offences: Vec>) -> Xcm<()> { + Xcm(vec![ Instruction::UnpaidExecution { weight_limit: WeightLimit::Unlimited, check_origin: None, @@ -676,17 +710,40 @@ impl ah_client::SendToAssetHub for StakingXcmToAssetHub { Instruction::Transact { origin_kind: OriginKind::Superuser, fallback_max_weight: None, - call: AssetHubRuntimePallets::RcClient(RcClientCalls::RelayNewOffence( - session_index, + call: AssetHubRuntimePallets::RcClient(RcClientCalls::RelayNewOffencePaged( offences, )) .encode() .into(), }, - ]); - if let Err(err) = send_xcm::(AssetHubLocation::get(), message) { - log::error!(target: "runtime::ah-client", "Failed to send relay offence message: {:?}", err); - } + ]) + } +} + +pub struct StakingXcmToAssetHub; +impl ah_client::SendToAssetHub for StakingXcmToAssetHub { + type AccountId = AccountId; + + fn relay_session_report( + session_report: rc_client::SessionReport, + ) -> Result<(), ()> { + rc_client::XCMSender::< + xcm_config::XcmRouter, + AssetHubLocation, + rc_client::SessionReport, + SessionReportToXcm, + >::send(session_report) + } + + fn relay_new_offence_paged( + offences: Vec>, + ) -> Result<(), ()> { + rc_client::XCMSender::< + xcm_config::XcmRouter, + AssetHubLocation, + Vec>, + QueuedOffenceToXcm, + >::send(offences) } } @@ -703,7 +760,7 @@ enum RcClientCalls { #[codec(index = 0)] RelaySessionReport(rc_client::SessionReport), #[codec(index = 1)] - RelayNewOffence(SessionIndex, Vec>), + RelayNewOffencePaged(Vec<(SessionIndex, rc_client::Offence)>), } pub struct EnsureAssetHub; @@ -725,6 +782,7 @@ impl frame_support::traits::EnsureOrigin for EnsureAssetHub { } parameter_types! { + /// each offence is 74 bytes max, sending 50 at a time will be 3,700 bytes. pub const MaxOffenceBatchSize: u32 = 50; } @@ -736,11 +794,12 @@ impl pallet_staking_async_ah_client::Config for Runtime { type SessionInterface = Self; type SendToAssetHub = StakingXcmToAssetHub; type MinimumValidatorSetSize = ConstU32<1>; + type MaximumValidatorsWithPoints = ConstU32<{ MaxActiveValidators::get() * 4 }>; type UnixTime = Timestamp; type PointsPerBlock = ConstU32<20>; type MaxOffenceBatchSize = MaxOffenceBatchSize; type Fallback = Staking; - type WeightInfo = (); + type MaxSessionReportRetries = ConstU32<3>; } parameter_types! { @@ -1888,6 +1947,8 @@ mod runtime { pub type StakingAhClient = pallet_staking_async_ah_client; #[runtime::pallet_index(68)] pub type PresetStore = pallet_staking_async_preset_store; + #[runtime::pallet_index(69)] + pub type RewardPointFiller = pallet_reward_point_filler::pallet; // Migrations pallet #[runtime::pallet_index(98)] diff --git a/substrate/frame/staking-async/runtimes/rc/src/xcm_config.rs b/substrate/frame/staking-async/runtimes/rc/src/xcm_config.rs index 8f53e88a45396..7da9bdcdf3e9f 100644 --- a/substrate/frame/staking-async/runtimes/rc/src/xcm_config.rs +++ b/substrate/frame/staking-async/runtimes/rc/src/xcm_config.rs @@ -63,6 +63,8 @@ parameter_types! { pub FeeAssetId: AssetId = AssetId(TokenLocation::get()); /// The base fee for the message delivery fees. pub const BaseDeliveryFee: u128 = CENTS.saturating_mul(3); + /// Westend does not have mint authority anymore after the Asset Hub migration. + pub TeleportTracking: Option<(AccountId, MintLocation)> = None; } pub type LocationConverter = ( @@ -83,8 +85,8 @@ pub type LocalAssetTransactor = FungibleAdapter< LocationConverter, // Our chain's account ID type (we can't get away without mentioning it explicitly): AccountId, - // It's a native asset so we keep track of the teleports to maintain total issuance. - LocalCheckAccount, + // Teleport tracking + TeleportTracking, >; type LocalOriginConverter = ( diff --git a/substrate/frame/staking-async/src/pallet/impls.rs b/substrate/frame/staking-async/src/pallet/impls.rs index d0017163b20a7..82e492650cde5 100644 --- a/substrate/frame/staking-async/src/pallet/impls.rs +++ b/substrate/frame/staking-async/src/pallet/impls.rs @@ -1333,11 +1333,8 @@ impl rc_client::AHStakingInterface for Pallet { weight } - fn weigh_on_new_offences( - _slash_session: SessionIndex, - offences: &[pallet_staking_async_rc_client::Offence], - ) -> Weight { - T::WeightInfo::rc_on_offence(offences.len() as u32) + fn weigh_on_new_offences(offence_count: u32) -> Weight { + T::WeightInfo::rc_on_offence(offence_count) } } diff --git a/substrate/frame/staking-async/src/session_rotation.rs b/substrate/frame/staking-async/src/session_rotation.rs index f1c8b3a38808b..2ddef7916b3a5 100644 --- a/substrate/frame/staking-async/src/session_rotation.rs +++ b/substrate/frame/staking-async/src/session_rotation.rs @@ -336,7 +336,7 @@ impl Eras { Some(individual) => individual.saturating_accrue(points), None => { // not much we can do -- validators should always be less than - // `MaxValidatorCount`. + // `MaxValidatorSet`. let _ = era_rewards.individual.try_insert(validator, points).defensive(); },