diff --git a/Cargo.lock b/Cargo.lock index 32d4c7655d..ae4c064532 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10095,6 +10095,23 @@ dependencies = [ "sp-mmr-primitives", ] +[[package]] +name = "pallet-multi-collective" +version = "1.0.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "impl-trait-for-tuples", + "num-traits", + "parity-scale-codec", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "subtensor-runtime-common", +] + [[package]] name = "pallet-multisig" version = "41.0.0" @@ -18290,6 +18307,7 @@ dependencies = [ "approx", "environmental", "frame-support", + "impl-trait-for-tuples", "num-traits", "parity-scale-codec", "polkadot-runtime-common", diff --git a/Cargo.toml b/Cargo.toml index 14ded6a4f9..dba2dd32e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,7 @@ pallet-subtensor = { path = "pallets/subtensor", default-features = false } pallet-subtensor-swap = { path = "pallets/swap", default-features = false } pallet-subtensor-swap-runtime-api = { path = "pallets/swap/runtime-api", default-features = false } pallet-subtensor-swap-rpc = { path = "pallets/swap/rpc", default-features = false } +pallet-multi-collective = { path = "pallets/multi-collective", default-features = false } procedural-fork = { path = "support/procedural-fork", default-features = false } safe-math = { path = "primitives/safe-math", default-features = false } share-pool = { path = "primitives/share-pool", default-features = false } @@ -87,6 +88,7 @@ enumflags2 = "0.7.9" futures = "0.3.30" hex = { version = "0.4", default-features = false } hex-literal = "0.4.1" +impl-trait-for-tuples = "0.2.3" jsonrpsee = { version = "0.24.9", default-features = false } libsecp256k1 = { version = "0.7.2", default-features = false } lencode = "0.1.6" @@ -117,7 +119,7 @@ toml_edit = "0.22" derive-syn-parse = "0.2" Inflector = "0.11" cfg-expr = "0.15" -itertools = "0.10" +itertools = { version = "0.10", default-features = false } macro_magic = { version = "0.5", default-features = false } frame-support-procedural-tools = { version = "10.0.0", default-features = false } proc-macro-warning = { version = "1", default-features = false } diff --git a/build.rs b/build.rs index 854778873e..f382604525 100644 --- a/build.rs +++ b/build.rs @@ -29,45 +29,52 @@ fn main() { // as we process each Rust file let (tx, rx) = channel(); - // Parse each rust file with syn and run the linting suite on it in parallel - rust_files.par_iter().for_each_with(tx.clone(), |tx, file| { - let is_test = file.display().to_string().contains("test"); - let Ok(content) = fs::read_to_string(file) else { - return; - }; - let Ok(parsed_tokens) = proc_macro2::TokenStream::from_str(&content) else { - return; - }; - let Ok(parsed_file) = syn::parse2::(parsed_tokens) else { - return; - }; + let pool = rayon::ThreadPoolBuilder::new() + .stack_size(64 * 1024 * 1024) + .build() + .expect("build script lint thread pool can be created"); - let track_lint = |result: Result| { - let Err(errors) = result else { + pool.install(|| { + // Parse each rust file with syn and run the linting suite on it in parallel. + rust_files.par_iter().for_each_with(tx.clone(), |tx, file| { + let is_test = file.display().to_string().contains("test"); + let Ok(content) = fs::read_to_string(file) else { return; }; - let relative_path = file.strip_prefix(workspace_root).unwrap_or(file.as_path()); - for error in errors { - let loc = error.span().start(); - let file_path = relative_path.display(); - // note that spans can't go across thread boundaries without losing their location - // info so we we serialize here and send a String - tx.send(format!( - "cargo:warning={}:{}:{}: {}", - file_path, loc.line, loc.column, error, - )) - .unwrap(); - } - }; + let Ok(parsed_tokens) = proc_macro2::TokenStream::from_str(&content) else { + return; + }; + let Ok(parsed_file) = syn::parse2::(parsed_tokens) else { + return; + }; + + let track_lint = |result: Result| { + let Err(errors) = result else { + return; + }; + let relative_path = file.strip_prefix(workspace_root).unwrap_or(file.as_path()); + for error in errors { + let loc = error.span().start(); + let file_path = relative_path.display(); + // note that spans can't go across thread boundaries without losing their location + // info so we we serialize here and send a String + tx.send(format!( + "cargo:warning={}:{}:{}: {}", + file_path, loc.line, loc.column, error, + )) + .unwrap(); + } + }; - track_lint(ForbidAsPrimitiveConversion::lint(&parsed_file)); - track_lint(ForbidKeysRemoveCall::lint(&parsed_file)); - track_lint(RequireFreezeStruct::lint(&parsed_file)); - track_lint(RequireExplicitPalletIndex::lint(&parsed_file)); + track_lint(ForbidAsPrimitiveConversion::lint(&parsed_file)); + track_lint(ForbidKeysRemoveCall::lint(&parsed_file)); + track_lint(RequireFreezeStruct::lint(&parsed_file)); + track_lint(RequireExplicitPalletIndex::lint(&parsed_file)); - if is_test { - track_lint(ForbidSaturatingMath::lint(&parsed_file)); - } + if is_test { + track_lint(ForbidSaturatingMath::lint(&parsed_file)); + } + }); }); // Collect and print all errors after the parallel processing is done diff --git a/common/Cargo.toml b/common/Cargo.toml index 9fa9bd1856..e225657b8c 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -25,6 +25,7 @@ substrate-fixed.workspace = true subtensor-macros.workspace = true runtime-common.workspace = true approx = { workspace = true, optional = true } +impl-trait-for-tuples.workspace = true [lints] workspace = true diff --git a/common/src/lib.rs b/common/src/lib.rs index ad29f123b2..92095b29b3 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -16,10 +16,12 @@ use subtensor_macros::freeze_struct; pub use currency::*; pub use evm_context::*; +pub use traits::*; pub use transaction_error::*; mod currency; mod evm_context; +mod traits; mod transaction_error; /// Balance of an account. diff --git a/common/src/traits.rs b/common/src/traits.rs new file mode 100644 index 0000000000..349d387fa5 --- /dev/null +++ b/common/src/traits.rs @@ -0,0 +1,34 @@ +use frame_support::pallet_prelude::*; + +/// Handler for when the members of a collective have changed. +pub trait OnMembersChanged { + /// A collective's members have changed, `incoming` members have joined and + /// `outgoing` members have left. + fn on_members_changed( + collective_id: CollectiveId, + incoming: &[AccountId], + outgoing: &[AccountId], + ); + /// Worst-case upper bound on `on_members_changed`'s weight. The + /// implementation is responsible for bounding its own iteration over + /// `incoming`/`outgoing` against the relevant `MaxMembers` constant. + fn weight() -> Weight; +} + +#[impl_trait_for_tuples::impl_for_tuples(10)] +impl OnMembersChanged for Tuple { + fn on_members_changed( + collective_id: CollectiveId, + incoming: &[AccountId], + outgoing: &[AccountId], + ) { + for_tuples!( #( Tuple::on_members_changed(collective_id.clone(), incoming, outgoing); )* ); + } + + fn weight() -> Weight { + #[allow(clippy::let_and_return)] + let mut weight = Weight::zero(); + for_tuples!( #( weight.saturating_accrue(Tuple::weight()); )* ); + weight + } +} diff --git a/pallets/multi-collective/Cargo.toml b/pallets/multi-collective/Cargo.toml new file mode 100644 index 0000000000..171faf9caa --- /dev/null +++ b/pallets/multi-collective/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "pallet-multi-collective" +version = "1.0.0" +authors = ["Bittensor Nucleus Team"] +edition.workspace = true +license = "Apache-2.0" +homepage = "https://bittensor.com" +description = "Membership for named collectives, with per-call origins and optional scheduled rotation." +readme = "README.md" + +[lints] +workspace = true + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { workspace = true, features = ["max-encoded-len"] } +scale-info = { workspace = true, features = ["derive"] } +frame-benchmarking = { workspace = true, optional = true } +frame-system = { workspace = true } +frame-support = { workspace = true } +impl-trait-for-tuples = { workspace = true } +num-traits = { workspace = true } +subtensor-runtime-common = { workspace = true } + +[dev-dependencies] +sp-io = { workspace = true, default-features = true } +sp-core = { workspace = true, default-features = true } +sp-runtime = { workspace = true, default-features = true } + +[features] +default = ["std"] +std = [ + "codec/std", + "scale-info/std", + "frame-benchmarking?/std", + "frame-system/std", + "frame-support/std", + "num-traits/std", + "subtensor-runtime-common/std", +] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", + "subtensor-runtime-common/runtime-benchmarks", +] +try-runtime = [ + "frame-support/try-runtime", + "frame-system/try-runtime", + "sp-runtime/try-runtime" +] diff --git a/pallets/multi-collective/README.md b/pallets/multi-collective/README.md new file mode 100644 index 0000000000..61d2739ea6 --- /dev/null +++ b/pallets/multi-collective/README.md @@ -0,0 +1,99 @@ +# pallet-multi-collective + +Membership storage for one or more named collectives, keyed by a +runtime-defined `CollectiveId`. Each collective is configured by a +`CollectivesInfo` impl: name, min/max members, optional term duration. + +The pallet only stores membership. Voting, proposing, and tallying are +left to the consumer (e.g. `pallet-referenda` + `pallet-signed-voting`), +which read members through the `CollectiveInspect` trait. + +## Concepts + +| Type | Provided by | Purpose | +| ---- | ----------- | ------- | +| `CollectiveId` | runtime | Enum naming each collective. | +| `CollectivesInfo` | runtime | Returns the static config for each id (name, bounds, term). | +| `CollectiveInfo` | this crate | `{ name, min_members, max_members, term_duration }`. | +| `Members<_>` | this crate | `BoundedVec` per id, sorted by `AccountId`. | + +## Extrinsics + +| Call | Origin | Effect | +| ---- | ------ | ------ | +| `add_member` | `T::AddOrigin` | Insert one member. Fails on `AlreadyMember`, `TooManyMembers`, `CollectiveNotFound`. | +| `remove_member` | `T::RemoveOrigin` | Remove one member. Fails on `NotMember`, `TooFewMembers`, `CollectiveNotFound`. | +| `swap_member` | `T::SwapOrigin` | Atomic remove + insert. Count is preserved, so the per-collective `min_members` / `max_members` bounds are not re-checked; works at either boundary. | +| `set_members` | `T::SetOrigin` | Replace the full list. Sorts the input and rejects `DuplicateAccounts` if any duplicates are present (the input is not silently deduplicated). | +| `force_rotate` | `T::RotateOrigin` | Trigger `OnNewTerm` for a rotating collective on demand. | + +Every mutation fires `T::OnMembersChanged` with the incoming and +outgoing accounts so downstream pallets can react (e.g. clean up +votes). The Subtensor runtime currently wires this to `()`: active +polls snapshot the voter set at creation, so member changes cannot +retroactively invalidate votes, and no cleanup is needed. + +## Rotation + +A collective whose `CollectiveInfo::term_duration` is `Some(d)` rotates +every `d` blocks: `on_initialize` calls `T::OnNewTerm::on_new_term(id)` +when `block_number % d == 0`. The runtime-supplied handler typically +recomputes membership from on-chain data and writes it back through +`set_members`. + +`force_rotate` runs the same hook on demand. Used to bootstrap the +first term (the natural cadence only fires after the first boundary, +which can be days or months in) and as a privileged override during +incidents. Calls against a collective with `term_duration: None` are +rejected with `CollectiveDoesNotRotate`. + +Curated collectives (no term duration) are managed directly via the +membership extrinsics. + +## Integrity check + +`integrity_test` runs at runtime construction and panics on a +misconfigured `CollectivesInfo`: + +- `min_members > T::MaxMembers` (collective can't reach its min) +- `max_members > T::MaxMembers` (storage can't hold the declared max) +- `min_members > max_members` (collective is unreachable) +- `term_duration: Some(0)` (silently disables rotation; use `None` to opt out) + +## Migrations + +Pinned at `StorageVersion::new(0)` to satisfy try-runtime CLI; the +project tracks migration runs through a per-pallet `HasMigrationRun` +storage map (see `pallet-crowdloan`), not via FRAME's `StorageVersion` +bump. Add a `migrations` module and an `on_runtime_upgrade` hook on +the next breaking change to `Members<_>` or any future persisted state. + +## Configuration + +```rust +parameter_types! { + pub const MaxMembers: u32 = 20; +} + +impl pallet_multi_collective::Config for Runtime { + type CollectiveId = CollectiveId; + type Collectives = Collectives; + type AddOrigin = AsEnsureOriginWithArg>; + type RemoveOrigin = AsEnsureOriginWithArg>; + type SwapOrigin = AsEnsureOriginWithArg>; + type SetOrigin = AsEnsureOriginWithArg>; + type RotateOrigin = AsEnsureOriginWithArg>; + type OnMembersChanged = (); + type OnNewTerm = TermManagement; + type MaxMembers = MaxMembers; + type WeightInfo = pallet_multi_collective::weights::SubstrateWeight; +} +``` + +`T::MaxMembers` bounds storage; per-collective `max_members` from +`CollectivesInfo` may be smaller but never larger (enforced by +`integrity_test`). + +## License + +Apache-2.0. diff --git a/pallets/multi-collective/src/benchmarking.rs b/pallets/multi-collective/src/benchmarking.rs new file mode 100644 index 0000000000..7755bea183 --- /dev/null +++ b/pallets/multi-collective/src/benchmarking.rs @@ -0,0 +1,150 @@ +//! Benchmarks for `pallet-multi-collective`. +//! +//! Setup is parameterised through [`Config::BenchmarkHelper`]: the runtime +//! supplies a non-rotatable collective whose bounds allow the pallet to +//! fill and drain it freely, plus a separate rotatable collective for +//! `force_rotate`. +#![allow(clippy::expect_used, clippy::indexing_slicing, clippy::unwrap_used)] + +use super::*; +use frame_benchmarking::v2::*; +use frame_system::RawOrigin; + +const SEED: u32 = 0; + +fn fill_members(collective_id: T::CollectiveId, count: u32) -> Vec { + let mut members: Vec = (0..count) + .map(|i| account::("member", i, SEED)) + .collect(); + members.sort(); + + // Bypass `add_member` to avoid paying the per-call binary_search cost + // during setup: we know the list is sorted and unique, so we can + // write the storage directly. + let bounded = + BoundedVec::try_from(members.clone()).expect("benchmark fill must respect MaxMembers"); + Members::::insert(collective_id, bounded); + members +} + +#[benchmarks] +mod benches { + use super::*; + + /// Worst case: pre-fill to `MaxMembers - 1` so the binary_search runs at full depth. + #[benchmark] + fn add_member() { + let collective = T::BenchmarkHelper::collective(); + let max = T::MaxMembers::get(); + let _existing = fill_members::(collective, max.saturating_sub(1)); + let new_member = account::("new", 0, SEED); + + #[extrinsic_call] + add_member(RawOrigin::Root, collective, new_member); + + assert_eq!(Members::::get(collective).len(), max as usize); + } + + /// Worst case: full collective; binary_search at max depth, remove + /// shifts the maximum number of trailing elements. + #[benchmark] + fn remove_member() { + let collective = T::BenchmarkHelper::collective(); + let max = T::MaxMembers::get(); + let members = fill_members::(collective, max); + // Remove the head: `remove(0)` shifts every other element. + let to_remove = members[0].clone(); + + #[extrinsic_call] + remove_member(RawOrigin::Root, collective, to_remove); + + assert_eq!( + Members::::get(collective).len(), + (max as usize).saturating_sub(1), + ); + } + + /// Worst case: full collective; two binary_searches at max depth, + /// then a remove + insert each shifting the maximum trailing slice. + #[benchmark] + fn swap_member() { + let collective = T::BenchmarkHelper::collective(); + let max = T::MaxMembers::get(); + let members = fill_members::(collective, max); + let to_remove = members[0].clone(); + let to_add = account::("new", 0, SEED); + + #[extrinsic_call] + swap_member(RawOrigin::Root, collective, to_remove, to_add); + + assert_eq!(Members::::get(collective).len(), max as usize); + } + + /// Worst case: replace a fully-populated collective with a completely disjoint set + /// of `MaxMembers` new accounts. + #[benchmark] + fn set_members() { + let collective = T::BenchmarkHelper::collective(); + let max = T::MaxMembers::get(); + let _existing = fill_members::(collective, max); + + let new_members: Vec = (0..max) + .map(|i| account::("new", i, SEED)) + .collect(); + + #[extrinsic_call] + set_members(RawOrigin::Root, collective, new_members.clone()); + + assert_eq!(Members::::get(collective).len(), max as usize); + } + + /// `force_rotate` itself does only validation + a hook dispatch; + /// this benchmark measures just the extrinsic-side overhead. The + /// hook's worst-case cost is added separately via + /// `T::OnNewTerm::weight()` in the `#[pallet::weight(...)]` + /// annotation. + #[benchmark] + fn force_rotate() { + let collective = T::BenchmarkHelper::rotatable_collective(); + + #[extrinsic_call] + force_rotate(RawOrigin::Root, collective); + } + + #[benchmark] + fn do_add_member() { + let collective = T::BenchmarkHelper::collective(); + let max = T::MaxMembers::get(); + let _existing = fill_members::(collective, max.saturating_sub(1)); + let new_member = account::("new", 0, SEED); + + #[block] + { + Pallet::::do_add_member(collective, new_member) + .expect("benchmark setup must allow add"); + } + + assert_eq!(Members::::get(collective).len(), max as usize); + } + + #[benchmark] + fn do_remove_member() { + let collective = T::BenchmarkHelper::collective(); + let max = T::MaxMembers::get(); + let members = fill_members::(collective, max); + let to_remove = members[0].clone(); + + #[block] + { + Pallet::::do_remove_member(collective, to_remove) + .expect("benchmark setup must allow remove"); + } + + assert_eq!( + Members::::get(collective).len(), + (max as usize).saturating_sub(1), + ); + } + + impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test); +} diff --git a/pallets/multi-collective/src/lib.rs b/pallets/multi-collective/src/lib.rs new file mode 100644 index 0000000000..93b1a4dd5a --- /dev/null +++ b/pallets/multi-collective/src/lib.rs @@ -0,0 +1,679 @@ +//! # Multi-Collective Pallet +//! +//! Stores the membership of one or more named collectives keyed by a +//! runtime-defined `CollectiveId`. Each collective is configured by a +//! `CollectivesInfo` impl: name, min/max members, optional term duration. +//! +//! ## Membership +//! +//! Members are kept sorted by `AccountId` in a per-collective `BoundedVec`. +//! Four extrinsics mutate the set, each gated by its own origin: +//! - [`Pallet::add_member`] (`T::AddOrigin`) +//! - [`Pallet::remove_member`] (`T::RemoveOrigin`) +//! - [`Pallet::swap_member`] (`T::SwapOrigin`) +//! - [`Pallet::set_members`] (`T::SetOrigin`) +//! +//! Every mutation fires `T::OnMembersChanged` with the incoming and +//! outgoing accounts. +//! +//! ## Rotations +//! +//! Collectives with `CollectiveInfo::term_duration = Some(d)` rotate on +//! schedule: `on_initialize` calls `T::OnNewTerm::on_new_term(id)` whenever +//! `block_number % d == 0`. The runtime-provided handler recomputes the +//! membership and pushes it back through `set_members`. +//! +//! [`Pallet::force_rotate`] (gated by `T::RotateOrigin`) triggers the same +//! hook on demand, for bootstrapping the first term or as a privileged +//! override. +//! +//! ## Inspection +//! +//! Other pallets read membership through [`CollectiveInspect`], implemented +//! by `Pallet` over `Members<_>`. + +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +use alloc::vec::Vec; +use frame_support::{ + dispatch::DispatchResult, + pallet_prelude::*, + traits::{ChangeMembers, EnsureOriginWithArg}, +}; +use frame_system::pallet_prelude::*; +use num_traits::ops::checked::CheckedRem; +pub use pallet::*; +pub use subtensor_runtime_common::OnMembersChanged; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; +pub mod weights; +pub use weights::WeightInfo; + +/// Recommended fixed length for the `Name` parameter of `CollectivesInfo`. +/// The pallet itself does not enforce this, but the runtime's +/// `CollectivesInfo` impl is expected to use `[u8; MAX_COLLECTIVE_NAME_LEN]` +/// so that names round-trip a stable, encodable type. +pub const MAX_COLLECTIVE_NAME_LEN: usize = 32; +type CollectiveName = [u8; MAX_COLLECTIVE_NAME_LEN]; + +#[frame_support::pallet] +#[allow(clippy::expect_used)] +pub mod pallet { + use super::*; + + // Pinned to 0 to satisfy try-runtime CLI's pre/post-upgrade checks. + // The project tracks migrations via a per-pallet `HasMigrationRun` map + // so this value is not bumped on schema changes. + const STORAGE_VERSION: StorageVersion = StorageVersion::new(0); + + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + /// The identifier for a collective. + type CollectiveId: Parameter + MaxEncodedLen + Copy; + + /// Provides per-collective information. + type Collectives: CollectivesInfo, CollectiveName, Id = Self::CollectiveId>; + + /// Required origin for adding a member to a collective. + type AddOrigin: EnsureOriginWithArg; + + /// Required origin for removing a member from a collective. + type RemoveOrigin: EnsureOriginWithArg; + + /// Required origin for swapping a member in a collective. + type SwapOrigin: EnsureOriginWithArg; + + /// Required origin for setting the full member list of a collective. + type SetOrigin: EnsureOriginWithArg; + + /// Required origin for `force_rotate`. + type RotateOrigin: EnsureOriginWithArg; + + /// The receiver of the signal for when the members of a collective have changed. + type OnMembersChanged: OnMembersChanged; + + /// The receiver of the signal for when a new term of a collective has started. + type OnNewTerm: OnNewTerm; + + /// The maximum number of members per collective. + /// + /// This is used for benchmarking. Re-run the benchmarks if this changes. + /// + /// This is enforced in the code; the membership size can not exceed this limit. + #[pallet::constant] + type MaxMembers: Get; + + /// Weight information for extrinsics in this pallet. + type WeightInfo: WeightInfo; + + /// Helper for setting up cross-pallet state needed by benchmarks. + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper: BenchmarkHelper; + } + + /// Benchmark setup helper. The runtime supplies a non-rotatable + /// collective for member-management benchmarks and a rotatable one + /// for `force_rotate`. + #[cfg(feature = "runtime-benchmarks")] + pub trait BenchmarkHelper { + /// A collective whose `info.max_members` allows reaching `MaxMembers` + /// and whose `info.min_members == 0`, so member-management + /// benchmarks can fill and drain freely. + fn collective() -> T::CollectiveId; + /// A collective whose `CollectiveInfo::term_duration` is `Some`, + /// for the `force_rotate` benchmark. + fn rotatable_collective() -> T::CollectiveId; + } + + /// Members of each collective, kept sorted by `AccountId`. + /// + /// The sorted invariant is maintained by every write path + /// (`add_member`, `remove_member`, `swap_member`, `set_members`) so + /// that membership lookups can use `binary_search` and `set_members` + /// can diff against the previous set with a linear merge. + #[pallet::storage] + pub(super) type Members = StorageMap< + _, + Blake2_128Concat, + T::CollectiveId, + BoundedVec, + ValueQuery, + >; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// An account was added to a collective. + MemberAdded { + /// Collective the account joined. + collective_id: T::CollectiveId, + /// Account that joined. + who: T::AccountId, + }, + /// An account was removed from a collective. + MemberRemoved { + /// Collective the account left. + collective_id: T::CollectiveId, + /// Account that left. + who: T::AccountId, + }, + /// A member of a collective was replaced by another account in + /// a single operation. + MemberSwapped { + /// Collective whose membership changed. + collective_id: T::CollectiveId, + /// Account that left. + removed: T::AccountId, + /// Account that joined in its place. + added: T::AccountId, + }, + /// The full membership of a collective was replaced. + MembersSet { + /// Collective whose membership was replaced. + collective_id: T::CollectiveId, + /// Accounts that became members in this update, sorted. + /// This is the difference against the previous member + /// list, not the full new list. + incoming: Vec, + /// Accounts that stopped being members in this update, + /// sorted. This is the difference against the previous + /// member list. + outgoing: Vec, + }, + } + + #[pallet::error] + pub enum Error { + /// Account is already a member of this collective. + AlreadyMember, + /// Account is not a member of this collective. + NotMember, + /// Adding a member would exceed the maximum for this collective. + TooManyMembers, + /// Removing a member would go below the minimum for this collective. + TooFewMembers, + /// The collective is not recognized. + CollectiveNotFound, + /// Duplicate accounts in member list. + DuplicateAccounts, + /// A rotation was requested for a collective that does not + /// rotate. Such collectives are curated directly through the + /// membership operations and have no rotation hook to trigger. + CollectiveDoesNotRotate, + } + + #[pallet::hooks] + impl Hooks> for Pallet { + fn on_initialize(n: BlockNumberFor) -> Weight { + // Conservative upper bound for the iteration cost. Matches the + // storage-backed case; static `CollectivesInfo` impls pay a + // smaller CPU cost, so this is a safe overestimate. + let mut weight = Weight::zero().saturating_add(T::DbWeight::get().reads(1)); + + for collective in T::Collectives::collectives() { + if collective + .info + .term_duration + .is_some_and(|td| n.checked_rem(&td).unwrap_or(n).is_zero()) + { + weight.saturating_accrue(T::OnNewTerm::on_new_term(collective.id)); + } + } + + weight + } + + fn integrity_test() { + Pallet::::check_integrity(); + } + + #[cfg(feature = "try-runtime")] + fn try_state( + _n: BlockNumberFor, + ) -> Result<(), frame_support::sp_runtime::TryRuntimeError> { + Pallet::::do_try_state() + } + } + + #[pallet::call] + impl Pallet { + #![deny(clippy::expect_used)] + + /// Add `who` to `collective_id`. + /// + /// Errors: `CollectiveNotFound`, `AlreadyMember`, `TooManyMembers`. + #[pallet::call_index(0)] + #[pallet::weight( + T::WeightInfo::add_member().saturating_add(T::OnMembersChanged::weight()) + )] + pub fn add_member( + origin: OriginFor, + collective_id: T::CollectiveId, + who: T::AccountId, + ) -> DispatchResult { + T::AddOrigin::ensure_origin(origin, &collective_id)?; + Self::do_add_member(collective_id, who)?; + Ok(()) + } + + /// Remove `who` from `collective_id`. Refuses to drop the + /// member count to or below `CollectiveInfo::min_members`. + #[pallet::call_index(1)] + #[pallet::weight( + T::WeightInfo::remove_member().saturating_add(T::OnMembersChanged::weight()) + )] + pub fn remove_member( + origin: OriginFor, + collective_id: T::CollectiveId, + who: T::AccountId, + ) -> DispatchResult { + T::RemoveOrigin::ensure_origin(origin, &collective_id)?; + Self::do_remove_member(collective_id, who)?; + Ok(()) + } + + /// Atomically replace `remove` with `add` in `collective_id`. + /// Member count is preserved, so a swap is allowed even when + /// the collective sits at its `min_members` or `max_members` + /// bound. Swap-with-self is rejected. + #[pallet::call_index(2)] + #[pallet::weight( + T::WeightInfo::swap_member().saturating_add(T::OnMembersChanged::weight()) + )] + pub fn swap_member( + origin: OriginFor, + collective_id: T::CollectiveId, + remove: T::AccountId, + add: T::AccountId, + ) -> DispatchResult { + T::SwapOrigin::ensure_origin(origin, &collective_id)?; + T::Collectives::info(collective_id).ok_or(Error::::CollectiveNotFound)?; + + Members::::try_mutate(collective_id, |members| -> DispatchResult { + let pos_remove = members + .binary_search(&remove) + .map_err(|_| Error::::NotMember)?; + let pos_add = members + .binary_search(&add) + .err() + .ok_or(Error::::AlreadyMember)?; + members.remove(pos_remove); + // After removing index `pos_remove`, every position strictly + // greater than it has shifted down by one. The branch guards + // `pos_add >= 1`, so `saturating_sub` is exact here. + let insert_at = if pos_remove < pos_add { + pos_add.saturating_sub(1) + } else { + pos_add + }; + members + .try_insert(insert_at, add.clone()) + .map_err(|_| Error::::TooManyMembers)?; + Ok(()) + })?; + + T::OnMembersChanged::on_members_changed( + collective_id, + core::slice::from_ref(&add), + core::slice::from_ref(&remove), + ); + Self::deposit_event(Event::MemberSwapped { + collective_id, + removed: remove, + added: add, + }); + Ok(()) + } + + /// Replace the full membership of `collective_id` with `members`. + /// The input may be in any order but must contain no duplicates; + /// the call does not silently deduplicate. + #[pallet::call_index(3)] + #[pallet::weight( + T::WeightInfo::set_members().saturating_add(T::OnMembersChanged::weight()) + )] + pub fn set_members( + origin: OriginFor, + collective_id: T::CollectiveId, + members: Vec, + ) -> DispatchResult { + T::SetOrigin::ensure_origin(origin, &collective_id)?; + Self::do_set_members(collective_id, members)?; + Ok(()) + } + + /// Trigger a rotation of `collective_id` on demand, ahead of its + /// scheduled cadence. Used to bootstrap the first term (the + /// natural cadence only fires after the first term boundary, + /// which can be days or months away) and as a privileged + /// override during incidents. + /// + /// Only valid for collectives that have a configured rotation + /// cadence. Calls against a non-rotating collective fail with + /// `CollectiveDoesNotRotate` rather than silently consuming + /// weight. + #[pallet::call_index(4)] + #[pallet::weight( + T::WeightInfo::force_rotate().saturating_add(T::OnNewTerm::weight()) + )] + pub fn force_rotate( + origin: OriginFor, + collective_id: T::CollectiveId, + ) -> DispatchResultWithPostInfo { + T::RotateOrigin::ensure_origin(origin, &collective_id)?; + let info = T::Collectives::info(collective_id).ok_or(Error::::CollectiveNotFound)?; + ensure!( + info.term_duration.is_some(), + Error::::CollectiveDoesNotRotate + ); + + Ok(Some( + T::WeightInfo::force_rotate() + .saturating_add(T::OnNewTerm::on_new_term(collective_id)), + ) + .into()) + } + } +} + +impl Pallet { + pub fn do_add_member( + collective_id: T::CollectiveId, + who: T::AccountId, + ) -> Result<(), Error> { + let info = T::Collectives::info(collective_id).ok_or(Error::::CollectiveNotFound)?; + + Members::::try_mutate(collective_id, |members| -> Result<(), Error> { + let pos = members + .binary_search(&who) + .err() + .ok_or(Error::::AlreadyMember)?; + if let Some(max) = info.max_members { + ensure!(members.len() < max as usize, Error::::TooManyMembers); + } + members + .try_insert(pos, who.clone()) + .map_err(|_| Error::::TooManyMembers)?; + Ok(()) + })?; + + T::OnMembersChanged::on_members_changed(collective_id, core::slice::from_ref(&who), &[]); + Self::deposit_event(Event::MemberAdded { collective_id, who }); + + Ok(()) + } + + pub fn do_remove_member( + collective_id: T::CollectiveId, + who: T::AccountId, + ) -> Result<(), Error> { + let info = T::Collectives::info(collective_id).ok_or(Error::::CollectiveNotFound)?; + + Members::::try_mutate(collective_id, |members| -> Result<(), Error> { + let pos = members + .binary_search(&who) + .map_err(|_| Error::::NotMember)?; + ensure!( + members.len() > info.min_members as usize, + Error::::TooFewMembers + ); + members.remove(pos); + Ok(()) + })?; + + T::OnMembersChanged::on_members_changed(collective_id, &[], core::slice::from_ref(&who)); + Self::deposit_event(Event::MemberRemoved { collective_id, who }); + + Ok(()) + } + + pub fn do_set_members( + collective_id: T::CollectiveId, + members: Vec, + ) -> Result<(), Error> { + let info = T::Collectives::info(collective_id).ok_or(Error::::CollectiveNotFound)?; + + ensure!( + members.len() >= info.min_members as usize, + Error::::TooFewMembers + ); + ensure!( + members.len() <= T::MaxMembers::get() as usize, + Error::::TooManyMembers + ); + if let Some(max) = info.max_members { + ensure!(members.len() <= max as usize, Error::::TooManyMembers); + } + + let len_before = members.len(); + let mut sorted = members; + sorted.sort(); + sorted.dedup(); + ensure!(sorted.len() == len_before, Error::::DuplicateAccounts); + + let old_members = Members::::get(collective_id); + let bounded = + BoundedVec::try_from(sorted.clone()).map_err(|_| Error::::TooManyMembers)?; + Members::::insert(collective_id, bounded); + + let (incoming, outgoing) = + <() as ChangeMembers>::compute_members_diff_sorted(&sorted, &old_members); + + T::OnMembersChanged::on_members_changed(collective_id, &incoming, &outgoing); + Self::deposit_event(Event::MembersSet { + collective_id, + incoming, + outgoing, + }); + + Ok(()) + } + + /// Validates the `CollectivesInfo` configuration against the + /// pallet's storage cap. Called from the `integrity_test` hook + /// at construction; extracted so tests can drive it directly. + /// + /// Guards against `CollectiveInfo` / `T::MaxMembers` mismatch: a + /// runtime declaring `max_members` (or `min_members`) greater + /// than `T::MaxMembers` would pass the per-collective cap check + /// in `add_member` / `set_members` but then fail the `BoundedVec` + /// bound with a confusing `TooManyMembers` at the storage + /// ceiling. Failing construction here makes the inconsistent + /// config unreachable at runtime. + /// + /// Alternative structural fix (not taken): drop `max_members` + /// from `CollectiveInfo` and expose it via a per-collective + /// method on `CollectivesInfo` computed against `T::MaxMembers` + /// (e.g. `fn max_members_of(id) -> u32`). That eliminates the + /// field mismatch by construction at the cost of a + /// `CollectivesInfo` trait-shape change. + pub fn check_integrity() { + let storage_max = T::MaxMembers::get(); + for collective in T::Collectives::collectives() { + let info = collective.info; + + assert!( + info.min_members <= storage_max, + "CollectiveInfo::min_members ({}) exceeds T::MaxMembers ({}); collective cannot reach its min", + info.min_members, + storage_max, + ); + + if let Some(max) = info.max_members { + assert!( + max <= storage_max, + "CollectiveInfo::max_members ({}) exceeds T::MaxMembers ({}); storage cannot hold this many", + max, + storage_max, + ); + assert!( + info.min_members <= max, + "CollectiveInfo::min_members ({}) exceeds max_members ({}); collective is unreachable", + info.min_members, + max, + ); + } + + // `Some(0)` for term_duration is indistinguishable from "rotate + // every block" at the type level, but the `n % td` check in + // `on_initialize` short-circuits via `checked_rem` and never + // fires. Reject it here rather than let a misconfigured runtime + // silently disable rotations. Use `None` to opt out. + if let Some(td) = info.term_duration { + assert!( + !td.is_zero(), + "CollectiveInfo::term_duration = Some(0) silently disables rotations; use None to opt out", + ); + } + } + } + + /// Storage-state invariants checked by `try-runtime`. Iterates the + /// `Members` map and verifies, for every entry: + /// + /// - the member list is strictly sorted ascending (no duplicates, + /// matching the invariant relied on by `binary_search` and the + /// linear-merge diff in `set_members`); + /// - the `collective_id` is registered in `T::Collectives`, so no + /// orphan rows survive a misconfigured runtime upgrade; + /// - the member count fits the per-collective `info.max_members`, + /// in addition to the type-level `T::MaxMembers` bound that + /// `BoundedVec` already enforces. + /// + /// `info.min_members` is intentionally not asserted here: a + /// freshly registered collective has no `Members` entry until its + /// first mutation, which would trip a strict lower-bound check. + #[cfg(any(feature = "try-runtime", test))] + pub fn do_try_state() -> Result<(), frame_support::sp_runtime::TryRuntimeError> { + for (collective_id, members) in Members::::iter() { + ensure!( + members.windows(2).all(|w| matches!(w, [a, b] if a < b)), + "Members storage is not strictly sorted ascending" + ); + + let info = T::Collectives::info(collective_id) + .ok_or("Members entry references an unregistered collective")?; + + if let Some(max) = info.max_members { + ensure!( + members.len() as u32 <= max, + "Member count exceeds CollectiveInfo::max_members" + ); + } + } + + Ok(()) + } +} + +// Detailed information about a collective. +pub struct CollectiveInfo { + pub name: Name, + /// Minimum number of members for a collective. + pub min_members: u32, + /// Maximum number of members for a collective. + pub max_members: Option, + /// The duration of the term for a collective. + pub term_duration: Option, +} + +/// Collective groups the information of a collective with its corresponding identifier. +pub struct Collective { + /// Identifier of the collective. + pub id: Id, + /// Information about the collective. + pub info: CollectiveInfo, +} + +/// Information on the collectives. +pub trait CollectivesInfo { + /// The identifier for a collective. + type Id: Parameter + MaxEncodedLen + Copy + Ord + PartialOrd + Send + Sync + 'static; + + /// Return the sorted iterable list of known collectives. + fn collectives() -> impl Iterator>; + + /// Return the list of identifiers of the known collectives. + fn collective_ids() -> impl Iterator { + Self::collectives().map(|c| c.id) + } + + /// Return the collective info for collective `id`, by default this just looks it up in `Self::collectives()`. + fn info(id: Self::Id) -> Option> { + Self::collectives().find(|c| c.id == id).map(|c| c.info) + } +} + +/// Handler for when a new term of a collective has started. +pub trait OnNewTerm { + /// A new term of a collective has started. Returns the actual weight + /// consumed so `on_initialize` can accumulate per-block hook weight + /// across all rotating collectives. + fn on_new_term(collective_id: CollectiveId) -> Weight; + + /// Worst-case upper bound on `on_new_term`'s weight, used to + /// pre-charge `force_rotate`. + fn weight() -> Weight; +} + +#[impl_trait_for_tuples::impl_for_tuples(10)] +impl OnNewTerm for Tuple { + // `for_tuples!` mutates `weight` inline; clippy can't see the expansion. + #[allow(clippy::let_and_return)] + fn on_new_term(collective_id: CollectiveId) -> Weight { + let mut weight = Weight::zero(); + for_tuples!( #( weight = weight.saturating_add(Tuple::on_new_term(collective_id.clone())); )* ); + weight + } + + fn weight() -> Weight { + #[allow(clippy::let_and_return)] + let mut weight = Weight::zero(); + for_tuples!( #( weight.saturating_accrue(Tuple::weight()); )* ); + weight + } +} + +/// Trait for inspecting a collective. +pub trait CollectiveInspect { + /// Return the members of a collective. + fn members_of(collective_id: CollectiveId) -> Vec; + + /// Return true once the collective's membership storage is initialized. + fn is_initialized(collective_id: CollectiveId) -> bool; + + /// Return true if an account is a member of a collective. + fn is_member(collective_id: CollectiveId, who: &AccountId) -> bool; + + /// Return the number of members of a collective. + fn member_count(collective_id: CollectiveId) -> u32; +} + +impl CollectiveInspect for Pallet { + fn members_of(collective_id: T::CollectiveId) -> Vec { + Members::::get(collective_id).to_vec() + } + + fn is_initialized(collective_id: T::CollectiveId) -> bool { + Members::::contains_key(collective_id) + } + + fn is_member(collective_id: T::CollectiveId, who: &T::AccountId) -> bool { + Members::::get(collective_id).binary_search(who).is_ok() + } + + fn member_count(collective_id: T::CollectiveId) -> u32 { + Members::::get(collective_id).len() as u32 + } +} diff --git a/pallets/multi-collective/src/mock.rs b/pallets/multi-collective/src/mock.rs new file mode 100644 index 0000000000..b2e5e88262 --- /dev/null +++ b/pallets/multi-collective/src/mock.rs @@ -0,0 +1,322 @@ +#![allow( + clippy::arithmetic_side_effects, + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing +)] + +use core::cell::RefCell; + +use frame_support::{ + derive_impl, + pallet_prelude::*, + parameter_types, + sp_runtime::{BuildStorage, traits::IdentityLookup}, + traits::AsEnsureOriginWithArg, +}; +use frame_system::EnsureRoot; +use sp_core::U256; + +use crate::{ + self as pallet_multi_collective, Collective, CollectiveInfo, CollectivesInfo, OnMembersChanged, + OnNewTerm, +}; + +type Block = frame_system::mocking::MockBlock; + +frame_support::construct_runtime!( + pub enum Test { + System: frame_system = 1, + MultiCollective: pallet_multi_collective = 2, + } +); + +// --- CollectiveId enum --- + +#[derive( + Copy, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, +)] +pub enum CollectiveId { + Alpha, + Beta, + Gamma, + Delta, + /// Intentionally NOT returned by `TestCollectives::collectives()`; used + /// to exercise the `CollectiveNotFound` error path in extrinsics. + Unknown, +} + +// --- CollectivesInfo impl --- + +pub fn name_bytes(s: &[u8]) -> [u8; 32] { + let mut n = [0u8; 32]; + let len = s.len().min(32); + n[..len].copy_from_slice(&s[..len]); + n +} + +pub struct TestCollectives; + +// Optional override used by the integrity-test panic tests. When set, +// `TestCollectives::collectives()` returns the override's output instead of +// the default config. A function pointer is used (not a Vec) so the type +// stays `Copy`. +thread_local! { + static COLLECTIVES_OVERRIDE: RefCell< + Option Vec>>, + > = const { RefCell::new(None) }; +} + +fn default_collectives() -> Vec> { + vec![ + Collective { + id: CollectiveId::Alpha, + info: CollectiveInfo { + name: name_bytes(b"alpha"), + min_members: 0, + max_members: Some(5), + term_duration: None, + }, + }, + Collective { + id: CollectiveId::Beta, + info: CollectiveInfo { + name: name_bytes(b"beta"), + min_members: 2, + max_members: Some(3), + term_duration: Some(100), + }, + }, + Collective { + id: CollectiveId::Gamma, + info: CollectiveInfo { + name: name_bytes(b"gamma"), + min_members: 0, + max_members: None, + term_duration: None, + }, + }, + Collective { + id: CollectiveId::Delta, + info: CollectiveInfo { + name: name_bytes(b"delta"), + min_members: 1, + max_members: Some(32), + term_duration: Some(50), + }, + }, + ] +} + +fn effective_collectives() -> Vec> { + let override_fn = COLLECTIVES_OVERRIDE.with(|o| *o.borrow()); + match override_fn { + Some(f) => f(), + None => default_collectives(), + } +} + +/// Run `f` with `TestCollectives` temporarily returning the output of +/// `override_fn`. An RAII guard clears the override when `f` returns *or +/// panics*, so a `#[should_panic]` integrity test cannot leak state onto +/// other tests running on the same thread. +pub fn with_collectives_override( + override_fn: fn() -> Vec>, + f: impl FnOnce() -> R, +) -> R { + struct Guard; + impl Drop for Guard { + fn drop(&mut self) { + COLLECTIVES_OVERRIDE.with(|o| *o.borrow_mut() = None); + } + } + + COLLECTIVES_OVERRIDE.with(|o| *o.borrow_mut() = Some(override_fn)); + let _guard = Guard; + f() +} + +impl CollectivesInfo for TestCollectives { + type Id = CollectiveId; + + fn collectives() -> impl Iterator> { + effective_collectives().into_iter() + } +} + +// --- Recording stubs for the pallet's two hooks --- +// +// `OnNewTerm` has no event counterpart; the rotation tests need the log to +// observe firings. `OnMembersChanged` is observable indirectly through the +// pallet's events, but the events do not show what was passed to the hook, +// so the recorder lets the hook-payload tests pin the exact arguments. + +thread_local! { + static NEW_TERM_LOG: RefCell> = const { RefCell::new(Vec::new()) }; + static NEW_TERM_WEIGHT: RefCell = const { RefCell::new(Weight::zero()) }; + static MEMBERS_CHANGED_LOG: RefCell> = + const { RefCell::new(Vec::new()) }; +} + +pub struct TestOnNewTerm; + +impl OnNewTerm for TestOnNewTerm { + fn on_new_term(id: CollectiveId) -> Weight { + NEW_TERM_LOG.with(|log| log.borrow_mut().push(id)); + NEW_TERM_WEIGHT.with(|w| *w.borrow()) + } + + fn weight() -> Weight { + NEW_TERM_WEIGHT.with(|w| *w.borrow()) + } +} + +/// Drain and return the recorded `OnNewTerm` calls since the last drain. +pub fn take_new_term_log() -> Vec { + NEW_TERM_LOG.with(|log| log.borrow_mut().drain(..).collect()) +} + +/// Set the weight that `TestOnNewTerm::on_new_term` reports back. Used by +/// `force_rotate` to assert that the post-info weight is the static +/// `WeightInfo::force_rotate()` plus the actual hook weight. +pub fn set_new_term_weight(weight: Weight) { + NEW_TERM_WEIGHT.with(|w| *w.borrow_mut() = weight); +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct MembersChangedCall { + pub collective_id: CollectiveId, + pub incoming: Vec, + pub outgoing: Vec, +} + +pub struct TestOnMembersChanged; + +impl OnMembersChanged for TestOnMembersChanged { + fn on_members_changed(collective_id: CollectiveId, incoming: &[U256], outgoing: &[U256]) { + MEMBERS_CHANGED_LOG.with(|log| { + log.borrow_mut().push(MembersChangedCall { + collective_id, + incoming: incoming.to_vec(), + outgoing: outgoing.to_vec(), + }) + }); + } + + fn weight() -> Weight { + Weight::zero() + } +} + +/// Drain and return the recorded `OnMembersChanged` calls since the last drain. +pub fn take_members_changed_log() -> Vec { + MEMBERS_CHANGED_LOG.with(|log| log.borrow_mut().drain(..).collect()) +} + +/// Returns the `pallet_multi_collective::Event` values recorded in +/// `System::events()` so far, in insertion order. +pub fn multi_collective_events() -> Vec> { + System::events() + .into_iter() + .filter_map(|r| match r.event { + RuntimeEvent::MultiCollective(e) => Some(e), + _ => None, + }) + .collect() +} + +// --- frame_system --- + +#[derive_impl(frame_system::config_preludes::TestDefaultConfig)] +impl frame_system::Config for Test { + type Block = Block; + type AccountId = U256; + type Lookup = IdentityLookup; +} + +// --- pallet_multi_collective --- + +parameter_types! { + pub const MaxMembers: u32 = 32; +} + +impl pallet_multi_collective::Config for Test { + type CollectiveId = CollectiveId; + type Collectives = TestCollectives; + type AddOrigin = AsEnsureOriginWithArg>; + type RemoveOrigin = AsEnsureOriginWithArg>; + type SwapOrigin = AsEnsureOriginWithArg>; + type SetOrigin = AsEnsureOriginWithArg>; + type RotateOrigin = AsEnsureOriginWithArg>; + type OnMembersChanged = TestOnMembersChanged; + type OnNewTerm = TestOnNewTerm; + type MaxMembers = MaxMembers; + type WeightInfo = (); + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = TestBenchmarkHelper; +} + +#[cfg(feature = "runtime-benchmarks")] +pub struct TestBenchmarkHelper; + +#[cfg(feature = "runtime-benchmarks")] +impl pallet_multi_collective::BenchmarkHelper for TestBenchmarkHelper { + fn collective() -> CollectiveId { + // Gamma: max_members = None, min_members = 0 → can fill to MaxMembers + // and drain to empty without tripping the per-collective bounds. + CollectiveId::Gamma + } + + fn rotatable_collective() -> CollectiveId { + // Beta has term_duration = Some(100). + CollectiveId::Beta + } +} + +// --- Test externality builder --- + +/// Build a fresh `TestExternalities` for the mock runtime. Used directly +/// by `impl_benchmark_test_suite!`; `TestState::build_and_execute` wraps +/// this with the per-test bootstrap unit tests rely on. +pub fn new_test_ext() -> sp_io::TestExternalities { + RuntimeGenesisConfig::default() + .build_storage() + .unwrap() + .into() +} + +pub struct TestState; + +impl TestState { + pub fn build_and_execute(test: impl FnOnce()) { + let mut ext = new_test_ext(); + + ext.execute_with(|| { + // System::events() only records events from block >= 1, so + // setting the block first means each test starts with an empty + // events buffer. + System::set_block_number(1); + let _ = take_new_term_log(); + let _ = take_members_changed_log(); + set_new_term_weight(Weight::zero()); + test(); + }); + } +} + +/// Advance to block `n`, invoking `on_finalize(k-1)` + `on_initialize(k)` for +/// each block `k` from the current block+1 up to and including `n`. +pub fn run_to_block(n: u64) { + System::run_to_block::(n); +} diff --git a/pallets/multi-collective/src/tests.rs b/pallets/multi-collective/src/tests.rs new file mode 100644 index 0000000000..43eff7b4d9 --- /dev/null +++ b/pallets/multi-collective/src/tests.rs @@ -0,0 +1,1617 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use frame_support::{BoundedVec, assert_noop, assert_ok, traits::Hooks, weights::Weight}; +use sp_core::U256; +use sp_runtime::DispatchError; + +use crate::{ + Collective, CollectiveInfo, CollectiveInspect, Error, Event as CollectiveEvent, OnNewTerm, + Pallet as MultiCollective, mock::*, +}; + +#[test] +fn add_member_happy_path() { + TestState::build_and_execute(|| { + let mid = U256::from(5); + let head = U256::from(2); + let tail = U256::from(8); + let between = U256::from(4); + + // Exercises the four insertion positions that `binary_search` can + // return: empty list, before the first element, after the last, + // and into the middle. A regression replacing the sorted insert + // with `push` would only be caught by the head and middle cases. + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + mid, + )); + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![mid] + ); + assert!(MultiCollective::::is_member( + CollectiveId::Alpha, + &mid + )); + + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + head, + )); + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![head, mid] + ); + + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + tail, + )); + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![head, mid, tail] + ); + + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + between, + )); + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![head, between, mid, tail] + ); + + assert_eq!( + MultiCollective::::member_count(CollectiveId::Alpha), + 4 + ); + + assert_eq!( + multi_collective_events(), + vec![ + CollectiveEvent::MemberAdded { + collective_id: CollectiveId::Alpha, + who: mid, + }, + CollectiveEvent::MemberAdded { + collective_id: CollectiveId::Alpha, + who: head, + }, + CollectiveEvent::MemberAdded { + collective_id: CollectiveId::Alpha, + who: tail, + }, + CollectiveEvent::MemberAdded { + collective_id: CollectiveId::Alpha, + who: between, + }, + ] + ); + }); +} + +#[test] +fn add_member_requires_origin() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + let caller = U256::from(999); + + assert_noop!( + MultiCollective::::add_member( + RuntimeOrigin::signed(caller), + CollectiveId::Alpha, + alice, + ), + DispatchError::BadOrigin + ); + + assert!(MultiCollective::::members_of(CollectiveId::Alpha).is_empty()); + assert!(multi_collective_events().is_empty()); + }); +} + +#[test] +fn add_member_fails_for_unknown_collective() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + + assert_noop!( + MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Unknown, + alice, + ), + Error::::CollectiveNotFound + ); + + assert!(multi_collective_events().is_empty()); + }); +} + +#[test] +fn add_member_rejects_duplicate() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + alice, + )); + + assert_noop!( + MultiCollective::::add_member(RuntimeOrigin::root(), CollectiveId::Alpha, alice,), + Error::::AlreadyMember + ); + + // Only one MemberAdded event; the failing call produced nothing. + assert_eq!( + multi_collective_events(), + vec![CollectiveEvent::MemberAdded { + collective_id: CollectiveId::Alpha, + who: alice, + }] + ); + assert_eq!( + MultiCollective::::member_count(CollectiveId::Alpha), + 1 + ); + }); +} + +#[test] +fn add_member_respects_info_max() { + TestState::build_and_execute(|| { + // Alpha declares max_members = Some(5). Fill it exactly to capacity. + for i in 1..=5u32 { + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + U256::from(i), + )); + } + assert_eq!( + MultiCollective::::member_count(CollectiveId::Alpha), + 5 + ); + + assert_noop!( + MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + U256::from(6), + ), + Error::::TooManyMembers + ); + + assert_eq!( + MultiCollective::::member_count(CollectiveId::Alpha), + 5 + ); + // Exactly five events; nothing from the failing 6th. + assert_eq!(multi_collective_events().len(), 5); + }); +} + +#[test] +fn add_member_respects_storage_max_when_info_max_none() { + TestState::build_and_execute(|| { + // Gamma's `info.max_members` is None; only `T::MaxMembers = 32` applies. + for i in 1..=32u32 { + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Gamma, + U256::from(i), + )); + } + assert_eq!( + MultiCollective::::member_count(CollectiveId::Gamma), + 32 + ); + + // 33rd add fails via `try_insert` (BoundedVec bound) rather than the info cap. + assert_noop!( + MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Gamma, + U256::from(33), + ), + Error::::TooManyMembers + ); + + assert_eq!( + MultiCollective::::member_count(CollectiveId::Gamma), + 32 + ); + assert_eq!(multi_collective_events().len(), 32); + }); +} + +#[test] +fn remove_member_happy_path() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + let bob = U256::from(2); + let charlie = U256::from(3); + + for who in [alice, bob, charlie] { + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + who, + )); + } + + // Remove from the middle. + assert_ok!(MultiCollective::::remove_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + bob, + )); + + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![alice, charlie] + ); + assert!(!MultiCollective::::is_member( + CollectiveId::Alpha, + &bob + )); + assert_eq!( + MultiCollective::::member_count(CollectiveId::Alpha), + 2 + ); + + // Remove from the head. A swap-remove would leave the list + // unsorted (`[charlie, ...]` shifting via swap), so asserting + // that the remaining tail stays in order discriminates against + // that regression. + assert_ok!(MultiCollective::::remove_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + alice, + )); + + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![charlie] + ); + assert!(!MultiCollective::::is_member( + CollectiveId::Alpha, + &alice + )); + assert_eq!( + MultiCollective::::member_count(CollectiveId::Alpha), + 1 + ); + + assert_eq!( + multi_collective_events().last(), + Some(&CollectiveEvent::MemberRemoved { + collective_id: CollectiveId::Alpha, + who: alice, + }) + ); + }); +} + +#[test] +fn remove_member_requires_origin() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + alice, + )); + + assert_noop!( + MultiCollective::::remove_member( + RuntimeOrigin::signed(U256::from(999)), + CollectiveId::Alpha, + alice, + ), + DispatchError::BadOrigin + ); + + assert!(MultiCollective::::is_member( + CollectiveId::Alpha, + &alice + )); + }); +} + +#[test] +fn remove_member_fails_for_unknown_collective() { + TestState::build_and_execute(|| { + assert_noop!( + MultiCollective::::remove_member( + RuntimeOrigin::root(), + CollectiveId::Unknown, + U256::from(1), + ), + Error::::CollectiveNotFound + ); + + assert!(multi_collective_events().is_empty()); + }); +} + +#[test] +fn remove_member_rejects_non_member() { + TestState::build_and_execute(|| { + assert_noop!( + MultiCollective::::remove_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + U256::from(1), + ), + Error::::NotMember + ); + + assert!(multi_collective_events().is_empty()); + }); +} + +#[test] +fn remove_member_respects_min() { + TestState::build_and_execute(|| { + // Beta declares min_members = 2. Seed exactly to the floor. + let alice = U256::from(1); + let bob = U256::from(2); + for who in [alice, bob] { + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Beta, + who, + )); + } + + assert_noop!( + MultiCollective::::remove_member( + RuntimeOrigin::root(), + CollectiveId::Beta, + alice, + ), + Error::::TooFewMembers + ); + + assert_eq!(MultiCollective::::member_count(CollectiveId::Beta), 2); + }); +} + +#[test] +fn remove_member_allows_down_to_min() { + TestState::build_and_execute(|| { + // Beta has min_members = 2; seed with one above. + let alice = U256::from(1); + let bob = U256::from(2); + let charlie = U256::from(3); + for who in [alice, bob, charlie] { + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Beta, + who, + )); + } + + // Removing once leaves the collective at min_members; the check is + // `len() > min_members` so post-removal len == min_members is allowed. + assert_ok!(MultiCollective::::remove_member( + RuntimeOrigin::root(), + CollectiveId::Beta, + charlie, + )); + + assert_eq!(MultiCollective::::member_count(CollectiveId::Beta), 2); + assert!(!MultiCollective::::is_member( + CollectiveId::Beta, + &charlie + )); + + assert_eq!( + multi_collective_events().last(), + Some(&CollectiveEvent::MemberRemoved { + collective_id: CollectiveId::Beta, + who: charlie, + }) + ); + }); +} + +#[test] +fn swap_member_happy_path() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + let bob = U256::from(2); + let charlie = U256::from(3); + let dave = U256::from(4); + let zara = U256::from(10); + + for who in [alice, bob, charlie] { + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + who, + )); + } + + // Swap the middle member for an account that sorts to the tail. + assert_ok!(MultiCollective::::swap_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + bob, + dave, + )); + + // Members are kept sorted: dave (4) goes after charlie (3). + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![alice, charlie, dave] + ); + assert!(!MultiCollective::::is_member( + CollectiveId::Alpha, + &bob + )); + assert!(MultiCollective::::is_member( + CollectiveId::Alpha, + &dave + )); + + assert_eq!( + multi_collective_events().last(), + Some(&CollectiveEvent::MemberSwapped { + collective_id: CollectiveId::Alpha, + removed: bob, + added: dave, + }) + ); + + // Swap the head member for an account that sorts to the tail. + // A swap-remove regression on the remove side would leave the + // resulting list unsorted, so this exercises both sides of the + // sorted invariant. + assert_ok!(MultiCollective::::swap_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + alice, + zara, + )); + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![charlie, dave, zara] + ); + }); +} + +#[test] +fn swap_member_requires_origin() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + alice, + )); + + assert_noop!( + MultiCollective::::swap_member( + RuntimeOrigin::signed(U256::from(999)), + CollectiveId::Alpha, + alice, + U256::from(2), + ), + DispatchError::BadOrigin + ); + + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![alice] + ); + }); +} + +#[test] +fn swap_member_fails_for_unknown_collective() { + TestState::build_and_execute(|| { + assert_noop!( + MultiCollective::::swap_member( + RuntimeOrigin::root(), + CollectiveId::Unknown, + U256::from(1), + U256::from(2), + ), + Error::::CollectiveNotFound + ); + + assert!(multi_collective_events().is_empty()); + }); +} + +#[test] +fn swap_member_rejects_missing_remove() { + TestState::build_and_execute(|| { + assert_noop!( + MultiCollective::::swap_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + U256::from(1), + U256::from(2), + ), + Error::::NotMember + ); + + assert!(multi_collective_events().is_empty()); + }); +} + +#[test] +fn swap_member_rejects_existing_add() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + let bob = U256::from(2); + + for who in [alice, bob] { + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + who, + )); + } + + assert_noop!( + MultiCollective::::swap_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + alice, + bob, + ), + Error::::AlreadyMember + ); + + // Both still present, in their original order. + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![alice, bob] + ); + }); +} + +#[test] +fn swap_member_rejects_self_swap() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + alice, + )); + + // `remove` matches a member, so `NotMember` doesn't fire; the next + // check (`!contains(add)`) rejects because add is already present + // (it is `remove` itself). "Swap with self" is a no-op the pallet + // refuses. + assert_noop!( + MultiCollective::::swap_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + alice, + alice, + ), + Error::::AlreadyMember + ); + + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![alice] + ); + }); +} + +/// Beta has `min_members = 2, max_members = 3`. Swap is count-invariant +/// and skips both bounds checks, so it must succeed at either end. +/// Setup walks the collective from min to max via `add_member`, then +/// swaps once at each bound. +#[test] +fn swap_member_works_at_bounds() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + let bob = U256::from(2); + let carol = U256::from(3); + let dave = U256::from(4); + let erin = U256::from(5); + + for who in [alice, bob] { + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Beta, + who, + )); + } + + // At min: swap alice for carol. + assert_ok!(MultiCollective::::swap_member( + RuntimeOrigin::root(), + CollectiveId::Beta, + alice, + carol, + )); + assert_eq!(MultiCollective::::member_count(CollectiveId::Beta), 2); + assert!(!MultiCollective::::is_member( + CollectiveId::Beta, + &alice + )); + assert!(MultiCollective::::is_member( + CollectiveId::Beta, + &carol + )); + + // Grow to max, then at max: swap carol for dave. + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Beta, + dave, + )); + assert_eq!(MultiCollective::::member_count(CollectiveId::Beta), 3); + + assert_ok!(MultiCollective::::swap_member( + RuntimeOrigin::root(), + CollectiveId::Beta, + carol, + erin, + )); + assert_eq!(MultiCollective::::member_count(CollectiveId::Beta), 3); + assert!(!MultiCollective::::is_member( + CollectiveId::Beta, + &carol + )); + assert!(MultiCollective::::is_member( + CollectiveId::Beta, + &erin + )); + }); +} + +#[test] +fn set_members_replaces_list() { + TestState::build_and_execute(|| { + let a = U256::from(1); + let b = U256::from(2); + let c = U256::from(3); + let d = U256::from(4); + let e = U256::from(5); + + for who in [a, b] { + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + who, + )); + } + + assert_ok!(MultiCollective::::set_members( + RuntimeOrigin::root(), + CollectiveId::Alpha, + vec![c, d, e], + )); + + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![c, d, e] + ); + assert!(!MultiCollective::::is_member(CollectiveId::Alpha, &a)); + assert!(!MultiCollective::::is_member(CollectiveId::Alpha, &b)); + + assert_eq!( + multi_collective_events().last(), + Some(&CollectiveEvent::MembersSet { + collective_id: CollectiveId::Alpha, + outgoing: vec![a, b], + incoming: vec![c, d, e], + }) + ); + }); +} + +#[test] +fn set_members_handles_overlap() { + TestState::build_and_execute(|| { + let a = U256::from(1); + let b = U256::from(2); + let c = U256::from(3); + let d = U256::from(4); + + for who in [a, b, c] { + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + who, + )); + } + + // [b, c, d] overlaps with the old [a, b, c]: b and c stay, a goes out, + // d comes in. Final storage reflects the new list verbatim. + assert_ok!(MultiCollective::::set_members( + RuntimeOrigin::root(), + CollectiveId::Alpha, + vec![b, c, d], + )); + + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![b, c, d] + ); + + assert_eq!( + multi_collective_events().last(), + Some(&CollectiveEvent::MembersSet { + collective_id: CollectiveId::Alpha, + outgoing: vec![a], + incoming: vec![d], + }) + ); + }); +} + +#[test] +fn set_members_requires_origin() { + TestState::build_and_execute(|| { + assert_noop!( + MultiCollective::::set_members( + RuntimeOrigin::signed(U256::from(999)), + CollectiveId::Alpha, + vec![U256::from(1)], + ), + DispatchError::BadOrigin + ); + + assert!(MultiCollective::::members_of(CollectiveId::Alpha).is_empty()); + assert!(multi_collective_events().is_empty()); + }); +} + +#[test] +fn set_members_fails_for_unknown_collective() { + TestState::build_and_execute(|| { + assert_noop!( + MultiCollective::::set_members( + RuntimeOrigin::root(), + CollectiveId::Unknown, + vec![U256::from(1)], + ), + Error::::CollectiveNotFound + ); + + assert!(multi_collective_events().is_empty()); + }); +} + +#[test] +fn set_members_rejects_too_few() { + TestState::build_and_execute(|| { + // Beta declares min_members = 2. + assert_noop!( + MultiCollective::::set_members( + RuntimeOrigin::root(), + CollectiveId::Beta, + vec![U256::from(1)], + ), + Error::::TooFewMembers + ); + + assert!(MultiCollective::::members_of(CollectiveId::Beta).is_empty()); + assert!(multi_collective_events().is_empty()); + }); +} + +#[test] +fn set_members_rejects_too_many_via_info() { + TestState::build_and_execute(|| { + // Beta declares max_members = Some(3); four accounts is one over. + let list: Vec = (1..=4u32).map(U256::from).collect(); + assert_noop!( + MultiCollective::::set_members(RuntimeOrigin::root(), CollectiveId::Beta, list,), + Error::::TooManyMembers + ); + + assert!(MultiCollective::::members_of(CollectiveId::Beta).is_empty()); + assert!(multi_collective_events().is_empty()); + }); +} + +#[test] +fn set_members_rejects_too_many_via_storage() { + TestState::build_and_execute(|| { + // Gamma's info.max_members is None; only T::MaxMembers = 32 applies. + // 33 accounts exceed the BoundedVec bound, caught by try_from. + let list: Vec = (1..=33u32).map(U256::from).collect(); + assert_noop!( + MultiCollective::::set_members(RuntimeOrigin::root(), CollectiveId::Gamma, list,), + Error::::TooManyMembers + ); + + assert!(MultiCollective::::members_of(CollectiveId::Gamma).is_empty()); + }); +} + +#[test] +fn set_members_rejects_duplicates() { + TestState::build_and_execute(|| { + let a = U256::from(1); + let b = U256::from(2); + + assert_noop!( + MultiCollective::::set_members( + RuntimeOrigin::root(), + CollectiveId::Alpha, + vec![a, b, a], + ), + Error::::DuplicateAccounts + ); + + assert!(MultiCollective::::members_of(CollectiveId::Alpha).is_empty()); + }); +} + +/// Setting a list identical to the current membership still emits a +/// `MembersSet` event; the pallet doesn't short-circuit no-op sets. +/// Pinned so downstream consumers know they must tolerate empty-diff calls. +#[test] +fn set_members_noop_still_fires_event() { + TestState::build_and_execute(|| { + let a = U256::from(1); + let b = U256::from(2); + + for who in [a, b] { + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + who, + )); + } + + assert_ok!(MultiCollective::::set_members( + RuntimeOrigin::root(), + CollectiveId::Alpha, + vec![a, b], + )); + + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![a, b] + ); + + assert_eq!( + multi_collective_events().last(), + Some(&CollectiveEvent::MembersSet { + collective_id: CollectiveId::Alpha, + incoming: vec![], + outgoing: vec![], + }) + ); + }); +} + +#[test] +fn on_initialize_no_rotation_when_term_duration_none() { + TestState::build_and_execute(|| { + // Alpha (td=None) and Gamma (td=None) must never appear in the log + // regardless of how many blocks pass. + run_to_block(300); + + let log = take_new_term_log(); + assert!( + !log.contains(&CollectiveId::Alpha), + "Alpha has term_duration = None; should never rotate" + ); + assert!( + !log.contains(&CollectiveId::Gamma), + "Gamma has term_duration = None; should never rotate" + ); + }); +} + +#[test] +fn on_initialize_no_rotation_between_boundaries() { + TestState::build_and_execute(|| { + // Earliest boundary is Delta's at block 50. Before that, nothing fires. + run_to_block(49); + assert!(take_new_term_log().is_empty()); + }); +} + +#[test] +fn on_initialize_fires_rotation_at_modulo_boundary() { + TestState::build_and_execute(|| { + // Delta (td=50) first fires at block 50. The "no rotation between + // boundaries" property is covered by + // `on_initialize_no_rotation_between_boundaries`. + run_to_block(50); + assert_eq!(take_new_term_log(), vec![CollectiveId::Delta]); + }); +} + +#[test] +fn on_initialize_fires_all_matching_collectives() { + TestState::build_and_execute(|| { + // Advance through the first shared boundary at block 100. Delta fires + // at 50, then both Beta and Delta fire at 100. Iteration order in + // `TestCollectives` is [Alpha, Beta, Gamma, Delta], so within block + // 100 the log gets Beta before Delta. + run_to_block(100); + + assert_eq!( + take_new_term_log(), + vec![ + CollectiveId::Delta, // block 50 + CollectiveId::Beta, // block 100 + CollectiveId::Delta, // block 100 + ] + ); + + // Next cadence: only Delta at 150, both again at 200. + run_to_block(150); + assert_eq!(take_new_term_log(), vec![CollectiveId::Delta]); + + run_to_block(200); + assert_eq!( + take_new_term_log(), + vec![CollectiveId::Beta, CollectiveId::Delta] + ); + }); +} + +#[test] +fn force_rotate_routes_through_on_new_term() { + TestState::build_and_execute(|| { + // Beta has term_duration = Some(100), so it's eligible. + assert_ok!(MultiCollective::::force_rotate( + RuntimeOrigin::root(), + CollectiveId::Beta, + )); + assert_eq!(take_new_term_log(), vec![CollectiveId::Beta]); + }); +} + +#[test] +fn force_rotate_requires_origin() { + TestState::build_and_execute(|| { + assert_noop!( + MultiCollective::::force_rotate( + RuntimeOrigin::signed(U256::from(1)), + CollectiveId::Beta, + ), + DispatchError::BadOrigin, + ); + assert!(take_new_term_log().is_empty()); + }); +} + +#[test] +fn force_rotate_rejects_non_rotating_collective() { + TestState::build_and_execute(|| { + // Alpha has `term_duration: None`. + assert_noop!( + MultiCollective::::force_rotate(RuntimeOrigin::root(), CollectiveId::Alpha,), + Error::::CollectiveDoesNotRotate, + ); + assert!(take_new_term_log().is_empty()); + }); +} + +#[test] +fn force_rotate_rejects_unknown_collective() { + TestState::build_and_execute(|| { + assert_noop!( + MultiCollective::::force_rotate(RuntimeOrigin::root(), CollectiveId::Unknown,), + Error::::CollectiveNotFound, + ); + assert!(take_new_term_log().is_empty()); + }); +} + +#[test] +fn inspect_is_member_basic() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + let mallory = U256::from(999); + + // Empty collective: no membership. + assert!(!MultiCollective::::is_member( + CollectiveId::Alpha, + &alice + )); + + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + alice, + )); + + assert!(MultiCollective::::is_member( + CollectiveId::Alpha, + &alice + )); + assert!(!MultiCollective::::is_member( + CollectiveId::Alpha, + &mallory + )); + // Membership is per-collective; alice isn't in Beta. + assert!(!MultiCollective::::is_member( + CollectiveId::Beta, + &alice + )); + }); +} + +#[test] +fn inspect_member_count_matches_mutations() { + TestState::build_and_execute(|| { + let a = U256::from(1); + let b = U256::from(2); + let c = U256::from(3); + let d = U256::from(4); + + assert_eq!( + MultiCollective::::member_count(CollectiveId::Alpha), + 0 + ); + + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + a, + )); + assert_eq!( + MultiCollective::::member_count(CollectiveId::Alpha), + 1 + ); + + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + b, + )); + assert_eq!( + MultiCollective::::member_count(CollectiveId::Alpha), + 2 + ); + + // Swap is count-invariant. + assert_ok!(MultiCollective::::swap_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + a, + c, + )); + assert_eq!( + MultiCollective::::member_count(CollectiveId::Alpha), + 2 + ); + + // Remove decrements by one. + assert_ok!(MultiCollective::::remove_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + b, + )); + assert_eq!( + MultiCollective::::member_count(CollectiveId::Alpha), + 1 + ); + + // `set_members` replaces wholesale; count reflects the new list length. + assert_ok!(MultiCollective::::set_members( + RuntimeOrigin::root(), + CollectiveId::Alpha, + vec![a, b, c, d], + )); + assert_eq!( + MultiCollective::::member_count(CollectiveId::Alpha), + 4 + ); + }); +} + +#[test] +fn inspect_of_unknown_collective_returns_empty() { + TestState::build_and_execute(|| { + // `Unknown` is not registered in TestCollectives::collectives(). + // `Members` storage uses ValueQuery and returns an empty BoundedVec by + // default, so all three reads succeed without error or panic. + assert_eq!( + MultiCollective::::members_of(CollectiveId::Unknown), + Vec::::new() + ); + assert!(!MultiCollective::::is_member( + CollectiveId::Unknown, + &U256::from(1) + )); + assert_eq!( + MultiCollective::::member_count(CollectiveId::Unknown), + 0 + ); + }); +} + +// `integrity_test_passes_on_valid_config` is implicit: the mock's +// auto-generated `__construct_runtime_integrity_test::runtime_integrity_tests` +// runs `integrity_test()` against the default `TestCollectives` on every +// `cargo test`. Listed in test output as `mock::...runtime_integrity_tests`. + +fn bad_min_exceeds_storage() -> Vec> { + vec![Collective { + id: CollectiveId::Alpha, + info: CollectiveInfo { + name: name_bytes(b"bad"), + // T::MaxMembers = 32 in the mock; 100 exceeds storage capacity. + min_members: 100, + max_members: Some(200), + term_duration: None, + }, + }] +} + +fn bad_max_exceeds_storage() -> Vec> { + vec![Collective { + id: CollectiveId::Alpha, + info: CollectiveInfo { + name: name_bytes(b"bad"), + min_members: 0, + // T::MaxMembers = 32; max_members = 100 is declaratively larger. + max_members: Some(100), + term_duration: None, + }, + }] +} + +fn bad_min_exceeds_info_max() -> Vec> { + vec![Collective { + id: CollectiveId::Alpha, + info: CollectiveInfo { + name: name_bytes(b"bad"), + // min > max: the collective can never satisfy both. + min_members: 5, + max_members: Some(3), + term_duration: None, + }, + }] +} + +fn bad_term_duration_zero() -> Vec> { + vec![Collective { + id: CollectiveId::Alpha, + info: CollectiveInfo { + name: name_bytes(b"bad"), + min_members: 0, + max_members: Some(5), + // Some(0) silently disables rotations; integrity_test rejects it. + term_duration: Some(0), + }, + }] +} + +#[test] +#[should_panic(expected = "min_members (100) exceeds T::MaxMembers (32)")] +fn integrity_test_panics_on_min_exceeds_storage_max() { + with_collectives_override(bad_min_exceeds_storage, || { + as Hooks>::integrity_test(); + }); +} + +#[test] +#[should_panic(expected = "max_members (100) exceeds T::MaxMembers (32)")] +fn integrity_test_panics_on_max_exceeds_storage_max() { + with_collectives_override(bad_max_exceeds_storage, || { + as Hooks>::integrity_test(); + }); +} + +#[test] +#[should_panic(expected = "min_members (5) exceeds max_members (3)")] +fn integrity_test_panics_on_min_exceeds_info_max() { + with_collectives_override(bad_min_exceeds_info_max, || { + as Hooks>::integrity_test(); + }); +} + +#[test] +#[should_panic(expected = "silently disables rotations")] +fn integrity_test_panics_on_term_duration_zero() { + with_collectives_override(bad_term_duration_zero, || { + as Hooks>::integrity_test(); + }); +} + +// `OnMembersChanged` payload tests. The pallet's events show what changed +// in storage but not what was passed to the hook, so an argument-order +// regression (e.g. swapping `incoming` and `outgoing`) would not be +// caught by the event assertions alone. + +#[test] +fn on_members_changed_payload_for_add_member() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + alice, + )); + assert_eq!( + take_members_changed_log(), + vec![MembersChangedCall { + collective_id: CollectiveId::Alpha, + incoming: vec![alice], + outgoing: vec![], + }] + ); + }); +} + +#[test] +fn on_members_changed_payload_for_remove_member() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + let bob = U256::from(2); + for who in [alice, bob] { + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + who, + )); + } + let _ = take_members_changed_log(); + + assert_ok!(MultiCollective::::remove_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + bob, + )); + assert_eq!( + take_members_changed_log(), + vec![MembersChangedCall { + collective_id: CollectiveId::Alpha, + incoming: vec![], + outgoing: vec![bob], + }] + ); + }); +} + +#[test] +fn on_members_changed_payload_for_swap_member() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + let bob = U256::from(2); + for who in [alice, bob] { + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + who, + )); + } + let _ = take_members_changed_log(); + + let carol = U256::from(3); + assert_ok!(MultiCollective::::swap_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + alice, + carol, + )); + assert_eq!( + take_members_changed_log(), + vec![MembersChangedCall { + collective_id: CollectiveId::Alpha, + incoming: vec![carol], + outgoing: vec![alice], + }] + ); + }); +} + +#[test] +fn on_members_changed_payload_for_set_members() { + TestState::build_and_execute(|| { + let a = U256::from(1); + let b = U256::from(2); + let c = U256::from(3); + let d = U256::from(4); + for who in [a, b, c] { + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + who, + )); + } + let _ = take_members_changed_log(); + + assert_ok!(MultiCollective::::set_members( + RuntimeOrigin::root(), + CollectiveId::Alpha, + vec![b, c, d], + )); + assert_eq!( + take_members_changed_log(), + vec![MembersChangedCall { + collective_id: CollectiveId::Alpha, + incoming: vec![d], + outgoing: vec![a], + }] + ); + }); +} + +// `do_try_state` direct tests. The extrinsics maintain the invariants by +// construction, so corrupting `Members` storage manually is the only way +// to exercise each failure branch. + +fn write_raw_members(id: CollectiveId, members: Vec) { + let bounded = BoundedVec::try_from(members).expect("test fixture must fit MaxMembers"); + crate::pallet::Members::::insert(id, bounded); +} + +#[test] +fn try_state_passes_on_valid_storage() { + TestState::build_and_execute(|| { + for who in [U256::from(1), U256::from(2)] { + assert_ok!(MultiCollective::::add_member( + RuntimeOrigin::root(), + CollectiveId::Alpha, + who, + )); + } + assert!(MultiCollective::::do_try_state().is_ok()); + }); +} + +#[test] +fn try_state_rejects_unsorted_storage() { + TestState::build_and_execute(|| { + write_raw_members(CollectiveId::Alpha, vec![U256::from(2), U256::from(1)]); + assert!(MultiCollective::::do_try_state().is_err()); + }); +} + +#[test] +fn try_state_rejects_orphan_collective_row() { + TestState::build_and_execute(|| { + // `Unknown` is reachable via the storage map's `Blake2_128Concat` + // hash but is not registered in `TestCollectives::collectives()`. + write_raw_members(CollectiveId::Unknown, vec![U256::from(1)]); + assert!(MultiCollective::::do_try_state().is_err()); + }); +} + +#[test] +fn try_state_rejects_count_exceeding_info_max() { + TestState::build_and_execute(|| { + // Beta declares max_members = 3; four entries fit the BoundedVec + // bound (T::MaxMembers = 32) but violate the per-collective cap. + let four: Vec = (1..=4u32).map(U256::from).collect(); + write_raw_members(CollectiveId::Beta, four); + assert!(MultiCollective::::do_try_state().is_err()); + }); +} + +/// `set_members` sorts its input before writing. Without this step, +/// downstream `binary_search` and `compute_members_diff_sorted` calls +/// would silently observe an unsorted storage entry; pinning the sort +/// here guards against a regression that drops the `sorted.sort()` call. +#[test] +fn set_members_sorts_input() { + TestState::build_and_execute(|| { + let a = U256::from(1); + let b = U256::from(2); + let c = U256::from(3); + + assert_ok!(MultiCollective::::set_members( + RuntimeOrigin::root(), + CollectiveId::Alpha, + vec![c, a, b], + )); + + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![a, b, c] + ); + }); +} + +/// `force_rotate` returns `Some(actual_weight)` equal to +/// `WeightInfo::force_rotate() + OnNewTerm::on_new_term(...)`. The mock's +/// `WeightInfo` is `()`, whose generated impl reports the pallet's base +/// dispatch cost, so the post-info weight should include that static cost +/// plus the hook's reported cost. +#[test] +fn force_rotate_returns_post_info_weight() { + TestState::build_and_execute(|| { + let hook_weight = Weight::from_parts(123_456, 0); + set_new_term_weight(hook_weight); + + let post = MultiCollective::::force_rotate(RuntimeOrigin::root(), CollectiveId::Beta) + .expect("force_rotate succeeds for Beta"); + + assert_eq!( + post.actual_weight, + Some( + <::WeightInfo as crate::WeightInfo>::force_rotate() + .saturating_add(hook_weight) + ) + ); + }); +} + +/// The pallet ships a tuple impl of `OnNewTerm` so a runtime can fan a +/// rotation out to multiple handlers. The mock wires a single impl, so +/// without this test the tuple expansion is not exercised by `cargo test`. +#[test] +fn on_new_term_tuple_impl_dispatches_to_each_member() { + TestState::build_and_execute(|| { + set_new_term_weight(Weight::from_parts(7, 0)); + + let combined = <(TestOnNewTerm, TestOnNewTerm) as OnNewTerm>::on_new_term( + CollectiveId::Beta, + ); + + assert_eq!(combined, Weight::from_parts(14, 0)); + assert_eq!( + take_new_term_log(), + vec![CollectiveId::Beta, CollectiveId::Beta] + ); + + let weight = <(TestOnNewTerm, TestOnNewTerm) as OnNewTerm>::weight(); + assert_eq!(weight, Weight::from_parts(14, 0)); + }); +} + +#[test] +fn do_add_member_inserts_and_emits_event() { + TestState::build_and_execute(|| { + let who = U256::from(7); + + assert_ok!(MultiCollective::::do_add_member( + CollectiveId::Alpha, + who, + )); + + assert_eq!( + MultiCollective::::members_of(CollectiveId::Alpha), + vec![who] + ); + assert_eq!( + take_members_changed_log(), + vec![MembersChangedCall { + collective_id: CollectiveId::Alpha, + incoming: vec![who], + outgoing: vec![], + }] + ); + assert_eq!( + multi_collective_events().last().expect("event emitted"), + &CollectiveEvent::MemberAdded { + collective_id: CollectiveId::Alpha, + who, + }, + ); + }); +} + +#[test] +fn do_add_member_errors_on_already_member() { + TestState::build_and_execute(|| { + let who = U256::from(7); + assert_ok!(MultiCollective::::do_add_member( + CollectiveId::Alpha, + who, + )); + assert!(matches!( + MultiCollective::::do_add_member(CollectiveId::Alpha, who), + Err(Error::::AlreadyMember), + )); + }); +} + +#[test] +fn do_add_member_errors_on_unknown_collective() { + TestState::build_and_execute(|| { + assert!(matches!( + MultiCollective::::do_add_member(CollectiveId::Unknown, U256::from(1)), + Err(Error::::CollectiveNotFound), + )); + }); +} + +#[test] +fn do_add_member_errors_when_max_members_reached() { + TestState::build_and_execute(|| { + // Alpha caps at 5 members. + for i in 0..5u32 { + assert_ok!(MultiCollective::::do_add_member( + CollectiveId::Alpha, + U256::from(i), + )); + } + assert!(matches!( + MultiCollective::::do_add_member(CollectiveId::Alpha, U256::from(99)), + Err(Error::::TooManyMembers), + )); + }); +} + +#[test] +fn do_remove_member_removes_and_emits_event() { + TestState::build_and_execute(|| { + let who = U256::from(7); + assert_ok!(MultiCollective::::do_add_member( + CollectiveId::Alpha, + who, + )); + let _ = take_members_changed_log(); + + assert_ok!(MultiCollective::::do_remove_member( + CollectiveId::Alpha, + who, + )); + + assert!(MultiCollective::::members_of(CollectiveId::Alpha).is_empty()); + assert_eq!( + take_members_changed_log(), + vec![MembersChangedCall { + collective_id: CollectiveId::Alpha, + incoming: vec![], + outgoing: vec![who], + }] + ); + assert_eq!( + multi_collective_events().last().expect("event emitted"), + &CollectiveEvent::MemberRemoved { + collective_id: CollectiveId::Alpha, + who, + }, + ); + }); +} + +#[test] +fn do_remove_member_errors_on_non_member() { + TestState::build_and_execute(|| { + assert!(matches!( + MultiCollective::::do_remove_member(CollectiveId::Alpha, U256::from(7)), + Err(Error::::NotMember), + )); + }); +} + +#[test] +fn do_remove_member_respects_min_members_floor() { + TestState::build_and_execute(|| { + let a = U256::from(1); + let b = U256::from(2); + assert_ok!(MultiCollective::::set_members( + RuntimeOrigin::root(), + CollectiveId::Beta, + vec![a, b], + )); + + // Beta has min_members = 2; dropping below the floor must error. + assert!(matches!( + MultiCollective::::do_remove_member(CollectiveId::Beta, a), + Err(Error::::TooFewMembers), + )); + }); +} + +#[test] +fn do_remove_member_errors_on_unknown_collective() { + TestState::build_and_execute(|| { + assert!(matches!( + MultiCollective::::do_remove_member(CollectiveId::Unknown, U256::from(1)), + Err(Error::::CollectiveNotFound), + )); + }); +} diff --git a/pallets/multi-collective/src/weights.rs b/pallets/multi-collective/src/weights.rs new file mode 100644 index 0000000000..325cb3954c --- /dev/null +++ b/pallets/multi-collective/src/weights.rs @@ -0,0 +1,207 @@ + +//! Autogenerated weights for `pallet_multi_collective` +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 +//! DATE: 2026-05-25, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `runnervmg397c`, CPU: `AMD EPYC 7763 64-Core Processor` +//! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` + +// Executed Command: +// /home/runner/work/subtensor/subtensor/target/production/node-subtensor +// benchmark +// pallet +// --runtime=/home/runner/work/subtensor/subtensor/target/production/wbuild/node-subtensor-runtime/node_subtensor_runtime.compact.compressed.wasm +// --genesis-builder=runtime +// --genesis-builder-preset=benchmark +// --wasm-execution=compiled +// --pallet=pallet_multi_collective +// --extrinsic=* +// --steps=50 +// --repeat=20 +// --no-storage-info +// --no-min-squares +// --no-median-slopes +// --output=/tmp/tmp.8vKpHuHTSt +// --template=/home/runner/work/subtensor/subtensor/.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::ParityDbWeight}}; +use core::marker::PhantomData; + +/// Weight functions needed for `pallet_multi_collective`. +pub trait WeightInfo { + fn add_member() -> Weight; + fn remove_member() -> Weight; + fn swap_member() -> Weight; + fn set_members() -> Weight; + fn force_rotate() -> Weight; + fn do_add_member() -> Weight; + fn do_remove_member() -> Weight; +} + +/// Weights for `pallet_multi_collective` using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn add_member() -> Weight { + // Proof Size summary in bytes: + // Measured: `2104` + // Estimated: `5532` + // Minimum execution time: 13_816_000 picoseconds. + Weight::from_parts(14_247_000, 5532) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn remove_member() -> Weight { + // Proof Size summary in bytes: + // Measured: `2137` + // Estimated: `5532` + // Minimum execution time: 13_575_000 picoseconds. + Weight::from_parts(13_976_000, 5532) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn swap_member() -> Weight { + // Proof Size summary in bytes: + // Measured: `2137` + // Estimated: `5532` + // Minimum execution time: 13_796_000 picoseconds. + Weight::from_parts(14_497_000, 5532) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn set_members() -> Weight { + // Proof Size summary in bytes: + // Measured: `2137` + // Estimated: `5532` + // Minimum execution time: 21_450_000 picoseconds. + Weight::from_parts(22_663_000, 5532) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `MultiCollective::Members` (r:1 w:0) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn force_rotate() -> Weight { + // Proof Size summary in bytes: + // Measured: `42` + // Estimated: `5532` + // Minimum execution time: 22_021_000 picoseconds. + Weight::from_parts(22_632_000, 5532) + .saturating_add(T::DbWeight::get().reads(1_u64)) + } + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn do_add_member() -> Weight { + // Proof Size summary in bytes: + // Measured: `2104` + // Estimated: `5532` + // Minimum execution time: 10_830_000 picoseconds. + Weight::from_parts(11_292_000, 5532) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn do_remove_member() -> Weight { + // Proof Size summary in bytes: + // Measured: `2137` + // Estimated: `5532` + // Minimum execution time: 10_590_000 picoseconds. + Weight::from_parts(11_071_000, 5532) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } +} + +// For backwards compatibility and tests. +impl WeightInfo for () { + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn add_member() -> Weight { + // Proof Size summary in bytes: + // Measured: `2104` + // Estimated: `5532` + // Minimum execution time: 13_816_000 picoseconds. + Weight::from_parts(14_247_000, 5532) + .saturating_add(ParityDbWeight::get().reads(1_u64)) + .saturating_add(ParityDbWeight::get().writes(1_u64)) + } + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn remove_member() -> Weight { + // Proof Size summary in bytes: + // Measured: `2137` + // Estimated: `5532` + // Minimum execution time: 13_575_000 picoseconds. + Weight::from_parts(13_976_000, 5532) + .saturating_add(ParityDbWeight::get().reads(1_u64)) + .saturating_add(ParityDbWeight::get().writes(1_u64)) + } + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn swap_member() -> Weight { + // Proof Size summary in bytes: + // Measured: `2137` + // Estimated: `5532` + // Minimum execution time: 13_796_000 picoseconds. + Weight::from_parts(14_497_000, 5532) + .saturating_add(ParityDbWeight::get().reads(1_u64)) + .saturating_add(ParityDbWeight::get().writes(1_u64)) + } + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn set_members() -> Weight { + // Proof Size summary in bytes: + // Measured: `2137` + // Estimated: `5532` + // Minimum execution time: 21_450_000 picoseconds. + Weight::from_parts(22_663_000, 5532) + .saturating_add(ParityDbWeight::get().reads(1_u64)) + .saturating_add(ParityDbWeight::get().writes(1_u64)) + } + /// Storage: `MultiCollective::Members` (r:1 w:0) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn force_rotate() -> Weight { + // Proof Size summary in bytes: + // Measured: `42` + // Estimated: `5532` + // Minimum execution time: 22_021_000 picoseconds. + Weight::from_parts(22_632_000, 5532) + .saturating_add(ParityDbWeight::get().reads(1_u64)) + } + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn do_add_member() -> Weight { + // Proof Size summary in bytes: + // Measured: `2104` + // Estimated: `5532` + // Minimum execution time: 10_830_000 picoseconds. + Weight::from_parts(11_292_000, 5532) + .saturating_add(ParityDbWeight::get().reads(1_u64)) + .saturating_add(ParityDbWeight::get().writes(1_u64)) + } + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn do_remove_member() -> Weight { + // Proof Size summary in bytes: + // Measured: `2137` + // Estimated: `5532` + // Minimum execution time: 10_590_000 picoseconds. + Weight::from_parts(11_071_000, 5532) + .saturating_add(ParityDbWeight::get().reads(1_u64)) + .saturating_add(ParityDbWeight::get().writes(1_u64)) + } +}