From 7dd68fa0124fd154ce794a4a6a9e6d1fbdddc4d9 Mon Sep 17 00:00:00 2001 From: Loris Moulin Date: Sun, 28 Jun 2026 22:59:37 -0300 Subject: [PATCH 1/6] Empty commit From cc7d48db3323fcacacc4e2d32e7feee979161e7b Mon Sep 17 00:00:00 2001 From: Loris Moulin Date: Sun, 28 Jun 2026 22:56:42 -0300 Subject: [PATCH 2/6] Introduce pallet-multi-collective --- Cargo.lock | 18 + Cargo.toml | 1 + common/Cargo.toml | 1 + common/src/lib.rs | 2 + common/src/traits.rs | 34 + pallets/multi-collective/Cargo.toml | 54 + pallets/multi-collective/README.md | 99 ++ pallets/multi-collective/src/benchmarking.rs | 150 ++ pallets/multi-collective/src/lib.rs | 679 ++++++++ pallets/multi-collective/src/mock.rs | 322 ++++ pallets/multi-collective/src/tests.rs | 1617 ++++++++++++++++++ pallets/multi-collective/src/weights.rs | 207 +++ 12 files changed, 3184 insertions(+) create mode 100644 common/src/traits.rs create mode 100644 pallets/multi-collective/Cargo.toml create mode 100644 pallets/multi-collective/README.md create mode 100644 pallets/multi-collective/src/benchmarking.rs create mode 100644 pallets/multi-collective/src/lib.rs create mode 100644 pallets/multi-collective/src/mock.rs create mode 100644 pallets/multi-collective/src/tests.rs create mode 100644 pallets/multi-collective/src/weights.rs diff --git a/Cargo.lock b/Cargo.lock index b49277401c..c1a7353984 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10230,6 +10230,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" @@ -18538,6 +18555,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 6f160c29e4..e66ecc3a06 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-bigmath = { package = "safe-bigmath", default-features = false, git = "https://github.com/sam0x17/safe-bigmath", rev = "013c49984910e1c9a23289e8c85e7a856e263a02" } safe-math = { path = "primitives/safe-math", default-features = false } diff --git a/common/Cargo.toml b/common/Cargo.toml index 9fa9bd1856..5fd69b4431 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -14,6 +14,7 @@ targets = ["x86_64-unknown-linux-gnu"] codec = { workspace = true, features = ["derive"] } environmental.workspace = true frame-support.workspace = true +impl-trait-for-tuples.workspace = true num-traits = { workspace = true, features = ["libm"] } scale-info.workspace = true serde.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)) + } +} From 0ebd2130d0ad3cd798b41e2521bba8271d5303a4 Mon Sep 17 00:00:00 2001 From: Loris Moulin Date: Sun, 28 Jun 2026 23:09:03 -0300 Subject: [PATCH 3/6] Introduce pallet-signed-voting --- Cargo.lock | 17 + Cargo.toml | 1 + common/src/lib.rs | 31 +- common/src/traits.rs | 102 ++ pallets/signed-voting/Cargo.toml | 54 ++ pallets/signed-voting/README.md | 117 +++ pallets/signed-voting/src/benchmarking.rs | 154 +++ pallets/signed-voting/src/lib.rs | 619 ++++++++++++ pallets/signed-voting/src/mock.rs | 325 +++++++ pallets/signed-voting/src/tests.rs | 1075 +++++++++++++++++++++ pallets/signed-voting/src/weights.rs | 251 +++++ 11 files changed, 2745 insertions(+), 1 deletion(-) create mode 100644 pallets/signed-voting/Cargo.toml create mode 100644 pallets/signed-voting/README.md create mode 100644 pallets/signed-voting/src/benchmarking.rs create mode 100644 pallets/signed-voting/src/lib.rs create mode 100644 pallets/signed-voting/src/mock.rs create mode 100644 pallets/signed-voting/src/tests.rs create mode 100644 pallets/signed-voting/src/weights.rs diff --git a/Cargo.lock b/Cargo.lock index c1a7353984..2143df2e66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10780,6 +10780,23 @@ dependencies = [ "subtensor-runtime-common", ] +[[package]] +name = "pallet-signed-voting" +version = "1.0.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "log", + "parity-scale-codec", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "subtensor-macros", + "subtensor-runtime-common", +] + [[package]] name = "pallet-skip-feeless-payment" version = "16.0.0" diff --git a/Cargo.toml b/Cargo.toml index e66ecc3a06..913cf5bc5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ 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 } +pallet-signed-voting = { path = "pallets/signed-voting", default-features = false } procedural-fork = { path = "support/procedural-fork", default-features = false } safe-bigmath = { package = "safe-bigmath", default-features = false, git = "https://github.com/sam0x17/safe-bigmath", rev = "013c49984910e1c9a23289e8c85e7a856e263a02" } safe-math = { path = "primitives/safe-math", default-features = false } diff --git a/common/src/lib.rs b/common/src/lib.rs index 92095b29b3..a31ef1d078 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -9,7 +9,7 @@ use runtime_common::prod_or_fast; use scale_info::TypeInfo; use serde::{Deserialize, Serialize}; use sp_runtime::{ - MultiSignature, Vec, + MultiSignature, Perbill, Vec, traits::{IdentifyAccount, Verify}, }; use subtensor_macros::freeze_struct; @@ -525,6 +525,35 @@ impl TypeInfo for NetUidStorageIndex { } } +#[derive( + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + PartialEq, + Eq, + Clone, + Copy, + TypeInfo, + Debug, +)] +#[freeze_struct("51505f4d98347bff")] +pub struct VoteTally { + pub approval: Perbill, + pub rejection: Perbill, + pub abstention: Perbill, +} + +impl Default for VoteTally { + fn default() -> Self { + Self { + approval: Perbill::zero(), + rejection: Perbill::zero(), + abstention: Perbill::one(), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/common/src/traits.rs b/common/src/traits.rs index 349d387fa5..928bee04ab 100644 --- a/common/src/traits.rs +++ b/common/src/traits.rs @@ -1,4 +1,106 @@ +use super::VoteTally; use frame_support::pallet_prelude::*; +use sp_runtime::Vec; + +pub trait SetLike { + fn contains(&self, item: &T) -> bool; + fn len(&self) -> u32; + fn is_initialized(&self) -> bool; + fn is_empty(&self) -> bool { + self.len() == 0 + } + /// Materialize the set as a `Vec`. Used by signed-voting to snapshot + /// the voter set at poll creation. Implementations must return each + /// distinct member exactly once; ordering is unspecified. + fn to_vec(&self) -> Vec; +} + +/// Poll provider seen from the voting pallet's side. Carries the +/// read-only queries plus the tally-update notification fired when a +/// vote moves the tally. +pub trait Polls { + type Index: Parameter + Copy + MaxEncodedLen; + type VotingScheme: PartialEq; + type VoterSet: SetLike; + + fn is_ongoing(index: Self::Index) -> bool; + fn voting_scheme_of(index: Self::Index) -> Option; + fn voter_set_of(index: Self::Index) -> Option; + + fn on_tally_updated(index: Self::Index, tally: &VoteTally); + /// Worst-case upper bound on `on_tally_updated`'s weight. + fn on_tally_updated_weight() -> Weight; +} + +/// Notification fired when a poll is created. +/// +/// # Producer contract +/// +/// Implementations are entitled to assume: +/// +/// 1. `on_poll_created(p)` is called at most once per `(p, lifecycle)`, +/// where `lifecycle` is the span between this hook and the matching +/// `OnPollCompleted::on_poll_completed(p)`. A second call for the +/// same index without an intervening completion is a contract +/// violation: implementations should treat it as a no-op (so a buggy +/// producer cannot silently clobber tallies) but are not required to +/// detect every form of misuse. +/// 2. `Polls::is_ongoing(p)` and `Polls::voting_scheme_of(p)` return +/// consistent values for the duration of the lifecycle. +/// 3. `Polls::voter_set_of(p)` may be queried during this hook. +pub trait OnPollCreated { + fn on_poll_created(poll_index: PollIndex); + /// Returns the worst-case upper bound on `on_poll_created`'s weight. + fn weight() -> Weight; +} + +/// Notification fired when a poll reaches a terminal status. +/// +/// # Producer contract +/// +/// Implementations are entitled to assume: +/// +/// 1. `on_poll_completed(p)` is called at most once per `(p, lifecycle)`. +/// 2. The producer may have already updated `p`'s status to a terminal +/// value before firing this hook, so `Polls::voting_scheme_of(p)` is +/// not required to return `Some` here. Implementations that need to +/// distinguish polls owned by a specific scheme should rely on +/// locally-stored state rather than re-querying the producer. +/// 3. `on_poll_completed` must not synchronously call back into the +/// producer in a way that would re-enter `OnPollCreated`. +pub trait OnPollCompleted { + fn on_poll_completed(poll_index: PollIndex); + /// Returns the worst-case upper bound on `on_poll_completed`'s weight. + fn weight() -> Weight; +} + +#[impl_trait_for_tuples::impl_for_tuples(10)] +impl OnPollCreated for Tuple { + fn on_poll_created(poll_index: I) { + for_tuples!( #( Tuple::on_poll_created(poll_index); )* ); + } + + fn weight() -> Weight { + #[allow(clippy::let_and_return)] + let mut weight = Weight::zero(); + for_tuples!( #( weight.saturating_accrue(Tuple::weight()); )* ); + weight + } +} + +#[impl_trait_for_tuples::impl_for_tuples(10)] +impl OnPollCompleted for Tuple { + fn on_poll_completed(poll_index: I) { + for_tuples!( #( Tuple::on_poll_completed(poll_index); )* ); + } + + fn weight() -> Weight { + #[allow(clippy::let_and_return)] + let mut weight = Weight::zero(); + for_tuples!( #( weight.saturating_accrue(Tuple::weight()); )* ); + weight + } +} /// Handler for when the members of a collective have changed. pub trait OnMembersChanged { diff --git a/pallets/signed-voting/Cargo.toml b/pallets/signed-voting/Cargo.toml new file mode 100644 index 0000000000..392b9f42bf --- /dev/null +++ b/pallets/signed-voting/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "pallet-signed-voting" +version = "1.0.0" +authors = ["Bittensor Nucleus Team"] +edition.workspace = true +license = "Apache-2.0" +homepage = "https://bittensor.com" +description = "A pallet for signed voting" +readme = "README.md" + +[lints] +workspace = true + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { workspace = true, features = ["max-encoded-len"] } +log = { workspace = true } +scale-info = { workspace = true, features = ["derive"] } +frame-benchmarking = { workspace = true, optional = true } +frame-system = { workspace = true } +frame-support = { workspace = true } +subtensor-macros.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", + "log/std", + "scale-info/std", + "frame-benchmarking?/std", + "frame-system/std", + "frame-support/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/signed-voting/README.md b/pallets/signed-voting/README.md new file mode 100644 index 0000000000..1037847f7d --- /dev/null +++ b/pallets/signed-voting/README.md @@ -0,0 +1,117 @@ +# pallet-signed-voting + +A per-account voting backend for a poll producer (typically +`pallet-referenda`). Each call records a single voter's aye or nay; the +tally is pushed back to the producer in real time so it can re-evaluate +thresholds and conclude polls without scheduler nudges. + +The pallet is generic over the producer. It does not know what is being +voted on, only that polls have an index, a voting scheme, and an +eligibility roster. + +## Architecture + +``` + ┌──────────────────┐ + │ Producer pallet │ (e.g. pallet-referenda) + │ is_ongoing │ + │ voting_scheme │ <─── implements Polls + │ voter_set_of │ + │ on_tally_updated│ + └──┬────────────┬──┘ + on_poll_created│ │ on_tally_updated + on_poll_completed │ + ▼ │ + ┌──────────────────┐ + │ pallet-signed │ + │ -voting │ <─── this pallet + │ │ + │ vote(poll, aye) │ + │ remove_vote(...) │ + └──────────────────┘ +``` + +The producer asks the pallet's hooks (`OnPollCreated`, +`OnPollCompleted`) when polls open and close; the pallet asks the +producer's `Polls` trait for the voter set and pushes tally updates +back through it. + +## Lifecycle + +| Event | What the pallet does | +| ------------------ | -------------------------------------------------------- | +| `on_poll_created` | Snapshot the voter set into `VoterSetOf` (sorted and deduplicated), seed `TallyOf` with `total = snapshot.len()`. Skipped for polls whose scheme does not match `T::Scheme`, or if a tally already exists for the index. | +| `vote` | Verify eligibility against the snapshot via `binary_search`, update `VotingFor` and `TallyOf`, push the new tally to the producer. | +| `remove_vote` | Roll back the caller's `VotingFor` entry, decrement `TallyOf`, push the new tally to the producer. | +| `on_poll_completed`| Remove `TallyOf` and `VoterSetOf` synchronously; enqueue the poll on `PendingCleanup` for lazy `VotingFor` cleanup. No-op if no tally exists for the index. | +| `on_idle` | Drain `PendingCleanup` head in `CleanupChunkSize` chunks until the queue is empty or the idle budget is exhausted. | + +## Design notes + +### Frozen voter-set snapshot + +The eligibility roster is whatever `Polls::voter_set_of` returns at +poll creation. After that the underlying collective can rotate freely +without affecting active polls: + +- Removed members keep the voting rights they had when the poll + opened. +- New members cannot vote on polls created before they joined. +- The denominator (`SignedVoteTally::total`) stays fixed so thresholds + cannot drift mid-poll. + +The snapshot is sorted once at creation so eligibility checks are +`O(log n)` per vote. + +### Lazy `VotingFor` cleanup + +`VotingFor` grows linearly with `voters × active polls`. Clearing the +prefix synchronously in `on_poll_completed` would put unbounded work +on the producer's call. Instead, completion enqueues the poll on +`PendingCleanup` and `on_idle` reclaims the storage in +`CleanupChunkSize`-sized chunks. Cleanup of one poll may span multiple +idle blocks; the resume cursor returned by `clear_prefix` is persisted +between passes so already-removed entries are not re-iterated. + +If `on_idle` cannot keep up and the queue overflows +`MaxPendingCleanup`, the pallet emits `CleanupQueueFull`, logs an +error, and leaks the overflowing poll's `VotingFor` entries. +Correctness is preserved (the entries are unread once `TallyOf` is +gone) but the storage is only reclaimable via a follow-up migration. + +Sizing `MaxPendingCleanup` is a throughput question, not just a +simultaneous-active-poll question: drain rate (`on_idle` budget, +`CleanupChunkSize`) must keep up with completion rate over time. +Setting it to a small multiple of the producer's `MaxQueued` gives +headroom for bursts where many polls complete in close succession +while `on_idle` is starved by full blocks. The pallet's +`integrity_test` rejects a zero value for `MaxPendingCleanup`, +`CleanupChunkSize`, or `MaxVoterSetSize` at boot. + +## Configuration + +```rust +parameter_types! { + pub const Scheme: VotingScheme = VotingScheme::Signed; + pub const MaxVoterSetSize: u32 = 64; // ≥ widest track's voter set + pub const MaxPendingCleanup: u32 = 40; // ≥ producer's MaxQueued, with headroom for bursts + pub const CleanupChunkSize: u32 = 16; // entries per idle drain step + pub const CleanupCursorMaxLen: u32 = 128; // bound for clear_prefix cursor +} + +impl pallet_signed_voting::Config for Runtime { + type Scheme = Scheme; + type Polls = Referenda; + type MaxVoterSetSize = MaxVoterSetSize; + type MaxPendingCleanup = MaxPendingCleanup; + type CleanupChunkSize = CleanupChunkSize; + type CleanupCursorMaxLen = CleanupCursorMaxLen; + type WeightInfo = pallet_signed_voting::weights::SubstrateWeight; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = SignedVotingBenchmarkHelper; +} +``` + +## License + +Apache-2.0. diff --git a/pallets/signed-voting/src/benchmarking.rs b/pallets/signed-voting/src/benchmarking.rs new file mode 100644 index 0000000000..f6cbd5e294 --- /dev/null +++ b/pallets/signed-voting/src/benchmarking.rs @@ -0,0 +1,154 @@ +//! Benchmarks for `pallet-signed-voting`. +//! +//! Setup is parameterised through [`Config::BenchmarkHelper`]: the runtime +//! supplies an ongoing poll index whose [`Polls::voting_scheme_of`] matches +//! [`Config::Scheme`]. Voter-set storage is populated directly, bypassing +//! [`OnPollCreated`], so each extrinsic benchmark can exercise the worst +//! case at a chosen `voters` count without rebuilding the producer's state. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use super::*; +use alloc::vec::Vec; +use frame_benchmarking::v2::*; +#[allow(unused_imports)] +use frame_system::RawOrigin; + +const SEED: u32 = 0; + +/// Runtime-supplied bootstrap for benchmarks. +#[cfg(feature = "runtime-benchmarks")] +pub trait BenchmarkHelper { + /// Return a poll index for which `T::Polls::is_ongoing` is true and + /// `T::Polls::voting_scheme_of` matches `T::Scheme::get()`. The + /// runtime should bootstrap this via its real [`Polls`] producer. + fn ongoing_poll() -> PollIndexOf; +} + +/// Pre-populate `VoterSetOf` and `TallyOf` for `index` with `voters` +/// distinct synthetic accounts, sorted to match the storage invariant +/// (`on_poll_created` sorts before insert). Returns the accounts in +/// sorted order. +fn populate_snapshot(index: PollIndexOf, voters: u32) -> Vec { + let mut accounts: Vec = (0..voters) + .map(|i| account::("voter", i, SEED)) + .collect(); + accounts.sort(); + let snapshot: BoundedVec = + BoundedVec::try_from(accounts.clone()) + .expect("benchmark voter count must respect MaxVoterSetSize"); + VoterSetOf::::insert(index, snapshot); + TallyOf::::insert( + index, + SignedVoteTally { + ayes: 0, + nays: 0, + total: voters, + }, + ); + accounts +} + +#[benchmarks] +mod benches { + use super::*; + + /// `vote` worst case: no prior vote (so the `None` branch of + /// `try_vote` runs). Snapshot is sorted, so `binary_search` is + /// `O(log v)` regardless of which voter is chosen; we pick the last + /// for determinism. `v` parameterises snapshot size. + #[benchmark] + fn vote(v: Linear<1, { T::MaxVoterSetSize::get() }>) { + let index = T::BenchmarkHelper::ongoing_poll(); + let accounts = populate_snapshot::(index, v); + let who = accounts.last().expect("voters >= 1").clone(); + + #[extrinsic_call] + vote(RawOrigin::Signed(who.clone()), index, true); + + let tally = TallyOf::::get(index).unwrap(); + assert_eq!(tally.ayes, 1); + assert_eq!(VotingFor::::get(index, who), Some(true)); + } + + /// `remove_vote` worst case: existing aye vote so the tally + /// decrement runs. + #[benchmark] + fn remove_vote(v: Linear<1, { T::MaxVoterSetSize::get() }>) { + let index = T::BenchmarkHelper::ongoing_poll(); + let accounts = populate_snapshot::(index, v); + let who = accounts.last().expect("voters >= 1").clone(); + Pallet::::vote(RawOrigin::Signed(who.clone()).into(), index, true) + .expect("vote setup must succeed"); + + #[extrinsic_call] + remove_vote(RawOrigin::Signed(who.clone()), index); + + assert_eq!(VotingFor::::get(index, who), None); + } + + /// `OnPollCreated` hook: invokes `T::Polls::voter_set_of`, + /// materialises and sorts the result, and writes the snapshot. + /// The runtime helper provisions a poll on its widest track (the + /// Adjustable one) so this measures the worst-case voter-set size + /// available on-chain. No parameter: the size is fixed by the + /// runtime's track configuration, not by the benchmark. + #[benchmark] + fn on_poll_created() { + let index = T::BenchmarkHelper::ongoing_poll(); + // Strip the snapshot the producer may have already inserted so + // the hook re-runs the materialisation path under the bench's + // weight measurement. + VoterSetOf::::remove(index); + TallyOf::::remove(index); + + #[block] + { + as OnPollCreated>>::on_poll_created(index); + } + + assert!(VoterSetOf::::get(index).is_some()); + } + + /// `OnPollCompleted` hook: removes the snapshot and tally, queues + /// the poll for lazy `VotingFor` cleanup. Fixed cost, independent of + /// the number of voters. + #[benchmark] + fn on_poll_completed() { + let index = T::BenchmarkHelper::ongoing_poll(); + let _ = populate_snapshot::(index, T::MaxVoterSetSize::get()); + + #[block] + { + as OnPollCompleted>>::on_poll_completed(index); + } + + assert!(TallyOf::::get(index).is_none()); + } + + /// One drain step of `on_idle`: clears `c` `VotingFor` entries via + /// `clear_prefix`, updates the queue head's cursor or pops it. + /// Parameterised over `c` up to `CleanupChunkSize` (the maximum + /// chunk size the runtime actually uses); values above that are + /// unreachable in production. + #[benchmark] + fn idle_cleanup_chunk(c: Linear<1, { T::CleanupChunkSize::get() }>) { + let index = T::BenchmarkHelper::ongoing_poll(); + let accounts = populate_snapshot::(index, c); + for who in &accounts { + Pallet::::vote(RawOrigin::Signed(who.clone()).into(), index, true) + .expect("vote setup must succeed"); + } + as OnPollCompleted>>::on_poll_completed(index); + + let weight = ::WeightInfo::idle_cleanup_chunk(c); + // Idle weight large enough for exactly one drain iteration. + let budget = weight.saturating_mul(2); + + #[block] + { + let _ = Pallet::::drain_pending_cleanup(budget); + } + } + + impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test); +} diff --git a/pallets/signed-voting/src/lib.rs b/pallets/signed-voting/src/lib.rs new file mode 100644 index 0000000000..d44fceaf43 --- /dev/null +++ b/pallets/signed-voting/src/lib.rs @@ -0,0 +1,619 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +//! # Signed Voting +//! +//! Per-account voting backend for a poll producer (typically +//! `pallet-referenda`). Voters cast a single aye or nay; the tally is +//! pushed back to the producer through the [`Polls`] trait so it can +//! re-evaluate thresholds in real time. +//! +//! The pallet is generic over the producer: it does not know what is +//! being voted on, only that polls have an index, a voting scheme, and +//! a voter set. The producer provides those via [`Polls`]; the pallet +//! provides [`OnPollCreated`] / [`OnPollCompleted`] in return for +//! lifecycle notifications. +//! +//! ## Lifecycle +//! +//! - [`OnPollCreated::on_poll_created`] snapshots the producer's voter +//! set into [`VoterSetOf`] and initialises [`TallyOf`]. Eligibility +//! and the tally denominator are frozen for the poll's lifetime. +//! - [`Pallet::vote`] / [`Pallet::remove_vote`] check eligibility +//! against the snapshot (binary-searched; the snapshot is sorted at +//! creation), update [`VotingFor`] and [`TallyOf`], and notify the +//! producer of the new tally. +//! - [`OnPollCompleted::on_poll_completed`] removes [`TallyOf`] and +//! [`VoterSetOf`] synchronously and enqueues the poll on +//! [`PendingCleanup`] for lazy [`VotingFor`] cleanup. +//! - [`Hooks::on_idle`] drains the cleanup queue in +//! [`Config::CleanupChunkSize`]-sized chunks. A single poll's cleanup +//! may span multiple idle blocks; progress is tracked by the resume +//! cursor returned by `clear_prefix`. +//! +//! ## Frozen voter-set snapshot +//! +//! The eligibility roster is whatever [`Polls::voter_set_of`] returns +//! at `on_poll_created`. After that the underlying collective can +//! rotate freely without affecting active polls: removed members keep +//! the voting rights they had when the poll opened, new members cannot +//! sneak votes onto polls created before they joined, and the +//! denominator stays fixed so thresholds cannot drift mid-poll. +//! +//! ## Lazy `VotingFor` cleanup +//! +//! The vote map grows linearly with `voters × active polls`. Clearing +//! it inside `on_poll_completed` would put unbounded work on the +//! producer's call. Instead, completion records the poll on +//! [`PendingCleanup`] and `on_idle` reclaims the storage in chunks +//! over subsequent blocks. The bound on chunk size and queue capacity +//! is set by the runtime via [`Config::CleanupChunkSize`] and +//! [`Config::MaxPendingCleanup`]. + +extern crate alloc; + +use frame_support::{ + pallet_prelude::*, + sp_runtime::{Perbill, Saturating}, + weights::WeightMeter, +}; +use frame_system::pallet_prelude::*; +use subtensor_runtime_common::{OnPollCompleted, OnPollCreated, Polls, SetLike, VoteTally}; + +pub use pallet::*; +pub use weights::WeightInfo; + +#[cfg(feature = "runtime-benchmarks")] +pub mod benchmarking; +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; +pub mod weights; + +type AccountIdOf = ::AccountId; +type PollIndexOf = <::Polls as Polls>>::Index; +type VotingSchemeOf = <::Polls as Polls>>::VotingScheme; + +/// Raw counts of votes cast on a poll. Converted to the producer's +/// `VoteTally` (Perbill ratios) on every tally update; storing counts +/// on-chain keeps the math exact and makes the `Voted` event payload +/// directly auditable. +#[derive( + Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, PartialEq, Eq, Clone, TypeInfo, Debug, +)] +#[subtensor_macros::freeze_struct("8f9ee43d39e00767")] +pub struct SignedVoteTally { + /// Number of approve votes cast. + pub ayes: u32, + /// Number of reject votes cast. + pub nays: u32, + /// Number of eligible voters at poll creation. + pub total: u32, +} + +impl From for VoteTally { + fn from(value: SignedVoteTally) -> Self { + if value.total == 0 { + // Substrate's `Perbill::from_rational(_, 0)` saturates to + // 100%, so without this short-circuit `approval`, + // `rejection`, and `abstention` would each be 100% and sum + // to 300%. Return the all-abstention default instead. + return VoteTally::default(); + } + let approval = Perbill::from_rational(value.ayes, value.total); + let rejection = Perbill::from_rational(value.nays, value.total); + let abstention = Perbill::one() + .saturating_sub(approval) + .saturating_sub(rejection); + VoteTally { + approval, + rejection, + abstention, + } + } +} + +/// Resume cursor returned by `clear_prefix` and persisted across idle +/// blocks so a poll's cleanup can span multiple drain passes without +/// re-iterating already-removed entries. +pub type CleanupCursorOf = BoundedVec::CleanupCursorMaxLen>; + +#[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 { + /// Voting scheme this backend handles. Polls reporting any + /// other scheme via the `Polls` provider are ignored. + type Scheme: Get>; + + /// Poll producer that owns poll lifecycles, voter sets, and + /// scheme assignment. This pallet only stores tallies and + /// per-voter records for polls the producer announces. + type Polls: Polls; + + /// Upper bound on the size of any track's voter set, used as the + /// storage bound for [`VoterSetOf`]. Must be ≥ the largest set + /// the runtime can produce via [`Polls::voter_set_of`]; runtimes + /// should derive it from their collective `max_members`. + #[pallet::constant] + type MaxVoterSetSize: Get; + + /// Maximum number of polls that can sit in [`PendingCleanup`] at + /// once. Should be ≥ the [`Polls`] provider's cap on + /// simultaneously active polls; a smaller bound risks rejecting + /// cleanup work and leaking storage. + #[pallet::constant] + type MaxPendingCleanup: Get; + + /// Number of `VotingFor` entries cleared per [`Hooks::on_idle`] + /// drain step. Tunes the trade-off between idle-block weight cost + /// and the latency of fully draining a completed poll. + #[pallet::constant] + type CleanupChunkSize: Get; + + /// Storage bound on the resume cursor. The cursor is a partial + /// trie key whose length depends on the storage layout; expose + /// the bound as a constant so it shows up in metadata. 128 is + /// comfortable for any `(poll, account)` shape. + #[pallet::constant] + type CleanupCursorMaxLen: Get; + + type WeightInfo: WeightInfo; + + /// Benchmark setup hook. The runtime supplies an ongoing poll + /// index whose voting scheme matches `Self::Scheme::get()`. + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper: crate::benchmarking::BenchmarkHelper; + } + + /// Per-`(poll, voter)` vote direction. `true` is an aye, `false` a + /// nay; absence means the voter has not cast a vote on this poll. + /// Drained lazily by `on_idle` after `on_poll_completed` enqueues + /// the poll for cleanup. + #[pallet::storage] + pub type VotingFor = StorageDoubleMap< + _, + Twox64Concat, + PollIndexOf, + Twox64Concat, + T::AccountId, + bool, + OptionQuery, + >; + + /// Per-poll tally. Doubles as the index of polls this backend + /// owns: every poll whose scheme matches `T::Scheme` has an entry + /// between `on_poll_created` and `on_poll_completed`, and nowhere + /// else. Polls of other schemes never get one. The cap on + /// simultaneously-live polls comes from the [`Polls`] provider, + /// which is the only producer of `on_poll_created` events. + #[pallet::storage] + pub type TallyOf = + StorageMap<_, Twox64Concat, PollIndexOf, SignedVoteTally, OptionQuery>; + + /// Voter-set snapshot taken at `on_poll_created` and used as the + /// authoritative eligibility roster for the poll's lifetime. Frozen + /// at creation: members rotated in or out of the underlying collective + /// during the poll do not change who can vote here. Cleared by + /// `on_poll_completed` alongside `TallyOf`. + #[pallet::storage] + pub type VoterSetOf = StorageMap< + _, + Twox64Concat, + PollIndexOf, + BoundedVec, + OptionQuery, + >; + + /// FIFO queue of polls awaiting `VotingFor` cleanup. `on_poll_completed` + /// pushes to the back; `on_idle` drains from the front in chunks of + /// `T::CleanupChunkSize`. The optional cursor lets a poll's cleanup + /// span multiple idle blocks without re-iterating already-removed + /// entries. + #[pallet::storage] + pub type PendingCleanup = StorageValue< + _, + BoundedVec<(PollIndexOf, Option>), T::MaxPendingCleanup>, + ValueQuery, + >; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// A member cast or changed a vote on a poll. + Voted { + /// Account that voted. + who: T::AccountId, + /// Poll voted on. + poll_index: PollIndexOf, + /// True for approve, false for reject. + approve: bool, + /// Tally after the vote was applied. + tally: SignedVoteTally, + }, + + /// A member withdrew a previously cast vote. + VoteRemoved { + /// Account that withdrew the vote. + who: T::AccountId, + /// Poll the vote was withdrawn from. + poll_index: PollIndexOf, + /// Tally after the vote was withdrawn. + tally: SignedVoteTally, + }, + + /// A poll concluded but the cleanup queue was full. Per-voter + /// records were left in storage and require operator + /// intervention to reclaim. + CleanupQueueFull { + /// Poll whose records were not queued for cleanup. + poll_index: PollIndexOf, + }, + } + + #[pallet::error] + pub enum Error { + /// The poll has not started or has already concluded. + PollNotOngoing, + /// No poll with this identifier is registered. + PollNotFound, + /// This poll is governed by a different voting scheme. + InvalidVotingScheme, + /// The caller is not eligible to vote on this poll. + NotInVoterSet, + /// The caller has already cast a vote in this direction. + DuplicateVote, + /// The caller has no vote on this poll to withdraw. + VoteNotFound, + /// The poll's eligibility roster is missing. Internal inconsistency. + VoterSetMissing, + /// The poll's tally is missing. Internal inconsistency. + TallyMissing, + } + + #[pallet::hooks] + impl Hooks> for Pallet { + // `on_poll_completed` only enqueues per-voter cleanup; this + // hook is what actually frees the storage. Draining lazily + // here keeps the producer-facing completion path O(1) + // regardless of voter-set size. + fn on_idle(_n: BlockNumberFor, remaining: Weight) -> Weight { + Pallet::::drain_pending_cleanup(remaining) + } + + fn integrity_test() { + // Zero would silently halt cleanup and leak `VotingFor` + // entries forever; reject at boot. + assert!( + T::CleanupChunkSize::get() > 0, + "pallet-signed-voting: CleanupChunkSize must be non-zero", + ); + // A zero pending-cleanup cap would route every completion + // through the overflow branch and leak unconditionally. + assert!( + T::MaxPendingCleanup::get() > 0, + "pallet-signed-voting: MaxPendingCleanup must be non-zero", + ); + // The voter-set snapshot must fit at least one account, or + // every poll degrades to the empty-snapshot defense path. + assert!( + T::MaxVoterSetSize::get() > 0, + "pallet-signed-voting: MaxVoterSetSize must be non-zero", + ); + } + } + + #[pallet::call] + impl Pallet { + /// Cast or change a vote on an ongoing poll. Calling again with + /// the opposite direction flips the vote and updates the tally; + /// calling with the same direction is rejected as a duplicate. + /// + /// The caller must be in the poll's voter-set snapshot taken at + /// creation; eligibility is not affected by membership changes + /// after the poll started. + #[pallet::call_index(0)] + #[pallet::weight( + T::WeightInfo::vote(T::MaxVoterSetSize::get()) + .saturating_add(T::Polls::on_tally_updated_weight()) + )] + pub fn vote( + origin: OriginFor, + poll_index: PollIndexOf, + approve: bool, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + ensure!(T::Polls::is_ongoing(poll_index), Error::::PollNotOngoing); + Self::ensure_valid_voting_scheme(poll_index)?; + Self::ensure_in_voter_set(poll_index, &who)?; + + let tally = Self::try_vote(poll_index, &who, approve)?; + + Self::deposit_event(Event::::Voted { + who, + poll_index, + approve, + tally, + }); + Ok(()) + } + + /// Withdraw a previously-cast vote on an ongoing poll. The + /// tally is rolled back as if the caller had never voted, and + /// the caller may cast a new vote afterwards. + #[pallet::call_index(1)] + #[pallet::weight( + T::WeightInfo::remove_vote(T::MaxVoterSetSize::get()) + .saturating_add(T::Polls::on_tally_updated_weight()) + )] + pub fn remove_vote(origin: OriginFor, poll_index: PollIndexOf) -> DispatchResult { + let who = ensure_signed(origin)?; + + ensure!(T::Polls::is_ongoing(poll_index), Error::::PollNotOngoing); + Self::ensure_valid_voting_scheme(poll_index)?; + Self::ensure_in_voter_set(poll_index, &who)?; + + let tally = Self::try_remove_vote(poll_index, &who)?; + + Self::deposit_event(Event::::VoteRemoved { + who, + poll_index, + tally, + }); + Ok(()) + } + } +} + +impl Pallet { + fn try_vote( + poll_index: PollIndexOf, + who: &T::AccountId, + approve: bool, + ) -> Result { + let mut tally = TallyOf::::get(poll_index).ok_or(Error::::TallyMissing)?; + + VotingFor::::try_mutate(poll_index, who, |vote| -> DispatchResult { + match vote { + Some(vote) => match (vote, approve) { + (true, false) => { + tally.ayes.saturating_dec(); + tally.nays.saturating_inc(); + } + (false, true) => { + tally.nays.saturating_dec(); + tally.ayes.saturating_inc(); + } + _ => return Err(Error::::DuplicateVote.into()), + }, + None => { + if approve { + tally.ayes.saturating_inc(); + } else { + tally.nays.saturating_inc(); + } + } + } + *vote = Some(approve); + Ok(()) + })?; + + TallyOf::::insert(poll_index, tally.clone()); + T::Polls::on_tally_updated(poll_index, &tally.clone().into()); + + Ok(tally) + } + + // Decrement the counter matching the *stored* direction, not + // anything the caller passes in. + fn try_remove_vote( + poll_index: PollIndexOf, + who: &T::AccountId, + ) -> Result { + let mut tally = TallyOf::::get(poll_index).ok_or(Error::::TallyMissing)?; + + VotingFor::::try_mutate_exists(poll_index, who, |vote| -> DispatchResult { + match vote { + Some(vote) => { + if *vote { + tally.ayes.saturating_dec(); + } else { + tally.nays.saturating_dec(); + } + } + None => return Err(Error::::VoteNotFound.into()), + } + *vote = None; + Ok(()) + })?; + + TallyOf::::insert(poll_index, tally.clone()); + T::Polls::on_tally_updated(poll_index, &tally.clone().into()); + + Ok(tally) + } + + // The producer can host multiple voting backends keyed by scheme; + // refuse polls owned by another backend so their tallies can't be + // mutated through this pallet. + fn ensure_valid_voting_scheme(poll_index: PollIndexOf) -> DispatchResult { + let scheme = T::Polls::voting_scheme_of(poll_index).ok_or(Error::::PollNotFound)?; + ensure!(T::Scheme::get() == scheme, Error::::InvalidVotingScheme); + Ok(()) + } + + // O(log n) thanks to the snapshot being sorted at `on_poll_created`. + // The sort cost is paid once; eligibility is read on every vote. + fn ensure_in_voter_set(poll_index: PollIndexOf, who: &T::AccountId) -> DispatchResult { + let voter_set = VoterSetOf::::get(poll_index).ok_or(Error::::VoterSetMissing)?; + voter_set + .binary_search(who) + .map_err(|_| Error::::NotInVoterSet)?; + Ok(()) + } + + // The queue read and write are billed atomically via `entry_cost`: + // we don't read the queue if we can't also afford to write progress + // back. Mutation between iterations happens in memory. + fn drain_pending_cleanup(remaining: Weight) -> Weight { + let chunk = T::CleanupChunkSize::get(); + if chunk == 0 { + return Weight::zero(); + } + let per_step = T::WeightInfo::idle_cleanup_chunk(chunk); + let entry_cost = T::DbWeight::get().reads_writes(1, 1); + let body_cost = per_step.saturating_sub(entry_cost); + let mut meter = WeightMeter::with_limit(remaining); + + if meter.try_consume(entry_cost).is_err() { + return meter.consumed(); + } + let mut queue = PendingCleanup::::get(); + if queue.is_empty() { + return meter.consumed(); + } + + let mut dirty = false; + loop { + if meter.try_consume(body_cost).is_err() { + break; + } + let Some((poll, prev_cursor)) = queue.first().cloned() else { + break; + }; + let result = VotingFor::::clear_prefix( + poll, + chunk, + prev_cursor.as_ref().map(|c| c.as_slice()), + ); + match result.maybe_cursor { + None => { + if !queue.is_empty() { + let _ = queue.remove(0); + } + } + Some(c) => { + // If the cursor exceeds `CleanupCursorMaxLen` it + // gets dropped here; the next pass then restarts + // the prefix and re-iterates already-removed + // entries (slower but still correct). + let bounded = BoundedVec::::try_from(c).ok(); + if let Some(head) = queue.iter_mut().next() { + *head = (poll, bounded); + } + } + } + dirty = true; + if queue.is_empty() { + break; + } + } + + if dirty { + PendingCleanup::::put(queue); + } + meter.consumed() + } +} + +impl OnPollCreated> for Pallet { + fn on_poll_created(poll_index: PollIndexOf) { + if T::Polls::voting_scheme_of(poll_index) != Some(T::Scheme::get()) { + return; + } + + // A second call would clobber `VoterSetOf` and reset the tally, + // silently erasing votes already cast. + if TallyOf::::contains_key(poll_index) { + log::warn!( + target: "runtime::signed-voting", + "on_poll_created called twice for poll {:?}; ignoring", + poll_index, + ); + return; + } + + // Sort + dedup so `ensure_in_voter_set` can `binary_search` and + // a producer returning a multiset cannot inflate `total`. + let snapshot: BoundedVec = + T::Polls::voter_set_of(poll_index) + .map(|s| { + let mut v = s.to_vec(); + v.sort(); + v.dedup(); + v + }) + .and_then(|v| BoundedVec::try_from(v).ok()) + .unwrap_or_default(); + + if snapshot.is_empty() { + log::error!( + target: "runtime::signed-voting", + "on_poll_created received empty or oversized voter set for poll {:?}; \ + producer or runtime configuration is broken", + poll_index, + ); + } + + let total = snapshot.len() as u32; + VoterSetOf::::insert(poll_index, snapshot); + TallyOf::::insert( + poll_index, + SignedVoteTally { + ayes: 0, + nays: 0, + total, + }, + ); + } + + fn weight() -> Weight { + T::WeightInfo::on_poll_created() + } +} + +impl OnPollCompleted> for Pallet { + fn on_poll_completed(poll_index: PollIndexOf) { + // Tally absent means either another backend owns this poll or + // the hook fired twice; either way there is nothing to clean up. + // `voting_scheme_of` is not usable as the scheme gate here: the + // producer transitions status to terminal before firing this hook. + if !TallyOf::::contains_key(poll_index) { + return; + } + + TallyOf::::remove(poll_index); + VoterSetOf::::remove(poll_index); + + let pushed = PendingCleanup::::mutate(|q| q.try_push((poll_index, None)).is_ok()); + if !pushed { + // Failing the hook would tear down the producer's call. + // The orphaned `VotingFor` entries leak storage but are + // unread once `TallyOf` is gone. + log::error!( + target: "runtime::signed-voting", + "PendingCleanup queue full; VotingFor entries for poll {:?} \ + leaked. Raise MaxPendingCleanup or run a cleanup migration.", + poll_index, + ); + Self::deposit_event(Event::::CleanupQueueFull { poll_index }); + } + } + + fn weight() -> Weight { + T::WeightInfo::on_poll_completed() + } +} diff --git a/pallets/signed-voting/src/mock.rs b/pallets/signed-voting/src/mock.rs new file mode 100644 index 0000000000..feeebb8f6a --- /dev/null +++ b/pallets/signed-voting/src/mock.rs @@ -0,0 +1,325 @@ +#![allow( + clippy::arithmetic_side_effects, + clippy::unwrap_used, + clippy::expect_used +)] + +use core::cell::RefCell; +use std::collections::BTreeMap; + +use frame_support::{ + derive_impl, + pallet_prelude::*, + parameter_types, + sp_runtime::{BuildStorage, traits::IdentityLookup}, + weights::constants::RocksDbWeight, +}; +use sp_core::U256; +use subtensor_runtime_common::{OnPollCompleted, OnPollCreated, Polls, SetLike, VoteTally}; + +use crate::{self as pallet_signed_voting}; + +type Block = frame_system::mocking::MockBlock; + +frame_support::construct_runtime!( + pub enum Test { + System: frame_system = 1, + SignedVoting: pallet_signed_voting = 2, + } +); + +#[derive( + Copy, + Clone, + PartialEq, + Eq, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, +)] +pub enum VotingScheme { + Signed, + /// Used to exercise the scheme-mismatch rejection in `vote` / `remove_vote`. + Anonymous, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SimpleVoterSet(pub Vec); + +impl SetLike for SimpleVoterSet { + fn contains(&self, who: &U256) -> bool { + self.0.contains(who) + } + fn len(&self) -> u32 { + self.0.len() as u32 + } + fn is_initialized(&self) -> bool { + true + } + fn to_vec(&self) -> Vec { + self.0.clone() + } +} + +#[derive(Clone)] +pub struct PollState { + pub is_ongoing: bool, + pub scheme: Option, + pub voter_set: Vec, +} + +thread_local! { + static POLLS_STATE: RefCell> = + const { RefCell::new(BTreeMap::new()) }; + static TALLY_UPDATES: RefCell> = + const { RefCell::new(Vec::new()) }; +} + +pub struct MockPolls; + +impl Polls for MockPolls { + type Index = u32; + type VotingScheme = VotingScheme; + type VoterSet = SimpleVoterSet; + + fn is_ongoing(index: Self::Index) -> bool { + POLLS_STATE.with(|p| { + p.borrow() + .get(&index) + .map(|s| s.is_ongoing) + .unwrap_or(false) + }) + } + + fn voting_scheme_of(index: Self::Index) -> Option { + POLLS_STATE.with(|p| p.borrow().get(&index).and_then(|s| s.scheme)) + } + + fn voter_set_of(index: Self::Index) -> Option { + POLLS_STATE.with(|p| { + p.borrow() + .get(&index) + .map(|s| SimpleVoterSet(s.voter_set.clone())) + }) + } + + fn on_tally_updated(index: Self::Index, tally: &VoteTally) { + TALLY_UPDATES.with(|t| t.borrow_mut().push((index, *tally))); + } + + fn on_tally_updated_weight() -> Weight { + Weight::zero() + } +} + +/// Register a poll and fire `on_poll_created` so `TallyOf` / `ActivePolls` +/// are populated. After this returns, the pallet sees the poll as ongoing. +pub fn start_poll(index: u32, scheme: VotingScheme, voter_set: Vec) { + POLLS_STATE.with(|p| { + p.borrow_mut().insert( + index, + PollState { + is_ongoing: true, + scheme: Some(scheme), + voter_set, + }, + ); + }); + >::on_poll_created(index); +} + +/// Mark the poll inactive and fire `on_poll_completed` to clean up storage. +pub fn complete_poll(index: u32) { + POLLS_STATE.with(|p| { + if let Some(s) = p.borrow_mut().get_mut(&index) { + s.is_ongoing = false; + } + }); + >::on_poll_completed(index); +} + +/// Simulate a membership rotation in the underlying collective by removing +/// `who` from the mock's `Polls::voter_set_of` view. Used to assert that +/// signed-voting is unaffected: the eligibility roster is whatever was +/// snapshotted into `VoterSetOf` at `on_poll_created`, regardless of later +/// changes here. +pub fn rotate_voter_out(index: u32, who: U256) { + POLLS_STATE.with(|p| { + if let Some(s) = p.borrow_mut().get_mut(&index) { + s.voter_set.retain(|v| *v != who); + } + }); +} + +/// Simulate adding a member to the underlying collective after the poll +/// snapshot was taken. The new member must not gain voting rights on the +/// existing poll. +pub fn rotate_voter_in(index: u32, who: U256) { + POLLS_STATE.with(|p| { + if let Some(s) = p.borrow_mut().get_mut(&index) + && !s.voter_set.contains(&who) + { + s.voter_set.push(who); + } + }); +} + +/// Simulate a producer that reports `is_ongoing = true` while +/// `voting_scheme_of` returns `None`. Used to reach the `PollNotFound` +/// branch in `ensure_valid_voting_scheme`. +pub fn force_scheme_none(index: u32) { + POLLS_STATE.with(|p| { + if let Some(s) = p.borrow_mut().get_mut(&index) { + s.scheme = None; + } + }); +} + +pub fn take_tally_updates() -> Vec<(u32, VoteTally)> { + TALLY_UPDATES.with(|t| t.borrow_mut().drain(..).collect()) +} + +pub fn signed_voting_events() -> Vec> { + System::events() + .into_iter() + .filter_map(|r| match r.event { + RuntimeEvent::SignedVoting(e) => Some(e), + _ => None, + }) + .collect() +} + +#[derive_impl(frame_system::config_preludes::TestDefaultConfig)] +impl frame_system::Config for Test { + type Block = Block; + type AccountId = U256; + type Lookup = IdentityLookup; + // Use the production weight table so `on_idle` weight assertions + // catch regressions that the default `DbWeight = ()` would mask. + type DbWeight = RocksDbWeight; +} + +macro_rules! define_scoped_state { + ($flag:ident, $guard:ident, $reader:ident, $ty:ty, $default:expr) => { + thread_local! { + static $flag: RefCell<$ty> = const { RefCell::new($default) }; + } + + #[must_use = "the guard restores the prior value on drop; bind it to a local"] + pub struct $guard { + previous: Option<$ty>, + } + + impl $guard { + pub fn new(value: $ty) -> Self { + let previous = + Some($flag.with(|r| core::mem::replace(&mut *r.borrow_mut(), value))); + Self { previous } + } + } + + impl Drop for $guard { + fn drop(&mut self) { + if let Some(prev) = self.previous.take() { + $flag.with(|r| *r.borrow_mut() = prev); + } + } + } + + fn $reader() -> $ty { + $flag.with(|r| *r.borrow()) + } + }; +} + +define_scoped_state!( + MAX_VOTER_SET_SIZE, + MaxVoterSetSizeGuard, + max_voter_set_size, + u32, + 256 +); +define_scoped_state!( + MAX_PENDING_CLEANUP, + MaxPendingCleanupGuard, + max_pending_cleanup, + u32, + 32 +); +define_scoped_state!( + CLEANUP_CHUNK_SIZE, + CleanupChunkSizeGuard, + cleanup_chunk_size, + u32, + 4 +); + +parameter_types! { + pub const TestScheme: VotingScheme = VotingScheme::Signed; + pub const TestCleanupCursorMaxLen: u32 = 128; + pub TestMaxVoterSetSize: u32 = max_voter_set_size(); + pub TestMaxPendingCleanup: u32 = max_pending_cleanup(); + pub TestCleanupChunkSize: u32 = cleanup_chunk_size(); +} + +impl pallet_signed_voting::Config for Test { + type Scheme = TestScheme; + type Polls = MockPolls; + type MaxVoterSetSize = TestMaxVoterSetSize; + type MaxPendingCleanup = TestMaxPendingCleanup; + type CleanupChunkSize = TestCleanupChunkSize; + type CleanupCursorMaxLen = TestCleanupCursorMaxLen; + type WeightInfo = pallet_signed_voting::weights::SubstrateWeight; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = MockBenchmarkHelper; +} + +/// Benchmark bootstrap for the mock. Registers a poll directly in +/// `POLLS_STATE` so `MockPolls::is_ongoing` and `voting_scheme_of` +/// return the values the benchmark expects. +#[cfg(feature = "runtime-benchmarks")] +pub struct MockBenchmarkHelper; + +#[cfg(feature = "runtime-benchmarks")] +impl pallet_signed_voting::benchmarking::BenchmarkHelper for MockBenchmarkHelper { + fn ongoing_poll() -> u32 { + let index: u32 = 0; + POLLS_STATE.with(|p| { + p.borrow_mut().insert( + index, + PollState { + is_ongoing: true, + scheme: Some(VotingScheme::Signed), + // Voter set populated directly by the benchmark via + // `populate_snapshot`. + voter_set: alloc::vec::Vec::new(), + }, + ); + }); + index + } +} + +pub fn new_test_ext() -> sp_io::TestExternalities { + let mut ext: sp_io::TestExternalities = RuntimeGenesisConfig::default() + .build_storage() + .unwrap() + .into(); + ext.execute_with(|| { + System::set_block_number(1); + POLLS_STATE.with(|p| p.borrow_mut().clear()); + let _ = take_tally_updates(); + }); + ext +} + +pub struct TestState; + +impl TestState { + pub fn build_and_execute(test: impl FnOnce()) { + new_test_ext().execute_with(test); + } +} diff --git a/pallets/signed-voting/src/tests.rs b/pallets/signed-voting/src/tests.rs new file mode 100644 index 0000000000..08612a2535 --- /dev/null +++ b/pallets/signed-voting/src/tests.rs @@ -0,0 +1,1075 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] + +use frame_support::{assert_noop, assert_ok, sp_runtime::Perbill, traits::Hooks, weights::Weight}; +use sp_core::U256; +use sp_runtime::{DispatchError, Saturating}; +use subtensor_runtime_common::{OnPollCompleted, OnPollCreated, VoteTally}; + +use crate::{ + Error, Event as SignedVotingEvent, Pallet as SignedVotingPallet, PendingCleanup, + SignedVoteTally, TallyOf, VoterSetOf, VotingFor, mock::*, +}; + +/// Loop `on_idle` with unlimited weight until `PendingCleanup` is empty. +/// Cursor-resume tests must use [`build_and_commit`] instead: the test +/// externality only progresses cleanup state across committed blocks. +fn drain_cleanup_queue() { + let block = System::block_number(); + while !PendingCleanup::::get().is_empty() { + SignedVotingPallet::::on_idle(block, Weight::MAX); + } +} + +/// Build a [`TestExternalities`], run `setup`, then commit so subsequent +/// `execute_with` blocks see the writes through the backend. Needed for +/// any test that calls `clear_prefix` with a non-trivial limit: the +/// limit ignores keys that live only in the overlay. +fn build_and_commit(setup: F) -> sp_io::TestExternalities { + let mut ext = new_test_ext(); + ext.execute_with(setup); + ext.commit_all().expect("commit_all"); + ext +} + +#[test] +fn vote_aye_increments_ayes_and_emits_voted_event() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll( + 0, + VotingScheme::Signed, + vec![alice, U256::from(2), U256::from(3)], + ); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + + let tally = TallyOf::::get(0u32).unwrap(); + assert_eq!(tally.ayes, 1); + assert_eq!(tally.nays, 0); + assert_eq!(tally.total, 3); + assert_eq!(VotingFor::::get(0u32, alice), Some(true)); + + assert_eq!( + signed_voting_events().last(), + Some(&SignedVotingEvent::Voted { + who: alice, + poll_index: 0, + approve: true, + tally, + }) + ); + }); +} + +#[test] +fn vote_nay_increments_nays() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice, U256::from(2)]); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + false, + )); + + let tally = TallyOf::::get(0u32).unwrap(); + assert_eq!(tally.nays, 1); + assert_eq!(VotingFor::::get(0u32, alice), Some(false)); + }); +} + +#[test] +fn vote_can_flip_aye_nay_aye() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice, U256::from(2)]); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + assert_eq!( + ( + TallyOf::::get(0u32).unwrap().ayes, + TallyOf::::get(0u32).unwrap().nays + ), + (1, 0) + ); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + false, + )); + assert_eq!( + ( + TallyOf::::get(0u32).unwrap().ayes, + TallyOf::::get(0u32).unwrap().nays + ), + (0, 1) + ); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + assert_eq!( + ( + TallyOf::::get(0u32).unwrap().ayes, + TallyOf::::get(0u32).unwrap().nays + ), + (1, 0) + ); + }); +} + +#[test] +fn vote_aggregates_across_distinct_voters() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + let bob = U256::from(2); + let charlie = U256::from(3); + start_poll(0, VotingScheme::Signed, vec![alice, bob, charlie]); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(bob), + 0u32, + false, + )); + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(charlie), + 0u32, + true, + )); + + let tally = TallyOf::::get(0u32).unwrap(); + assert_eq!((tally.ayes, tally.nays, tally.total), (2, 1, 3)); + }); +} + +#[test] +fn vote_invokes_polls_on_tally_updated_with_perbill_ratios() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll( + 0, + VotingScheme::Signed, + vec![alice, U256::from(2), U256::from(3)], + ); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + + let updates = take_tally_updates(); + assert_eq!(updates.len(), 1); + let (idx, tally) = &updates[0]; + assert_eq!(*idx, 0); + assert_eq!(tally.approval, Perbill::from_rational(1u32, 3u32)); + assert_eq!(tally.rejection, Perbill::zero()); + assert_eq!( + tally.abstention, + Perbill::one().saturating_sub(tally.approval), + ); + assert_eq!( + tally.approval + tally.rejection + tally.abstention, + Perbill::one(), + ); + }); +} + +#[test] +fn vote_rejects_root_origin() { + TestState::build_and_execute(|| { + start_poll(0, VotingScheme::Signed, vec![U256::from(1)]); + + assert_noop!( + SignedVotingPallet::::vote(RuntimeOrigin::root(), 0u32, true), + DispatchError::BadOrigin + ); + }); +} + +#[test] +fn vote_rejects_completed_poll_with_poll_not_ongoing() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice]); + complete_poll(0); + + assert_noop!( + SignedVotingPallet::::vote(RuntimeOrigin::signed(alice), 0u32, true), + Error::::PollNotOngoing + ); + }); +} + +#[test] +fn vote_rejects_unknown_poll_with_poll_not_ongoing() { + TestState::build_and_execute(|| { + assert_noop!( + SignedVotingPallet::::vote(RuntimeOrigin::signed(U256::from(1)), 999u32, true), + Error::::PollNotOngoing + ); + }); +} + +#[test] +fn vote_rejects_poll_with_mismatched_scheme() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Anonymous, vec![alice]); + + assert_noop!( + SignedVotingPallet::::vote(RuntimeOrigin::signed(alice), 0u32, true), + Error::::InvalidVotingScheme + ); + }); +} + +#[test] +fn vote_rejects_non_member_with_not_in_voter_set() { + TestState::build_and_execute(|| { + let mallory = U256::from(999); + start_poll(0, VotingScheme::Signed, vec![U256::from(1), U256::from(2)]); + + assert_noop!( + SignedVotingPallet::::vote(RuntimeOrigin::signed(mallory), 0u32, true), + Error::::NotInVoterSet + ); + }); +} + +#[test] +fn vote_rejects_duplicate_in_same_direction() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice]); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + + assert_noop!( + SignedVotingPallet::::vote(RuntimeOrigin::signed(alice), 0u32, true), + Error::::DuplicateVote + ); + + let tally = TallyOf::::get(0u32).unwrap(); + assert_eq!((tally.ayes, tally.nays), (1, 0)); + }); +} + +#[test] +fn rotated_out_member_can_still_vote_until_poll_ends() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice, U256::from(2)]); + + rotate_voter_out(0, alice); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + assert_eq!(VotingFor::::get(0u32, alice), Some(true)); + }); +} + +#[test] +fn rotated_in_member_cannot_vote_on_poll_created_before_they_joined() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + let newcomer = U256::from(42); + start_poll(0, VotingScheme::Signed, vec![alice]); + + rotate_voter_in(0, newcomer); + + assert_noop!( + SignedVotingPallet::::vote(RuntimeOrigin::signed(newcomer), 0u32, true), + Error::::NotInVoterSet + ); + }); +} + +#[test] +fn rotated_out_member_can_flip_their_vote() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice, U256::from(2)]); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + rotate_voter_out(0, alice); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + false, + )); + let tally = TallyOf::::get(0u32).unwrap(); + assert_eq!((tally.ayes, tally.nays), (0, 1)); + assert_eq!(VotingFor::::get(0u32, alice), Some(false)); + }); +} + +#[test] +fn remove_vote_clears_aye_and_emits_vote_removed_event() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice, U256::from(2)]); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + assert_ok!(SignedVotingPallet::::remove_vote( + RuntimeOrigin::signed(alice), + 0u32, + )); + + let tally = TallyOf::::get(0u32).unwrap(); + assert_eq!((tally.ayes, tally.nays, tally.total), (0, 0, 2)); + assert_eq!(VotingFor::::get(0u32, alice), None); + + assert_eq!( + signed_voting_events().last(), + Some(&SignedVotingEvent::VoteRemoved { + who: alice, + poll_index: 0, + tally, + }) + ); + }); +} + +#[test] +fn remove_vote_clears_nay() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice, U256::from(2)]); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + false, + )); + assert_ok!(SignedVotingPallet::::remove_vote( + RuntimeOrigin::signed(alice), + 0u32, + )); + + let tally = TallyOf::::get(0u32).unwrap(); + assert_eq!(tally.nays, 0); + assert_eq!(VotingFor::::get(0u32, alice), None); + }); +} + +#[test] +fn remove_vote_rejects_root_origin() { + TestState::build_and_execute(|| { + start_poll(0, VotingScheme::Signed, vec![U256::from(1)]); + + assert_noop!( + SignedVotingPallet::::remove_vote(RuntimeOrigin::root(), 0u32), + DispatchError::BadOrigin + ); + }); +} + +#[test] +fn remove_vote_rejects_completed_poll() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice]); + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + complete_poll(0); + + assert_noop!( + SignedVotingPallet::::remove_vote(RuntimeOrigin::signed(alice), 0u32), + Error::::PollNotOngoing + ); + }); +} + +#[test] +fn remove_vote_rejects_poll_with_mismatched_scheme() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Anonymous, vec![alice]); + + assert_noop!( + SignedVotingPallet::::remove_vote(RuntimeOrigin::signed(alice), 0u32), + Error::::InvalidVotingScheme + ); + }); +} + +#[test] +fn remove_vote_rejects_non_member() { + TestState::build_and_execute(|| { + let mallory = U256::from(999); + start_poll(0, VotingScheme::Signed, vec![U256::from(1)]); + + assert_noop!( + SignedVotingPallet::::remove_vote(RuntimeOrigin::signed(mallory), 0u32), + Error::::NotInVoterSet + ); + }); +} + +#[test] +fn remove_vote_rejects_voter_who_never_voted() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice]); + + assert_noop!( + SignedVotingPallet::::remove_vote(RuntimeOrigin::signed(alice), 0u32), + Error::::VoteNotFound + ); + }); +} + +#[test] +fn remove_vote_succeeds_for_voter_rotated_out_after_creation() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice, U256::from(2)]); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + rotate_voter_out(0, alice); + + assert_ok!(SignedVotingPallet::::remove_vote( + RuntimeOrigin::signed(alice), + 0u32, + )); + assert_eq!(VotingFor::::get(0u32, alice), None); + }); +} + +#[test] +fn on_poll_created_initializes_tally_with_voter_set_size() { + TestState::build_and_execute(|| { + let voters: Vec = (1..=5u32).map(U256::from).collect(); + start_poll(0, VotingScheme::Signed, voters); + + let tally = TallyOf::::get(0u32).unwrap(); + assert_eq!( + tally, + SignedVoteTally { + ayes: 0, + nays: 0, + total: 5, + } + ); + }); +} + +#[test] +fn on_poll_created_snapshots_voter_set_into_voter_set_of() { + TestState::build_and_execute(|| { + let voters: Vec = (1..=4u32).map(U256::from).collect(); + start_poll(0, VotingScheme::Signed, voters.clone()); + + let snapshot = VoterSetOf::::get(0u32).expect("snapshot stored"); + assert_eq!(snapshot.to_vec(), voters); + }); +} + +/// Defense-in-depth path. The runtime's compile-time bound checks and +/// `pallet-referenda::submit`'s `EmptyVoterSet` guard should make this +/// unreachable, but if the producer ever hands over an oversized set +/// the pallet falls back to an empty snapshot rather than panicking. +#[test] +fn on_poll_created_with_oversized_voter_set_falls_back_to_empty() { + TestState::build_and_execute(|| { + let cap = TestMaxVoterSetSize::get(); + let voters: Vec = (1..=(cap + 1)).map(|i| U256::from(i as u64)).collect(); + start_poll(0, VotingScheme::Signed, voters); + + let snapshot = VoterSetOf::::get(0u32).expect("snapshot stored"); + assert!(snapshot.is_empty()); + assert_eq!(TallyOf::::get(0u32).unwrap().total, 0); + }); +} + +#[test] +fn on_poll_created_twice_does_not_clobber_existing_tally() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + let bob = U256::from(2); + start_poll(0, VotingScheme::Signed, vec![alice, bob]); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + let tally_before = TallyOf::::get(0u32).expect("tally seeded"); + assert_eq!(tally_before.ayes, 1); + + as OnPollCreated>::on_poll_created(0u32); + + let tally_after = TallyOf::::get(0u32).expect("tally preserved"); + assert_eq!(tally_after, tally_before); + assert_eq!(VotingFor::::get(0u32, alice), Some(true)); + }); +} + +#[test] +fn on_poll_created_skips_polls_with_mismatched_scheme() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Anonymous, vec![alice]); + + assert!(TallyOf::::get(0u32).is_none()); + assert!(VoterSetOf::::get(0u32).is_none()); + }); +} + +#[test] +fn on_poll_created_sorts_and_dedups_voter_set() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + let bob = U256::from(2); + let carol = U256::from(3); + start_poll(0, VotingScheme::Signed, vec![carol, bob, alice, bob, carol]); + + let snapshot = VoterSetOf::::get(0u32).expect("snapshot stored"); + assert_eq!(snapshot.to_vec(), vec![alice, bob, carol]); + assert_eq!(TallyOf::::get(0u32).unwrap().total, 3); + }); +} + +#[test] +fn tally_total_is_immune_to_membership_changes_after_creation() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + let bob = U256::from(2); + start_poll(0, VotingScheme::Signed, vec![alice, bob]); + let total_at_creation = TallyOf::::get(0u32).unwrap().total; + assert_eq!(total_at_creation, 2); + + rotate_voter_out(0, alice); + rotate_voter_in(0, U256::from(99)); + + assert_eq!(TallyOf::::get(0u32).unwrap().total, total_at_creation); + }); +} + +#[test] +fn on_poll_completed_synchronously_clears_tally_and_voter_set() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + let bob = U256::from(2); + start_poll(0, VotingScheme::Signed, vec![alice, bob]); + + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(bob), + 0u32, + false, + )); + + complete_poll(0); + + assert!(TallyOf::::get(0u32).is_none()); + assert!(VoterSetOf::::get(0u32).is_none()); + }); +} + +#[test] +fn on_poll_completed_enqueues_voting_for_for_lazy_cleanup() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice, U256::from(2)]); + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + + complete_poll(0); + + let queue = PendingCleanup::::get(); + assert_eq!(queue.len(), 1); + assert_eq!(queue[0].0, 0u32); + assert!(queue[0].1.is_none(), "fresh enqueue carries no cursor"); + assert_eq!(VotingFor::::get(0u32, alice), Some(true)); + }); +} + +#[test] +fn on_poll_completed_twice_does_not_duplicate_cleanup_queue() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice]); + complete_poll(0); + assert_eq!(PendingCleanup::::get().len(), 1); + + as OnPollCompleted>::on_poll_completed(0u32); + assert_eq!(PendingCleanup::::get().len(), 1); + }); +} + +#[test] +fn on_poll_completed_no_ops_when_no_local_tally() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Anonymous, vec![alice]); + + complete_poll(0); + assert!(PendingCleanup::::get().is_empty()); + }); +} + +#[test] +fn on_poll_completed_emits_cleanup_queue_full_and_leaks_voting_for() { + TestState::build_and_execute(|| { + let cap = TestMaxPendingCleanup::get(); + for i in 0..cap { + start_poll(i, VotingScheme::Signed, vec![U256::from(i as u64 + 1)]); + complete_poll(i); + } + let extra = cap; + let leaker = U256::from(99); + start_poll(extra, VotingScheme::Signed, vec![leaker]); + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(leaker), + extra, + true, + )); + complete_poll(extra); + + let events = signed_voting_events(); + assert!( + events.iter().any(|e| matches!( + e, + SignedVotingEvent::CleanupQueueFull { poll_index } if *poll_index == extra + )), + "CleanupQueueFull event must fire for poll {}", + extra + ); + assert_eq!(PendingCleanup::::get().len(), cap as usize); + assert_eq!( + VotingFor::::get(extra, leaker), + Some(true), + "overflow path must leak VotingFor for the rejected poll", + ); + }); +} + +/// Stress check at 200 voters, well past any track's `MaxVoterSetSize`. +/// Catches regressions where the cleanup queue or its drain loop +/// silently drops entries. +#[test] +fn drain_cleanup_queue_clears_all_voting_for_entries_for_completed_polls() { + TestState::build_and_execute(|| { + let voters: Vec = (1..=200u32).map(U256::from).collect(); + start_poll(0, VotingScheme::Signed, voters.clone()); + for v in &voters { + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(*v), + 0u32, + true, + )); + } + + complete_poll(0); + drain_cleanup_queue(); + + for v in &voters { + assert_eq!(VotingFor::::get(0u32, *v), None); + } + assert!(PendingCleanup::::get().is_empty()); + }); +} + +/// One drain pass clears at most `CleanupChunkSize` entries and +/// persists the resume cursor on the queue head, so a busy chain +/// cannot starve cleanup of bounded weight. +#[test] +fn on_idle_clears_one_chunk_per_pass_and_stores_cursor() { + use crate::weights::WeightInfo as _; + + let voters: Vec = (1..=10u32).map(U256::from).collect(); + let mut ext = build_and_commit(|| { + start_poll(0, VotingScheme::Signed, voters.clone()); + for v in &voters { + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(*v), + 0u32, + true, + )); + } + complete_poll(0); + }); + + ext.execute_with(|| { + let chunk = TestCleanupChunkSize::get(); + let one_step = <::WeightInfo>::idle_cleanup_chunk(chunk); + let budget = one_step.saturating_add(one_step.saturating_div(2)); + + SignedVotingPallet::::on_idle(System::block_number(), budget); + + let remaining = voters + .iter() + .filter(|v| VotingFor::::get(0u32, **v).is_some()) + .count(); + assert_eq!(remaining, voters.len() - chunk as usize); + + let queue = PendingCleanup::::get(); + assert_eq!(queue.len(), 1); + assert_eq!(queue[0].0, 0u32); + assert!( + queue[0].1.is_some(), + "cursor must be persisted after a partial clear" + ); + }); +} + +/// Successive drain passes resume from the persisted cursor. Each pass +/// runs in its own committed externality so `clear_prefix`'s cursor sees +/// real backend state, not just the in-block overlay. +#[test] +fn successive_idle_passes_resume_via_cursor_until_drained() { + use crate::weights::WeightInfo as _; + + let voters: Vec = (1..=10u32).map(U256::from).collect(); + let mut ext = build_and_commit(|| { + start_poll(0, VotingScheme::Signed, voters.clone()); + for v in &voters { + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(*v), + 0u32, + true, + )); + } + complete_poll(0); + }); + + let chunk = TestCleanupChunkSize::get(); + let one_step = <::WeightInfo>::idle_cleanup_chunk(chunk); + let budget = one_step + (one_step / 2); + + for _ in 0..3 { + ext.execute_with(|| { + SignedVotingPallet::::on_idle(System::block_number(), budget); + }); + ext.commit_all().expect("commit_all"); + } + + ext.execute_with(|| { + let stored = VotingFor::::iter_prefix(0u32).count(); + assert_eq!(stored, 0, "all VotingFor entries must be drained"); + assert!(PendingCleanup::::get().is_empty()); + }); +} + +#[test] +fn idle_drain_finishes_head_poll_before_starting_next() { + let voters_a: Vec = (1..=8u32).map(U256::from).collect(); + let voters_b: Vec = (101..=108u32).map(U256::from).collect(); + let mut ext = build_and_commit(|| { + start_poll(0, VotingScheme::Signed, voters_a.clone()); + start_poll(1, VotingScheme::Signed, voters_b.clone()); + for v in &voters_a { + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(*v), + 0u32, + true, + )); + } + for v in &voters_b { + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(*v), + 1u32, + true, + )); + } + complete_poll(0); + complete_poll(1); + }); + + ext.execute_with(|| { + use crate::weights::WeightInfo as _; + let chunk = TestCleanupChunkSize::get(); + let one_step = <::WeightInfo>::idle_cleanup_chunk(chunk); + let single_budget = one_step.saturating_add(one_step.saturating_div(2)); + + SignedVotingPallet::::on_idle(System::block_number(), single_budget); + + let a_remaining = voters_a + .iter() + .filter(|v| VotingFor::::get(0u32, **v).is_some()) + .count(); + let b_remaining = voters_b + .iter() + .filter(|v| VotingFor::::get(1u32, **v).is_some()) + .count(); + assert_eq!(a_remaining, voters_a.len() - chunk as usize); + assert_eq!(b_remaining, voters_b.len(), "poll 1 must not be touched"); + + let queue = PendingCleanup::::get(); + assert_eq!(queue.len(), 2); + assert_eq!(queue[0].0, 0u32, "poll 0 still at head"); + assert_eq!(queue[1].0, 1u32); + }); +} + +#[test] +fn on_idle_is_noop_when_weight_below_one_drain_step() { + use crate::weights::WeightInfo as _; + + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice, U256::from(2)]); + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + complete_poll(0); + + let chunk = TestCleanupChunkSize::get(); + let one_step = <::WeightInfo>::idle_cleanup_chunk(chunk); + let starved = one_step.saturating_div(2); + + SignedVotingPallet::::on_idle(System::block_number(), starved); + + assert_eq!(PendingCleanup::::get().len(), 1); + assert_eq!(VotingFor::::get(0u32, alice), Some(true)); + }); +} + +/// `on_idle` with an empty queue consumes only the upfront 1-read / +/// 1-write reservation. The mock uses `RocksDbWeight` so this catches +/// regressions that the default `DbWeight = ()` would silently mask. +#[test] +fn on_idle_with_empty_queue_consumes_only_entry_cost() { + TestState::build_and_execute(|| { + let entry_cost = ::DbWeight::get().reads_writes(1, 1); + let consumed = SignedVotingPallet::::on_idle(System::block_number(), Weight::MAX); + assert_eq!(consumed, entry_cost); + }); +} + +#[test] +fn on_idle_consumes_nothing_when_budget_below_entry_cost() { + TestState::build_and_execute(|| { + let consumed = SignedVotingPallet::::on_idle(System::block_number(), Weight::zero()); + assert_eq!(consumed, Weight::zero()); + }); +} + +#[test] +fn tally_conversion_computes_perbill_ratios() { + let tally = SignedVoteTally { + ayes: 1, + nays: 2, + total: 10, + }; + let vote_tally: VoteTally = tally.into(); + + assert_eq!(vote_tally.approval, Perbill::from_rational(1u32, 10u32)); + assert_eq!(vote_tally.rejection, Perbill::from_rational(2u32, 10u32)); + assert_eq!(vote_tally.abstention, Perbill::from_rational(7u32, 10u32)); +} + +#[test] +fn tally_conversion_saturates_approval_when_all_aye() { + let tally = SignedVoteTally { + ayes: 3, + nays: 0, + total: 3, + }; + let vote_tally: VoteTally = tally.into(); + + assert_eq!(vote_tally.approval, Perbill::one()); + assert_eq!(vote_tally.rejection, Perbill::zero()); + assert_eq!(vote_tally.abstention, Perbill::zero()); +} + +/// `Perbill::from_rational(_, 0)` returns 100%, so a naive conversion +/// of a zero-total tally would yield approval + rejection + abstention +/// = 300%. The short-circuit to `default()` avoids that. +#[test] +fn tally_conversion_short_circuits_zero_total_to_default() { + let tally = SignedVoteTally { + ayes: 0, + nays: 0, + total: 0, + }; + let vote_tally: VoteTally = tally.into(); + + assert_eq!(vote_tally, VoteTally::default()); + assert_eq!(vote_tally.approval, Perbill::zero()); + assert_eq!(vote_tally.rejection, Perbill::zero()); + assert_eq!(vote_tally.abstention, Perbill::one()); +} + +#[test] +fn tally_conversion_saturates_rejection_when_all_nay() { + let tally = SignedVoteTally { + ayes: 0, + nays: 3, + total: 3, + }; + let vote_tally: VoteTally = tally.into(); + + assert_eq!(vote_tally.approval, Perbill::zero()); + assert_eq!(vote_tally.rejection, Perbill::one()); + assert_eq!(vote_tally.abstention, Perbill::zero()); +} + +#[test] +fn integrity_test_passes_for_default_config() { + SignedVotingPallet::::integrity_test(); +} + +#[test] +#[should_panic(expected = "CleanupChunkSize must be non-zero")] +fn integrity_test_panics_when_cleanup_chunk_size_is_zero() { + let _g = CleanupChunkSizeGuard::new(0); + SignedVotingPallet::::integrity_test(); +} + +#[test] +#[should_panic(expected = "MaxPendingCleanup must be non-zero")] +fn integrity_test_panics_when_max_pending_cleanup_is_zero() { + let _g = MaxPendingCleanupGuard::new(0); + SignedVotingPallet::::integrity_test(); +} + +#[test] +#[should_panic(expected = "MaxVoterSetSize must be non-zero")] +fn integrity_test_panics_when_max_voter_set_size_is_zero() { + let _g = MaxVoterSetSizeGuard::new(0); + SignedVotingPallet::::integrity_test(); +} + +#[test] +fn vote_returns_poll_not_found_when_producer_reports_no_scheme() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice]); + force_scheme_none(0); + + assert_noop!( + SignedVotingPallet::::vote(RuntimeOrigin::signed(alice), 0u32, true), + Error::::PollNotFound + ); + }); +} + +#[test] +fn vote_returns_tally_missing_on_internal_inconsistency() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice]); + TallyOf::::remove(0u32); + + assert_noop!( + SignedVotingPallet::::vote(RuntimeOrigin::signed(alice), 0u32, true), + Error::::TallyMissing + ); + }); +} + +#[test] +fn remove_vote_returns_tally_missing_on_internal_inconsistency() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice]); + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + TallyOf::::remove(0u32); + + assert_noop!( + SignedVotingPallet::::remove_vote(RuntimeOrigin::signed(alice), 0u32), + Error::::TallyMissing + ); + }); +} + +#[test] +fn vote_returns_voter_set_missing_on_internal_inconsistency() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll(0, VotingScheme::Signed, vec![alice]); + VoterSetOf::::remove(0u32); + + assert_noop!( + SignedVotingPallet::::vote(RuntimeOrigin::signed(alice), 0u32, true), + Error::::VoterSetMissing + ); + }); +} + +#[test] +fn remove_vote_invokes_polls_on_tally_updated() { + TestState::build_and_execute(|| { + let alice = U256::from(1); + start_poll( + 0, + VotingScheme::Signed, + vec![alice, U256::from(2), U256::from(3)], + ); + assert_ok!(SignedVotingPallet::::vote( + RuntimeOrigin::signed(alice), + 0u32, + true, + )); + let _ = take_tally_updates(); + + assert_ok!(SignedVotingPallet::::remove_vote( + RuntimeOrigin::signed(alice), + 0u32, + )); + + let updates = take_tally_updates(); + assert_eq!(updates.len(), 1); + let (idx, tally) = &updates[0]; + assert_eq!(*idx, 0); + assert_eq!(tally.approval, Perbill::zero()); + assert_eq!(tally.rejection, Perbill::zero()); + assert_eq!(tally.abstention, Perbill::one()); + }); +} diff --git a/pallets/signed-voting/src/weights.rs b/pallets/signed-voting/src/weights.rs new file mode 100644 index 0000000000..0c3954f3f3 --- /dev/null +++ b/pallets/signed-voting/src/weights.rs @@ -0,0 +1,251 @@ + +//! Autogenerated weights for `pallet_signed_voting` +//! +//! 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_signed_voting +// --extrinsic=* +// --steps=50 +// --repeat=20 +// --no-storage-info +// --no-min-squares +// --no-median-slopes +// --output=/tmp/tmp.zhEy1JuImq +// --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_signed_voting`. +pub trait WeightInfo { + fn vote(v: u32, ) -> Weight; + fn remove_vote(v: u32, ) -> Weight; + fn on_poll_created() -> Weight; + fn on_poll_completed() -> Weight; + fn idle_cleanup_chunk(c: u32, ) -> Weight; +} + +/// Weights for `pallet_signed_voting` using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + /// Storage: `Referenda::ReferendumStatusFor` (r:1 w:1) + /// Proof: `Referenda::ReferendumStatusFor` (`max_values`: None, `max_size`: Some(219), added: 2694, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VoterSetOf` (r:1 w:0) + /// Proof: `SignedVoting::VoterSetOf` (`max_values`: None, `max_size`: Some(2062), added: 4537, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::TallyOf` (r:1 w:1) + /// Proof: `SignedVoting::TallyOf` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VotingFor` (r:1 w:1) + /// Proof: `SignedVoting::VotingFor` (`max_values`: None, `max_size`: Some(53), added: 2528, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Lookup` (r:1 w:1) + /// Proof: `Scheduler::Lookup` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Agenda` (r:1 w:1) + /// Proof: `Scheduler::Agenda` (`max_values`: None, `max_size`: Some(10463), added: 12938, mode: `MaxEncodedLen`) + /// The range of component `v` is `[1, 64]`. + fn vote(v: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `659 + v * (32 ±0)` + // Estimated: `13928` + // Minimum execution time: 55_464_000 picoseconds. + Weight::from_parts(57_373_252, 13928) + // Standard Error: 1_349 + .saturating_add(Weight::from_parts(17_612, 0).saturating_mul(v.into())) + .saturating_add(T::DbWeight::get().reads(6_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: `Referenda::ReferendumStatusFor` (r:1 w:1) + /// Proof: `Referenda::ReferendumStatusFor` (`max_values`: None, `max_size`: Some(219), added: 2694, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VoterSetOf` (r:1 w:0) + /// Proof: `SignedVoting::VoterSetOf` (`max_values`: None, `max_size`: Some(2062), added: 4537, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::TallyOf` (r:1 w:1) + /// Proof: `SignedVoting::TallyOf` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VotingFor` (r:1 w:1) + /// Proof: `SignedVoting::VotingFor` (`max_values`: None, `max_size`: Some(53), added: 2528, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Lookup` (r:1 w:1) + /// Proof: `Scheduler::Lookup` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Agenda` (r:2 w:2) + /// Proof: `Scheduler::Agenda` (`max_values`: None, `max_size`: Some(10463), added: 12938, mode: `MaxEncodedLen`) + /// The range of component `v` is `[1, 64]`. + fn remove_vote(v: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `855 + v * (32 ±0)` + // Estimated: `26866` + // Minimum execution time: 67_516_000 picoseconds. + Weight::from_parts(69_657_975, 26866) + // Standard Error: 1_844 + .saturating_add(Weight::from_parts(21_501, 0).saturating_mul(v.into())) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(6_u64)) + } + /// Storage: `Referenda::ReferendumStatusFor` (r:1 w:0) + /// Proof: `Referenda::ReferendumStatusFor` (`max_values`: None, `max_size`: Some(219), added: 2694, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::TallyOf` (r:1 w:1) + /// Proof: `SignedVoting::TallyOf` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `MultiCollective::Members` (r:2 w:0) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VoterSetOf` (r:0 w:1) + /// Proof: `SignedVoting::VoterSetOf` (`max_values`: None, `max_size`: Some(2062), added: 4537, mode: `MaxEncodedLen`) + fn on_poll_created() -> Weight { + // Proof Size summary in bytes: + // Measured: `606` + // Estimated: `10074` + // Minimum execution time: 32_972_000 picoseconds. + Weight::from_parts(33_672_000, 10074) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: `SignedVoting::TallyOf` (r:1 w:1) + /// Proof: `SignedVoting::TallyOf` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::PendingCleanup` (r:1 w:1) + /// Proof: `SignedVoting::PendingCleanup` (`max_values`: Some(1), `max_size`: Some(5401), added: 5896, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VoterSetOf` (r:0 w:1) + /// Proof: `SignedVoting::VoterSetOf` (`max_values`: None, `max_size`: Some(2062), added: 4537, mode: `MaxEncodedLen`) + fn on_poll_completed() -> Weight { + // Proof Size summary in bytes: + // Measured: `149` + // Estimated: `6886` + // Minimum execution time: 10_830_000 picoseconds. + Weight::from_parts(11_341_000, 6886) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: `SignedVoting::PendingCleanup` (r:1 w:1) + /// Proof: `SignedVoting::PendingCleanup` (`max_values`: Some(1), `max_size`: Some(5401), added: 5896, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VotingFor` (r:16 w:16) + /// Proof: `SignedVoting::VotingFor` (`max_values`: None, `max_size`: Some(53), added: 2528, mode: `MaxEncodedLen`) + /// The range of component `c` is `[1, 16]`. + fn idle_cleanup_chunk(c: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `106 + c * (47 ±0)` + // Estimated: `6886 + c * (2528 ±0)` + // Minimum execution time: 13_255_000 picoseconds. + Weight::from_parts(12_911_771, 6886) + // Standard Error: 5_426 + .saturating_add(Weight::from_parts(1_024_154, 0).saturating_mul(c.into())) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(c.into()))) + .saturating_add(T::DbWeight::get().writes(1_u64)) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(c.into()))) + .saturating_add(Weight::from_parts(0, 2528).saturating_mul(c.into())) + } +} + +// For backwards compatibility and tests. +impl WeightInfo for () { + /// Storage: `Referenda::ReferendumStatusFor` (r:1 w:1) + /// Proof: `Referenda::ReferendumStatusFor` (`max_values`: None, `max_size`: Some(219), added: 2694, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VoterSetOf` (r:1 w:0) + /// Proof: `SignedVoting::VoterSetOf` (`max_values`: None, `max_size`: Some(2062), added: 4537, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::TallyOf` (r:1 w:1) + /// Proof: `SignedVoting::TallyOf` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VotingFor` (r:1 w:1) + /// Proof: `SignedVoting::VotingFor` (`max_values`: None, `max_size`: Some(53), added: 2528, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Lookup` (r:1 w:1) + /// Proof: `Scheduler::Lookup` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Agenda` (r:1 w:1) + /// Proof: `Scheduler::Agenda` (`max_values`: None, `max_size`: Some(10463), added: 12938, mode: `MaxEncodedLen`) + /// The range of component `v` is `[1, 64]`. + fn vote(v: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `659 + v * (32 ±0)` + // Estimated: `13928` + // Minimum execution time: 55_464_000 picoseconds. + Weight::from_parts(57_373_252, 13928) + // Standard Error: 1_349 + .saturating_add(Weight::from_parts(17_612, 0).saturating_mul(v.into())) + .saturating_add(ParityDbWeight::get().reads(6_u64)) + .saturating_add(ParityDbWeight::get().writes(5_u64)) + } + /// Storage: `Referenda::ReferendumStatusFor` (r:1 w:1) + /// Proof: `Referenda::ReferendumStatusFor` (`max_values`: None, `max_size`: Some(219), added: 2694, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VoterSetOf` (r:1 w:0) + /// Proof: `SignedVoting::VoterSetOf` (`max_values`: None, `max_size`: Some(2062), added: 4537, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::TallyOf` (r:1 w:1) + /// Proof: `SignedVoting::TallyOf` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VotingFor` (r:1 w:1) + /// Proof: `SignedVoting::VotingFor` (`max_values`: None, `max_size`: Some(53), added: 2528, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Lookup` (r:1 w:1) + /// Proof: `Scheduler::Lookup` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Agenda` (r:2 w:2) + /// Proof: `Scheduler::Agenda` (`max_values`: None, `max_size`: Some(10463), added: 12938, mode: `MaxEncodedLen`) + /// The range of component `v` is `[1, 64]`. + fn remove_vote(v: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `855 + v * (32 ±0)` + // Estimated: `26866` + // Minimum execution time: 67_516_000 picoseconds. + Weight::from_parts(69_657_975, 26866) + // Standard Error: 1_844 + .saturating_add(Weight::from_parts(21_501, 0).saturating_mul(v.into())) + .saturating_add(ParityDbWeight::get().reads(7_u64)) + .saturating_add(ParityDbWeight::get().writes(6_u64)) + } + /// Storage: `Referenda::ReferendumStatusFor` (r:1 w:0) + /// Proof: `Referenda::ReferendumStatusFor` (`max_values`: None, `max_size`: Some(219), added: 2694, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::TallyOf` (r:1 w:1) + /// Proof: `SignedVoting::TallyOf` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `MultiCollective::Members` (r:2 w:0) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VoterSetOf` (r:0 w:1) + /// Proof: `SignedVoting::VoterSetOf` (`max_values`: None, `max_size`: Some(2062), added: 4537, mode: `MaxEncodedLen`) + fn on_poll_created() -> Weight { + // Proof Size summary in bytes: + // Measured: `606` + // Estimated: `10074` + // Minimum execution time: 32_972_000 picoseconds. + Weight::from_parts(33_672_000, 10074) + .saturating_add(ParityDbWeight::get().reads(4_u64)) + .saturating_add(ParityDbWeight::get().writes(2_u64)) + } + /// Storage: `SignedVoting::TallyOf` (r:1 w:1) + /// Proof: `SignedVoting::TallyOf` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::PendingCleanup` (r:1 w:1) + /// Proof: `SignedVoting::PendingCleanup` (`max_values`: Some(1), `max_size`: Some(5401), added: 5896, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VoterSetOf` (r:0 w:1) + /// Proof: `SignedVoting::VoterSetOf` (`max_values`: None, `max_size`: Some(2062), added: 4537, mode: `MaxEncodedLen`) + fn on_poll_completed() -> Weight { + // Proof Size summary in bytes: + // Measured: `149` + // Estimated: `6886` + // Minimum execution time: 10_830_000 picoseconds. + Weight::from_parts(11_341_000, 6886) + .saturating_add(ParityDbWeight::get().reads(2_u64)) + .saturating_add(ParityDbWeight::get().writes(3_u64)) + } + /// Storage: `SignedVoting::PendingCleanup` (r:1 w:1) + /// Proof: `SignedVoting::PendingCleanup` (`max_values`: Some(1), `max_size`: Some(5401), added: 5896, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VotingFor` (r:16 w:16) + /// Proof: `SignedVoting::VotingFor` (`max_values`: None, `max_size`: Some(53), added: 2528, mode: `MaxEncodedLen`) + /// The range of component `c` is `[1, 16]`. + fn idle_cleanup_chunk(c: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `106 + c * (47 ±0)` + // Estimated: `6886 + c * (2528 ±0)` + // Minimum execution time: 13_255_000 picoseconds. + Weight::from_parts(12_911_771, 6886) + // Standard Error: 5_426 + .saturating_add(Weight::from_parts(1_024_154, 0).saturating_mul(c.into())) + .saturating_add(ParityDbWeight::get().reads(1_u64)) + .saturating_add(ParityDbWeight::get().reads((1_u64).saturating_mul(c.into()))) + .saturating_add(ParityDbWeight::get().writes(1_u64)) + .saturating_add(ParityDbWeight::get().writes((1_u64).saturating_mul(c.into()))) + .saturating_add(Weight::from_parts(0, 2528).saturating_mul(c.into())) + } +} From f11a3bbd4fc9b2cc0b27cfd40eac9b51c9c52248 Mon Sep 17 00:00:00 2001 From: Loris Moulin Date: Sun, 28 Jun 2026 23:19:45 -0300 Subject: [PATCH 4/6] Introduce pallet-referenda --- Cargo.lock | 28 +- Cargo.toml | 1 + common/src/lib.rs | 10 + pallets/referenda/Cargo.toml | 73 + pallets/referenda/README.md | 197 +++ pallets/referenda/src/benchmarking.rs | 121 ++ pallets/referenda/src/lib.rs | 1136 +++++++++++++++ pallets/referenda/src/mock.rs | 831 +++++++++++ pallets/referenda/src/tests.rs | 1875 +++++++++++++++++++++++++ pallets/referenda/src/types.rs | 422 ++++++ pallets/referenda/src/weights.rs | 252 ++++ 11 files changed, 4943 insertions(+), 3 deletions(-) create mode 100644 pallets/referenda/Cargo.toml create mode 100644 pallets/referenda/README.md create mode 100644 pallets/referenda/src/benchmarking.rs create mode 100644 pallets/referenda/src/lib.rs create mode 100644 pallets/referenda/src/mock.rs create mode 100644 pallets/referenda/src/tests.rs create mode 100644 pallets/referenda/src/types.rs create mode 100644 pallets/referenda/src/weights.rs diff --git a/Cargo.lock b/Cargo.lock index 2143df2e66..b76f3446db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10513,6 +10513,28 @@ dependencies = [ "scale-info", ] +[[package]] +name = "pallet-referenda" +version = "1.0.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "log", + "pallet-balances", + "pallet-multi-collective", + "pallet-preimage", + "pallet-scheduler", + "pallet-signed-voting", + "parity-scale-codec", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "subtensor-macros", + "subtensor-runtime-common", +] + [[package]] name = "pallet-referenda" version = "41.0.0" @@ -12839,7 +12861,7 @@ dependencies = [ "pallet-proxy", "pallet-ranked-collective", "pallet-recovery", - "pallet-referenda", + "pallet-referenda 41.0.0", "pallet-remark", "pallet-revive", "pallet-root-offences", @@ -14323,7 +14345,7 @@ dependencies = [ "pallet-proxy", "pallet-ranked-collective", "pallet-recovery", - "pallet-referenda", + "pallet-referenda 41.0.0", "pallet-root-testing", "pallet-scheduler", "pallet-session", @@ -20516,7 +20538,7 @@ dependencies = [ "pallet-preimage", "pallet-proxy", "pallet-recovery", - "pallet-referenda", + "pallet-referenda 41.0.0", "pallet-root-testing", "pallet-scheduler", "pallet-session", diff --git a/Cargo.toml b/Cargo.toml index 913cf5bc5d..b836a91843 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,7 @@ pallet-subtensor-swap-runtime-api = { path = "pallets/swap/runtime-api", default pallet-subtensor-swap-rpc = { path = "pallets/swap/rpc", default-features = false } pallet-multi-collective = { path = "pallets/multi-collective", default-features = false } pallet-signed-voting = { path = "pallets/signed-voting", default-features = false } +pallet-referenda = { path = "pallets/referenda", default-features = false } procedural-fork = { path = "support/procedural-fork", default-features = false } safe-bigmath = { package = "safe-bigmath", default-features = false, git = "https://github.com/sam0x17/safe-bigmath", rev = "013c49984910e1c9a23289e8c85e7a856e263a02" } safe-math = { path = "primitives/safe-math", default-features = false } diff --git a/common/src/lib.rs b/common/src/lib.rs index a31ef1d078..97ff2a6bd7 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -49,6 +49,16 @@ pub type Nonce = u32; pub const SMALL_TRANSFER_LIMIT: Balance = TaoBalance::new(500_000_000); // 0.5 TAO pub const SMALL_ALPHA_TRANSFER_LIMIT: AlphaBalance = AlphaBalance::new(500_000_000); // 0.5 Alpha +/// Pad `s` into a fixed-width byte array, truncating if it exceeds `N`. +pub fn pad_name(s: &[u8]) -> [u8; N] { + let mut out = [0u8; N]; + let len = s.len().min(N); + if let (Some(dst), Some(src)) = (out.get_mut(..len), s.get(..len)) { + dst.copy_from_slice(src); + } + out +} + #[freeze_struct("c972489bff40ae48")] #[repr(transparent)] #[derive( diff --git a/pallets/referenda/Cargo.toml b/pallets/referenda/Cargo.toml new file mode 100644 index 0000000000..17fbe7a7d8 --- /dev/null +++ b/pallets/referenda/Cargo.toml @@ -0,0 +1,73 @@ +[package] +name = "pallet-referenda" +version = "1.0.0" +authors = ["Bittensor Nucleus Team"] +edition.workspace = true +license = "Apache-2.0" +homepage = "https://bittensor.com" +description = "A pallet for on-chain decision making" +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-system = { workspace = true } +frame-support = { workspace = true } +frame-benchmarking = { workspace = true, optional = true } +sp-runtime = { workspace = true } +sp-io = { workspace = true } +subtensor-macros.workspace = true +subtensor-runtime-common = { workspace = true } +log = { workspace = true } + +[dev-dependencies] +pallet-balances = { workspace = true, default-features = true } +pallet-preimage = { workspace = true, default-features = true } +pallet-scheduler = { workspace = true, default-features = true } +pallet-signed-voting = { path = "../signed-voting", default-features = true } +pallet-multi-collective = { path = "../multi-collective", default-features = true } +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-system/std", + "frame-support/std", + "frame-benchmarking?/std", + "sp-runtime/std", + "sp-io/std", + "subtensor-runtime-common/std", + "log/std", +] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", + "subtensor-runtime-common/runtime-benchmarks", + "pallet-balances/runtime-benchmarks", + "pallet-multi-collective/runtime-benchmarks", + "pallet-preimage/runtime-benchmarks", + "pallet-scheduler/runtime-benchmarks", + "pallet-signed-voting/runtime-benchmarks" +] +try-runtime = [ + "frame-support/try-runtime", + "frame-system/try-runtime", + "sp-runtime/try-runtime", + "pallet-balances/try-runtime", + "pallet-multi-collective/try-runtime", + "pallet-preimage/try-runtime", + "pallet-scheduler/try-runtime", + "pallet-signed-voting/try-runtime" +] diff --git a/pallets/referenda/README.md b/pallets/referenda/README.md new file mode 100644 index 0000000000..50c1a36b52 --- /dev/null +++ b/pallets/referenda/README.md @@ -0,0 +1,197 @@ +# pallet-referenda + +Track-based on-chain referenda. Proposals are filed against a track +that defines who may submit, who may vote, and how a tally is turned +into a decision. The pallet runs the state machine and dispatches the +governed call when approved; voting itself is delegated to a separate +backend (e.g. `pallet-signed-voting`) through the `Polls` trait. + +The pallet only stores referendum status and a thin scheduler-cleanup +handle. Tallies, voter lists, and per-account vote records live in the +voting backend. + +## Architecture + +``` + ┌──────────────────┐ + │ pallet-referenda │ <─── this pallet + │ │ + │ submit, kill │ + │ advance │ + │ enact │ + └──┬────────────┬──┘ + on_poll_created │ │ Polls + on_poll_completed │ │ is_ongoing + ▼ │ voting_scheme_of + ┌──────────────────┐ voter_set_of + │ Voting backend │ on_tally_updated + │ (e.g. signed- │ + │ voting) │ + └──────────────────┘ +``` + +Tracks come from a runtime-supplied `TracksInfo` impl: each track +declares its proposer set, voter set, voting scheme, and decision +strategy. + +## Decision strategies + +| Strategy | Decision | Outcome | +| -------- | -------- | ------- | +| `PassOrFail` | Approve / reject by deadline. | On approval the call is dispatched directly, or handed off to a child review referendum filed on an `Adjustable` track. On rejection or deadline elapse the referendum terminates. | +| `Adjustable` | Timing decision over an already-scheduled call. | Submit schedules the call at `submitted + initial_delay`. Voters can fast-track it sooner, cancel it, or shift the dispatch time via interpolation on net votes: net approval shrinks the delay toward zero, net rejection extends it toward the track's `max_delay` before the cancel threshold fires. The shape of that interpolation is set by `Config::AdjustmentCurve`. | + +## Extrinsics + +| Call | Origin | Effect | +| ---- | ------ | ------ | +| `submit` | signed (must be in the track's proposer set) | Open a new referendum carrying `call`. | +| `kill` | `T::KillOrigin` | Privileged termination of an undispatched referendum; cancels pending scheduler entries and concludes as `Killed`. | +| `advance_referendum` | root | Drive the state machine for one referendum. Invoked by the alarm; available as a manual recovery path. | +| `enact` | root | Dispatch the inner call and mark the referendum as enacted. Invoked by the scheduler at the configured dispatch time; no-op on terminal-no-dispatch statuses. | + +## State machine + +`PassOrFail`: + +```text + submit + │ + ▼ + vote re-arms ┌───────┐ kill + alarm ┌─►│Ongoing│─────────────────────► Killed + │ └───┬───┘ + │ │ alarm fires: + │ ├─ approve (Execute) ─► Approved ─► enact ─► Enacted + │ ├─ approve (Review) ─► Delegated + │ ├─ reject_threshold ─► Rejected + │ ├─ deadline reached ─► Expired + │ └─ no decision yet ─► re-arm alarm at deadline + └──────┘ +``` + +`Adjustable`: + +```text + submit + │ + │ schedule enact at submitted + initial_delay + ▼ + vote re-arms ┌───────┐ kill + alarm ┌─►│Ongoing│─────────────────────► Killed + │ └───┬───┘ + │ ├─ enact fires (natural) ─► Enacted + │ │ alarm fires: + │ ├─ fast_track_threshold ─► FastTracked ─► enact ─► Enacted + │ ├─ cancel_threshold ─► Cancelled + │ └─ otherwise ─► reschedule enact (earlier on + └──────┘ net approval, later on net rejection) +``` + +`kill` is also accepted from `Approved` and `FastTracked` until +`enact` dispatches: the wrapper task is cancelled and the inner call +never runs. + +## Design notes + +### Dispatch wrapping + +Approval and adjustable submission both schedule a wrapper call +`Pallet::enact(index, call)` rather than the governed call directly. +The wrapper marks the referendum as enacted in the same call that +dispatches the inner call, so dispatch and the `Enacted` status +transition are atomic. A stale wrapper that fires after a failed +cancel cannot run the call twice: `enact` no-ops on terminal-no- +dispatch statuses. + +### Tally hook deferral + +`Polls::on_tally_updated` only stores the new tally and arms an alarm +at `now + 1`. All decision logic runs from the alarm via +`advance_referendum`, which keeps the tally hook free of re-entrancy +with the voting backend. + +### Track-config snapshotting + +`submit` snapshots the track's decision strategy into the referendum. +State-machine evaluation reads the snapshot, so a runtime upgrade +that changes thresholds, swaps strategies, or removes a track only +affects new submissions; live referenda continue to resolve under the +rules they started with. + +Voter-set membership stays dynamic: percentages reflect current +membership of the underlying collective. + +### Per-proposer quota + +`MaxActivePerProposer` bounds the number of simultaneously-active +referenda one account can hold. This caps the blast radius of a +compromised proposer key when many proposers compete for the global +`MaxQueued` slots. + +### Adjustment curve + +The mapping from net-vote progress to delay fraction is supplied by +the runtime as `Config::AdjustmentCurve`. The pallet calls +`AdjustmentCurve::apply(progress)` on each side, where `progress` is +the position of the net vote between zero and the side-specific +threshold (`fast_track_threshold` for net approval, +`cancel_threshold` for net rejection). The same curve is applied to +both sides for symmetry. The choice is runtime-global and not +snapshotted: a runtime upgrade that swaps the impl takes effect for +all in-flight referenda on the next state-machine evaluation. + +## Integrity check + +`integrity_test` runs at runtime construction and panics on a +misconfigured track table: + +- Duplicate track ids. +- `ApprovalAction::Review { track }` referencing an unknown track or + one whose strategy is not `Adjustable`. +- `PassOrFail` with zero `decision_period`, `approve_threshold`, or + `reject_threshold`; or with + `approve_threshold + reject_threshold ≤ 100%` so the reject branch + could be masked by an approval that fires first on the same tally + split. +- `Adjustable` with zero `initial_delay`, `fast_track_threshold`, or + `cancel_threshold`; with `max_delay < initial_delay` (so net + rejection cannot extend the delay); or with + `fast_track_threshold + cancel_threshold ≤ 100%` so the cancel + branch could be masked by a fast-track that fires first on the same + tally split. + +## 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. + +## Configuration + +```rust +parameter_types! { + pub const MaxQueued: u32 = 20; + pub const MaxActivePerProposer: u32 = 5; +} + +impl pallet_referenda::Config for Runtime { + type RuntimeCall = RuntimeCall; + type Scheduler = Scheduler; + type Preimages = Preimage; + type MaxQueued = MaxQueued; + type MaxActivePerProposer = MaxActivePerProposer; + type KillOrigin = EnsureRoot; + type Tracks = tracks::Tracks; + type AdjustmentCurve = tracks::EaseOutAdjustmentCurve; + type BlockNumberProvider = System; + type OnPollCreated = SignedVoting; + type OnPollCompleted = SignedVoting; + type WeightInfo = pallet_referenda::weights::SubstrateWeight; +} +``` + +## License + +Apache-2.0. diff --git a/pallets/referenda/src/benchmarking.rs b/pallets/referenda/src/benchmarking.rs new file mode 100644 index 0000000000..154517f7b9 --- /dev/null +++ b/pallets/referenda/src/benchmarking.rs @@ -0,0 +1,121 @@ +//! Benchmarks for `pallet_referenda`. +//! +//! Setup is parameterised through [`Config::BenchmarkHelper`]: the runtime +//! supplies track ids of each strategy variant plus a proposer that's +//! already in the directly submittable track's proposer set. +//! +//! `advance_referendum` is benchmarked on its worst-case branch +//! (approve-with-`Review`): the parent fires `OnPollCompleted`, the child +//! fires `OnPollCreated`, and two scheduler operations run. Every other +//! branch is strictly cheaper, so a single figure soundly bounds them all. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use super::*; +use alloc::boxed::Box; +use frame_benchmarking::v2::*; +use frame_system::RawOrigin; +use sp_runtime::Perbill; + +#[benchmarks] +mod benches { + use super::*; + + /// Worst-case `submit` for directly submittable tracks: this runtime's + /// `Adjustable` review track is not directly submittable, so the worst + /// reachable path is `PassOrFail`, which schedules the deadline alarm. + #[benchmark] + fn submit() { + let proposer = T::BenchmarkHelper::proposer(); + T::BenchmarkHelper::seed_collective_members(); + let track = T::BenchmarkHelper::track_passorfail(); + let call = Box::new(T::BenchmarkHelper::call()); + + #[extrinsic_call] + submit(RawOrigin::Signed(proposer), track, call); + + assert_eq!(ActiveCount::::get(), 1); + } + + /// Worst-case `kill` for directly submittable tracks: an `Adjustable` + /// review would cancel both enactment and alarm tasks, but it is not + /// directly submittable in this runtime, so the worst reachable path is + /// `PassOrFail` before approval. + #[benchmark] + fn kill() { + let proposer = T::BenchmarkHelper::proposer(); + T::BenchmarkHelper::seed_collective_members(); + let track = T::BenchmarkHelper::track_passorfail(); + let call = Box::new(T::BenchmarkHelper::call()); + let index = ReferendumCount::::get(); + Pallet::::submit(RawOrigin::Signed(proposer).into(), track, call) + .expect("submit must succeed in benchmark setup"); + + #[extrinsic_call] + kill(RawOrigin::Root, index); + + assert!(matches!( + ReferendumStatusFor::::get(index), + Some(ReferendumStatus::Killed(_)) + )); + } + + /// Worst-case `advance_referendum`: PassOrFail with `Review` outcome. + /// Fires both `OnPollCreated` (for the child) and `OnPollCompleted` + /// (parent), runs two scheduler operations. + #[benchmark] + fn advance_referendum() { + let proposer = T::BenchmarkHelper::proposer(); + T::BenchmarkHelper::seed_collective_members(); + let track = T::BenchmarkHelper::track_passorfail(); + let call = Box::new(T::BenchmarkHelper::call()); + let index = ReferendumCount::::get(); + Pallet::::submit(RawOrigin::Signed(proposer).into(), track, call) + .expect("submit must succeed in benchmark setup"); + + // Force the approve-with-Review branch by overwriting the tally. + let mut info = match ReferendumStatusFor::::get(index) { + Some(ReferendumStatus::Ongoing(info)) => info, + _ => panic!("expected ongoing referendum"), + }; + info.tally = VoteTally { + approval: Perbill::one(), + rejection: Perbill::zero(), + abstention: Perbill::zero(), + }; + ReferendumStatusFor::::insert(index, ReferendumStatus::Ongoing(info)); + + #[extrinsic_call] + advance_referendum(RawOrigin::Root, index); + + assert!(matches!( + ReferendumStatusFor::::get(index), + Some(ReferendumStatus::Delegated(_)) + )); + } + + /// `OnTallyUpdated` hook: stores the new tally and arms an alarm at + /// `now + 1`. Benchmarked as a function call rather than an extrinsic. + #[benchmark] + fn on_tally_updated() { + let proposer = T::BenchmarkHelper::proposer(); + T::BenchmarkHelper::seed_collective_members(); + let track = T::BenchmarkHelper::track_passorfail(); + let call = Box::new(T::BenchmarkHelper::call()); + let index = ReferendumCount::::get(); + Pallet::::submit(RawOrigin::Signed(proposer).into(), track, call) + .expect("submit must succeed in benchmark setup"); + + let tally = VoteTally { + approval: Perbill::from_percent(50), + rejection: Perbill::from_percent(10), + abstention: Perbill::from_percent(40), + }; + + #[block] + { + as Polls>::on_tally_updated(index, &tally); + } + } + + impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test); +} diff --git a/pallets/referenda/src/lib.rs b/pallets/referenda/src/lib.rs new file mode 100644 index 0000000000..12c397658d --- /dev/null +++ b/pallets/referenda/src/lib.rs @@ -0,0 +1,1136 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +//! # Referenda +//! +//! Track-based on-chain referenda with two decision strategies. +//! +//! ## Tracks +//! +//! Each referendum is filed against a `Track` defined by the runtime via the +//! [`TracksInfo`] trait. A track carries the proposer set, the voter set, the +//! voting scheme, and the decision strategy. Two strategies are supported: +//! +//! * `PassOrFail`: a binary decision before a deadline. Submitters provide a +//! call. On approval the call is dispatched (either directly, or handed off +//! to an `Adjustable` review track via `ApprovalAction::Review`). +//! * `Adjustable`: a timing decision over an already-scheduled call. The call +//! runs after `initial_delay` by default. Voters can fast-track it sooner, +//! cancel it entirely, or shift the dispatch time via a curve-shaped +//! interpolation on net votes. +//! +//! ## Lifecycle +//! +//! `submit` records a referendum, schedules the relevant scheduler entries +//! (an alarm for `PassOrFail`; an enactment task for `Adjustable`), and +//! notifies subscribers via [`OnPollCreated::on_poll_created`]. +//! +//! Tally updates arrive through [`Polls::on_tally_updated`]. The hook is +//! intentionally side-effect-light: it stores the new tally and arms an +//! alarm at `now + 1`. All decision logic runs from the alarm via +//! `advance_referendum`, which keeps the tally hook free of re-entrancy. +//! +//! `advance_referendum` is the single state-machine entry point. For an +//! `Ongoing` referendum it dispatches into the appropriate threshold or +//! timing logic; on terminal statuses it is a no-op. +//! +//! ## Dispatch wrapping +//! +//! Approval (Execute) and Adjustable submission both schedule a wrapper +//! call `Pallet::enact(index, call)` rather than the governed call +//! directly. The scheduler invokes the wrapper with `RawOrigin::Root` at +//! the configured time; `enact` dispatches the inner call and marks the +//! referendum `Enacted` in the same call. Dispatch and `Enacted` are +//! atomic; the pallet never has to infer dispatch from scheduler-internal +//! state. `enact` no-ops on terminal-no-dispatch statuses, so a stale +//! wrapper task that fires after a failed scheduler cancel (e.g. inside +//! `kill` or `do_cancel`) cannot dispatch. The submit-time preimage is +//! dropped at scheduling time since the wrapper is the sole reference to +//! the inner call from then on. +//! +//! ## State machine +//! +//! `PassOrFail` track: +//! +//! ```text +//! submit +//! │ +//! ▼ +//! vote re-arms alarm ┌───────┐ kill +//! (now + 1) ┌─►│Ongoing│───────────────────────────► Killed (terminal) +//! │ └───┬───┘ +//! │ │ +//! │ │ alarm fires: +//! │ ├─ approve_threshold + Execute ─► Approved ─► enact ─► Enacted +//! │ ├─ approve_threshold + Review ─► Delegated (terminal) +//! │ ├─ reject_threshold ─► Rejected (terminal) +//! │ ├─ deadline reached ─► Expired (terminal) +//! │ └─ no decision, before deadline ─► re-arm at deadline, +//! └──────┘ stay Ongoing +//! ``` +//! +//! `Adjustable` track: +//! +//! ```text +//! submit +//! │ +//! │ schedule enact(index) at submitted + initial_delay +//! ▼ +//! vote re-arms alarm ┌───────┐ kill +//! (now + 1) ┌─►│Ongoing│───────────────────────────► Killed (terminal) +//! │ └───┬───┘ +//! │ │ +//! │ ├─ enact fires (natural) ─► Enacted (terminal) +//! │ │ alarm fires: +//! │ ├─ fast_track_threshold ─► FastTracked ─► enact ─► Enacted +//! │ ├─ cancel_threshold ─► Cancelled (terminal) +//! │ └─ otherwise: do_adjust_delay ─► move enact task (earlier +//! └──────┘ on net approval, later on +//! net rejection), stay Ongoing +//! ``` +//! +//! `kill` is also accepted from `Approved` (PassOrFail) and +//! `FastTracked` (Adjustable) until `enact` dispatches: the wrapper task +//! is cancelled and the inner call never runs. +//! +//! ## Status taxonomy +//! +//! * `Ongoing`: voting in progress. +//! * `Approved`: vote crossed `approve_threshold` on a `PassOrFail` track +//! with `ApprovalAction::Execute`. The `enact(index)` wrapper is +//! scheduled on this index and will mark `Enacted` when it dispatches. +//! * `Delegated`: vote crossed `approve_threshold` on a `PassOrFail` track +//! with `ApprovalAction::Review`. The call now lives on a fresh +//! referendum on the configured review track; this index is a terminal +//! audit trail. +//! * `Rejected`: vote crossed `reject_threshold` on a `PassOrFail` track. +//! * `Expired`: `PassOrFail` decision period elapsed without crossing +//! either threshold. +//! * `FastTracked`: vote crossed `fast_track_threshold` on an `Adjustable` +//! track. Wrapper rescheduled to next block; marks `Enacted` on dispatch. +//! * `Cancelled`: vote crossed `cancel_threshold` on an `Adjustable` +//! track. Wrapper cancelled and [`EnactmentTask`] cleared. +//! * `Enacted`: the dispatch attempt completed. The `Enacted` event +//! carries the inner call's result via an `Option`. +//! * `Killed`: privileged termination via `KillOrigin`. +//! +//! ## Alarm and task discipline +//! +//! Each referendum has at most one alarm (`alarm_name(index)`) and at +//! most one enactment task (`task_name(index)`). [`set_alarm`] is +//! idempotent: it reschedules an existing alarm when possible and only +//! schedules a fresh alarm when none is pending. +//! +//! `Adjustable` enactment tasks can move earlier or later than the +//! initial schedule via interpolation on net votes (see +//! `do_adjust_delay`): net approval shrinks the delay toward zero, +//! net rejection extends it toward the track's `max_delay` before +//! the cancel threshold fires. The mapping from net-vote progress to +//! delay fraction is shaped by [`Config::AdjustmentCurve`], which the +//! runtime supplies; the pallet itself stays curve-agnostic. +//! +//! ## Runtime configuration check +//! +//! [`Pallet::integrity_test`] runs at startup and asserts that the track +//! table is well-formed: +//! +//! * Track ids are unique. +//! * Every `ApprovalAction::Review { track }` references a track that +//! exists and uses the `Adjustable` strategy. +//! * `PassOrFail` tracks have non-zero `decision_period`, +//! `approve_threshold`, and `reject_threshold`; +//! `approve_threshold + reject_threshold > 100%` so the reject branch +//! cannot be masked by an approval that fires first on the same tally +//! split. +//! * `Adjustable` tracks have non-zero `initial_delay`, +//! `fast_track_threshold`, and `cancel_threshold`; +//! `max_delay >= initial_delay`; and +//! `fast_track_threshold + cancel_threshold > 100%` so the cancel +//! branch cannot be masked by a fast-track that fires first on the +//! same tally split. +//! +//! A misconfigured runtime panics at boot with a precise cause. +//! +//! ## Track-config snapshotting +//! +//! `submit` snapshots the track's [`DecisionStrategy`] into +//! [`ReferendumInfo`]. State-machine evaluation reads the snapshot, not +//! the live track table. Runtime upgrades that change thresholds, swap +//! strategy, or remove a track therefore only affect *new* submissions; +//! live referenda continue to resolve under the rules they started with. +//! +//! Voter-set membership stays dynamic by design (collective members +//! naturally come and go), so percentages reflect current membership. +//! +//! Removing a track from the runtime is safe for the state machine but +//! freezes the tally on any in-flight referendum (signed-voting refuses +//! new votes when [`Polls::voter_set_of`] returns `None`). All paths are +//! still terminal: PassOrFail resolves on the frozen tally or expires at +//! `decision_period`; Adjustable runs at `initial_delay`. To drop a +//! track cleanly, ship a migration that resolves (kills, concludes, or +//! reassigns) live referenda on that track before the upgrade. + +extern crate alloc; + +use alloc::boxed::Box; +use frame_support::{ + dispatch::{DispatchResult, GetDispatchInfo}, + pallet_prelude::*, + sp_runtime::{ + Perbill, Saturating, + traits::{BlockNumberProvider, Dispatchable, One, Zero}, + }, + traits::{ + QueryPreimage, StorePreimage, + schedule::{DispatchTime, v3::Named as ScheduleNamed}, + }, +}; +use frame_system::pallet_prelude::*; +use subtensor_runtime_common::{OnPollCompleted, OnPollCreated, Polls, SetLike, VoteTally}; + +pub use pallet::*; +pub use types::*; +pub use weights::WeightInfo; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; +mod types; +pub mod weights; + +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +#[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 aggregate runtime call type. Submitted calls and the + /// pallet's own `advance_referendum` are dispatched through this. + type RuntimeCall: Parameter + + Dispatchable + + GetDispatchInfo + + From> + + IsType<::RuntimeCall> + + From>; + + /// Named scheduler used to queue enactment tasks and alarms. Each + /// referendum has at most one task and one alarm, identified by + /// the names produced by [`task_name`] and [`alarm_name`]. + type Scheduler: ScheduleNamed< + BlockNumberFor, + CallOf, + PalletsOriginOf, + Hasher = Self::Hashing, + >; + + /// Preimage provider used to bound submitted calls into a + /// content-addressed reference and to bound the pallet's own + /// `advance_referendum` call when scheduling alarms. + type Preimages: QueryPreimage + StorePreimage; + + /// Maximum number of simultaneously-active referenda. Submission is + /// rejected with [`Error::QueueFull`] when this is reached. + type MaxQueued: Get; + + /// Maximum number of simultaneously-active referenda that a single + /// proposer may hold. Bounds the queue surface a single account can + /// occupy when many proposers compete for [`MaxQueued`] slots. + type MaxActivePerProposer: Get; + + /// Origin authorized to terminate an ongoing referendum via `kill`. + type KillOrigin: EnsureOrigin; + + /// Track configuration. Defines the proposer set, voter set, voting + /// scheme, and decision strategy for each track id. + type Tracks: TracksInfo, BlockNumberFor>; + + /// Curve applied to net-vote progress on `Adjustable` tracks. Not + /// snapshotted: a runtime upgrade that swaps the impl affects all + /// in-flight referenda. + type AdjustmentCurve: AdjustmentCurve; + + /// Source of "now" used for scheduling decisions. Typically + /// `frame_system::Pallet`; configurable for runtimes that + /// expose a different block-number authority. + type BlockNumberProvider: BlockNumberProvider>; + + /// Subscriber notified when a new referendum is created. The hook + /// returns its actual weight; the pallet pre-charges + /// `OnPollCreated::weight()` and refunds the unused portion. + type OnPollCreated: OnPollCreated; + + /// Subscriber notified when a referendum reaches a terminal status. + /// Same weight contract as [`OnPollCreated`]. + type OnPollCompleted: OnPollCompleted; + + /// Weight information for extrinsics in this pallet. + type WeightInfo: WeightInfo; + + /// Helper for setting up cross-pallet state needed by benchmarks. + /// The runtime provides track ids of each strategy variant plus a + /// proposer guaranteed to be in those tracks' proposer sets. + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper: BenchmarkHelper, Self::AccountId, CallOf>; + } + + /// Benchmark setup helper. The runtime wires this with track ids and a + /// proposer that match its track table; the mock provides defaults + /// matching `pallet-referenda::mock::TestTracks`. + /// + /// Note: only a `PassOrFail` track is needed for the approve benchmark + /// because the `Review` outcome is the worst case and bounds `Execute` + /// from above (see [`weights::WeightInfo`]). + #[cfg(feature = "runtime-benchmarks")] + pub trait BenchmarkHelper { + /// Track id of a `PassOrFail` track. The benchmark drives both the + /// approve and reject paths through it. + fn track_passorfail() -> TrackId; + /// Track id of an `Adjustable` track. + fn track_adjustable() -> TrackId; + /// Account in the proposer set of both tracks returned above. + fn proposer() -> AccountId; + /// Seed collective members that we need for benchmarks. + fn seed_collective_members(); + /// A call that `T::Tracks::authorize_proposal` accepts. Should be + /// cheap to bound (e.g. `frame_system::remark`). + fn call() -> Call; + } + + /// Monotonic referendum id generator. Incremented by `submit`; never + /// decremented. Existing referenda continue to be identified by their + /// assigned id even after the count moves on. + #[pallet::storage] + pub type ReferendumCount = StorageValue<_, ReferendumIndex, ValueQuery>; + + /// Number of currently-ongoing referenda. Bounded by [`Config::MaxQueued`] + /// and used as the capacity check at submit time. Distinct from + /// [`ReferendumCount`], which only ever grows. + #[pallet::storage] + pub type ActiveCount = StorageValue<_, u32, ValueQuery>; + + /// Per-proposer count of currently-ongoing referenda. Bounded by + /// [`Config::MaxActivePerProposer`]. + #[pallet::storage] + pub type ActivePerProposer = + StorageMap<_, Blake2_128Concat, T::AccountId, u32, ValueQuery>; + + /// Status of every referendum that has been submitted, keyed by index. + /// Entries persist after the referendum reaches a terminal state so the + /// outcome remains queryable for audit. + #[pallet::storage] + pub type ReferendumStatusFor = + StorageMap<_, Blake2_128Concat, ReferendumIndex, ReferendumStatusOf, OptionQuery>; + + /// Wrapper preimage handle for any referendum with a scheduled enactment + /// task. Present iff `task_name(index)` is currently in the scheduler's + /// agenda. Used to release the scheduler's preimage ref on cancel paths, + /// since `Scheduler::cancel_named` via the trait API does not drop the + /// preimage it requested at schedule time. + #[pallet::storage] + pub type EnactmentTask = + StorageMap<_, Blake2_128Concat, ReferendumIndex, BoundedCallOf, OptionQuery>; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// A new referendum was submitted. + Submitted { + /// Index assigned to the new referendum. + index: ReferendumIndex, + /// Track the referendum was filed against. + track: TrackIdOf, + /// Account that submitted the referendum. + proposer: T::AccountId, + }, + /// The approval threshold was crossed and the call has been + /// scheduled for direct dispatch. + Approved { + /// Referendum that was approved. + index: ReferendumIndex, + }, + /// The approval threshold was crossed and the call was handed + /// off to a child review referendum. + Delegated { + /// Parent referendum that approved the handoff. + index: ReferendumIndex, + /// New referendum that now carries the call. + review: ReferendumIndex, + /// Track the new referendum was filed against. + track: TrackIdOf, + }, + /// Approval was reached on a review handoff but the child + /// referendum could not be created. The parent stays ongoing + /// and will retry on the next vote or expire at its deadline. + ReviewSchedulingFailed { + /// Parent referendum whose handoff failed. + index: ReferendumIndex, + /// Track the handoff was attempting to file against. + track: TrackIdOf, + }, + /// The rejection threshold was crossed. + Rejected { + /// Referendum that was rejected. + index: ReferendumIndex, + }, + /// The cancel threshold was crossed and the scheduled call has + /// been cancelled. + Cancelled { + /// Referendum that was cancelled. + index: ReferendumIndex, + }, + /// The referendum was terminated by a privileged origin before + /// dispatch. + Killed { + /// Referendum that was killed. + index: ReferendumIndex, + }, + /// The decision period elapsed without crossing the approve or + /// reject threshold. + Expired { + /// Referendum that expired. + index: ReferendumIndex, + }, + /// The fast-track threshold was crossed and the call now runs + /// in the next block. + FastTracked { + /// Referendum that was fast-tracked. + index: ReferendumIndex, + }, + /// The dispatch attempt completed. + Enacted { + /// Referendum that was enacted. + index: ReferendumIndex, + /// Block at which dispatch ran. + when: BlockNumberFor, + /// `None` if the inner call returned `Ok`, otherwise the + /// failure returned by the dispatch. + error: Option, + }, + /// A scheduler operation failed. Surfaced for observability; + /// the pallet does not roll back the surrounding state change. + SchedulerOperationFailed { + /// Referendum the failed operation was acting on. + index: ReferendumIndex, + }, + } + + #[pallet::error] + pub enum Error { + /// The specified track does not exist. + BadTrack, + /// The track has no proposer set configured. + TrackNotSubmittable, + /// The caller is not in the track's proposer set. + NotProposer, + /// The referendum has already concluded. + ReferendumFinalized, + /// The proposal is not authorized for this track. + ProposalNotAuthorized, + /// The active-referenda cap has been reached. + QueueFull, + /// The per-proposer active-referenda cap has been reached. + ProposerQuotaExceeded, + /// A scheduler operation failed at submit time. + SchedulerError, + /// The specified referendum does not exist. + ReferendumNotFound, + /// Reached a state combination that should be prevented by + /// submit-time invariants. Indicates a configuration mismatch. + Unreachable, + /// The track's voter set is empty. With no eligible voters the + /// tally would freeze at zero and the referendum would resolve + /// to a pre-determined outcome. + EmptyVoterSet, + } + + #[pallet::hooks] + impl Hooks> for Pallet { + fn integrity_test() { + T::Tracks::check_integrity().expect("pallet-referenda: invalid track configuration"); + } + + #[cfg(feature = "try-runtime")] + fn try_state( + _n: BlockNumberFor, + ) -> Result<(), frame_support::sp_runtime::TryRuntimeError> { + Pallet::::do_try_state() + } + } + + #[pallet::call] + impl Pallet { + /// Submit a new referendum on `track` carrying `call`. On a + /// pass-or-fail track the call is held until the approval + /// threshold is reached; on an adjustable track the call is + /// scheduled for dispatch immediately and voting only adjusts + /// when it runs. + #[pallet::call_index(0)] + #[pallet::weight( + T::WeightInfo::submit().saturating_add(T::OnPollCreated::weight()) + )] + pub fn submit( + origin: OriginFor, + track: TrackIdOf, + call: Box>, + ) -> DispatchResult { + let proposer = ensure_signed(origin)?; + let track_info = T::Tracks::info(track).ok_or(Error::::BadTrack)?; + + let Some(ref proposer_set) = track_info.proposer_set else { + return Err(Error::::TrackNotSubmittable.into()); + }; + ensure!(proposer_set.contains(&proposer), Error::::NotProposer); + ensure!( + T::Tracks::authorize_proposal(&track_info, &call), + Error::::ProposalNotAuthorized + ); + ensure!(!track_info.voter_set.is_empty(), Error::::EmptyVoterSet); + let active = ActiveCount::::get(); + ensure!(active < T::MaxQueued::get(), Error::::QueueFull); + let active_per_proposer = ActivePerProposer::::get(&proposer); + ensure!( + active_per_proposer < T::MaxActivePerProposer::get(), + Error::::ProposerQuotaExceeded + ); + + let now = T::BlockNumberProvider::current_block_number(); + let index = ReferendumCount::::get(); + ReferendumCount::::put(index.saturating_add(1)); + ActiveCount::::put(active.saturating_add(1)); + ActivePerProposer::::insert(&proposer, active_per_proposer.saturating_add(1)); + + let proposal = match &track_info.decision_strategy { + DecisionStrategy::PassOrFail { + decision_period, .. + } => { + let when = now.saturating_add(*decision_period); + Self::set_alarm(index, when)?; + let bounded_call = T::Preimages::bound(*call)?; + Proposal::Action(bounded_call) + } + DecisionStrategy::Adjustable { initial_delay, .. } => { + let when = now.saturating_add(*initial_delay); + Self::schedule_enactment(index, DispatchTime::At(when), call)?; + Proposal::Review + } + }; + + let info = ReferendumInfo { + track, + proposal, + proposer: proposer.clone(), + submitted: now, + tally: VoteTally::default(), + decision_strategy: track_info.decision_strategy, + }; + ReferendumStatusFor::::insert(index, ReferendumStatus::Ongoing(info)); + + T::OnPollCreated::on_poll_created(index); + + Self::deposit_event(Event::::Submitted { + index, + track, + proposer, + }); + + Ok(()) + } + + /// Privileged termination of a referendum that has not yet + /// dispatched. Cancels any pending scheduler entries, releases + /// the wrapper preimage, and records the referendum as killed. + /// Already-terminal referenda are rejected. + #[pallet::call_index(1)] + #[pallet::weight( + T::WeightInfo::kill().saturating_add(T::OnPollCompleted::weight()) + )] + pub fn kill(origin: OriginFor, index: ReferendumIndex) -> DispatchResult { + T::KillOrigin::ensure_origin(origin)?; + + let status = + ReferendumStatusFor::::get(index).ok_or(Error::::ReferendumNotFound)?; + ensure!( + matches!( + status, + ReferendumStatus::Ongoing(_) + | ReferendumStatus::Approved(_) + | ReferendumStatus::FastTracked(_) + ), + Error::::ReferendumFinalized + ); + + // Best-effort cleanup. Either entry may legitimately be absent: + // PassOrFail has no enactment task before approval, and the alarm + // for Approved/FastTracked has already fired (it is what drove + // the transition). If a cancel fails and the wrapper task still + // dispatches, `enact` no-ops on the terminal status. + let _ = T::Scheduler::cancel_named(task_name(index)); + let _ = T::Scheduler::cancel_named(alarm_name(index)); + // `Scheduler::cancel_named` via the trait API does not drop the + // preimage it requested at schedule time; balance manually so the + // wrapper preimage is fully released. + if let Some(wrapper) = EnactmentTask::::take(index) { + T::Preimages::drop(&wrapper); + } + + let now = T::BlockNumberProvider::current_block_number(); + Self::conclude( + index, + ReferendumStatus::Killed(now), + Event::::Killed { index }, + ); + Ok(()) + } + + /// Drive the state machine for `index`. Invoked by the alarm + /// and available as a privileged extrinsic for manual recovery + /// if the alarm has been dropped. + #[pallet::call_index(2)] + #[pallet::weight( + // Worst-case bound: the approve-with-`Review` branch fires both hooks. + T::WeightInfo::advance_referendum() + .saturating_add(T::OnPollCreated::weight()) + .saturating_add(T::OnPollCompleted::weight()) + )] + pub fn advance_referendum(origin: OriginFor, index: ReferendumIndex) -> DispatchResult { + ensure_root(origin)?; + + let status = + ReferendumStatusFor::::get(index).ok_or(Error::::ReferendumNotFound)?; + + if let ReferendumStatus::Ongoing(info) = status { + Self::advance_ongoing(index, info)?; + } + + Ok(()) + } + + /// Dispatch `call` and mark the referendum as enacted. + /// Invoked by the scheduler at the configured dispatch time; + /// root may also call it directly to retry a referendum whose + /// scheduled task was lost. + /// + /// No-op on terminal-no-dispatch statuses, so a stale task + /// that fires after a cancel cannot run the call twice. + #[pallet::call_index(3)] + #[pallet::weight( + T::WeightInfo::advance_referendum() + .saturating_add(call.get_dispatch_info().call_weight) + )] + pub fn enact( + origin: OriginFor, + index: ReferendumIndex, + call: Box>, + ) -> DispatchResult { + ensure_root(origin)?; + + let Some(status) = ReferendumStatusFor::::get(index) else { + return Ok(()); + }; + match status { + ReferendumStatus::Ongoing(_) + | ReferendumStatus::Approved(_) + | ReferendumStatus::FastTracked(_) => {} + _ => return Ok(()), + } + + let error = call + .dispatch(frame_system::RawOrigin::Root.into()) + .err() + .map(|post| post.error); + + // Tracking entry only; the scheduler drops the wrapper preimage + // ref itself once the dispatch returns to it. + EnactmentTask::::remove(index); + + let now = T::BlockNumberProvider::current_block_number(); + Self::conclude( + index, + ReferendumStatus::Enacted(now), + Event::::Enacted { + index, + when: now, + error, + }, + ); + + Ok(()) + } + } +} + +impl Pallet { + /// Runtime-state invariants. Live against populated state, so this + /// runs from `try_state` rather than `integrity_test`. + /// + /// * Initialized voter sets are non-empty: an empty voter set silently + /// breaks delegation. `schedule_for_review` would create a review + /// child no one can vote on, and the Adjustable state machine would + /// lapse it to `Enacted` after `initial_delay`. + /// * Initialized `proposer_set: Some(_)` sets are non-empty: + /// `Some(empty)` silently closes the track to all submissions; if + /// that is intended, the track must declare `proposer_set: None` to + /// make it explicit. + /// + /// Genesis can legitimately observe empty sets before the + /// stake-ranking warmup populates collectives; that is a separate + /// concern and not enforced here. + #[cfg(any(feature = "try-runtime", test))] + pub fn do_try_state() -> Result<(), frame_support::sp_runtime::TryRuntimeError> { + for track in T::Tracks::tracks() { + ensure!( + !track.info.voter_set.is_initialized() || !track.info.voter_set.is_empty(), + "pallet-referenda: track has empty voter set" + ); + if let Some(set) = &track.info.proposer_set { + ensure!( + !set.is_initialized() || !set.is_empty(), + "pallet-referenda: track has Some(empty) proposer_set; use None" + ); + } + } + Ok(()) + } + + /// PassOrFail no-decision branch: expire if the deadline has elapsed, + /// otherwise re-arm the deadline alarm. + fn expire_or_rearm_deadline( + index: ReferendumIndex, + submitted: BlockNumberFor, + decision_period: BlockNumberFor, + ) { + let deadline = submitted.saturating_add(decision_period); + let now = T::BlockNumberProvider::current_block_number(); + if now >= deadline { + Self::do_expire(index); + } else if let Err(err) = Self::set_alarm(index, deadline) { + Self::report_scheduler_error(index, "set_alarm", err); + } + } + + /// Used in scheduled-call contexts where `Err` cannot be propagated + /// to a caller; surfaces the failure off-chain instead. + fn report_scheduler_error(index: ReferendumIndex, operation: &str, err: DispatchError) { + log::error!( + target: "runtime::referenda", + "Scheduler {} failed for referendum {}: {:?}", + operation, + index, + err, + ); + Self::deposit_event(Event::::SchedulerOperationFailed { index }); + } + + /// Run threshold checks on an `Ongoing` referendum and dispatch to + /// the appropriate action helper based on the proposal kind. + fn advance_ongoing(index: ReferendumIndex, info: ReferendumInfoOf) -> DispatchResult { + let tally = info.tally; + + match &info.proposal { + Proposal::Action(_) => { + let DecisionStrategy::PassOrFail { + decision_period, + approve_threshold, + reject_threshold, + on_approval, + } = &info.decision_strategy + else { + return Err(Error::::Unreachable.into()); + }; + + if tally.approval >= *approve_threshold { + Self::do_approve(index, &info, on_approval, *decision_period); + } else if tally.rejection >= *reject_threshold { + Self::do_reject(index); + } else { + Self::expire_or_rearm_deadline(index, info.submitted, *decision_period); + } + } + Proposal::Review => { + let DecisionStrategy::Adjustable { + initial_delay, + max_delay, + fast_track_threshold, + cancel_threshold, + } = &info.decision_strategy + else { + return Err(Error::::Unreachable.into()); + }; + + if tally.approval >= *fast_track_threshold { + Self::do_fast_track(index); + } else if tally.rejection >= *cancel_threshold { + Self::do_cancel(index); + } else { + Self::do_adjust_delay( + index, + &tally, + info.submitted, + *initial_delay, + *max_delay, + *fast_track_threshold, + *cancel_threshold, + ); + } + } + } + + Ok(()) + } + + fn conclude(index: ReferendumIndex, status: ReferendumStatusOf, event: Event) { + let releases_preimage = matches!( + status, + ReferendumStatus::Rejected(_) + | ReferendumStatus::Expired(_) + | ReferendumStatus::Killed(_) + ); + + let prior = ReferendumStatusFor::::get(index); + ReferendumStatusFor::::insert(index, status); + + if let Some(ReferendumStatus::Ongoing(info)) = prior { + ActiveCount::::mutate(|c| *c = c.saturating_sub(1)); + ActivePerProposer::::mutate(&info.proposer, |c| *c = c.saturating_sub(1)); + T::OnPollCompleted::on_poll_completed(index); + + if releases_preimage && let Proposal::Action(bounded) = info.proposal { + T::Preimages::drop(&bounded); + } + } + + Self::deposit_event(event); + } + + /// Both `Execute` and `Review` fail closed on scheduler error: the + /// parent stays `Ongoing` with the deadline alarm re-armed so the + /// approved call cannot dispatch without going through the configured + /// path. + fn do_approve( + index: ReferendumIndex, + info: &ReferendumInfoOf, + on_approval: &ApprovalAction>, + decision_period: BlockNumberFor, + ) { + let Proposal::Action(bounded_call) = &info.proposal else { + // Reachable only on a configuration mismatch (track strategy + // changed under live referenda). Bail without action. + return; + }; + + let Ok((inner, _)) = T::Preimages::peek(bounded_call) else { + Self::expire_or_rearm_deadline(index, info.submitted, decision_period); + return; + }; + + if let ApprovalAction::Review { track } = on_approval { + let Some(review) = + Self::schedule_for_review(Box::new(inner), info.proposer.clone(), *track) + else { + Self::deposit_event(Event::::ReviewSchedulingFailed { + index, + track: *track, + }); + Self::expire_or_rearm_deadline(index, info.submitted, decision_period); + return; + }; + T::Preimages::drop(bounded_call); + + let now = T::BlockNumberProvider::current_block_number(); + Self::conclude( + index, + ReferendumStatus::Delegated(now), + Event::::Delegated { + index, + review, + track: *track, + }, + ); + return; + } + + if let Err(err) = + Self::schedule_enactment(index, DispatchTime::After(Zero::zero()), Box::new(inner)) + { + Self::report_scheduler_error(index, "schedule_enactment", err); + Self::expire_or_rearm_deadline(index, info.submitted, decision_period); + return; + } + T::Preimages::drop(bounded_call); + + let now = T::BlockNumberProvider::current_block_number(); + Self::conclude( + index, + ReferendumStatus::Approved(now), + Event::::Approved { index }, + ); + } + + /// The child claims a slot against `ActiveCount`; the caller's + /// `conclude` on the parent releases its slot, so the net change is + /// zero. No `Submitted` event is emitted: the child is created by + /// approval, not by user submission. + fn schedule_for_review( + call: Box>, + proposer: T::AccountId, + track: TrackIdOf, + ) -> Option { + let track_info = T::Tracks::info(track)?; + let DecisionStrategy::Adjustable { initial_delay, .. } = &track_info.decision_strategy + else { + return None; + }; + if track_info.voter_set.is_empty() { + return None; + } + + let now = T::BlockNumberProvider::current_block_number(); + let when = now.saturating_add(*initial_delay); + let new_index = ReferendumCount::::get(); + + if let Err(err) = Self::schedule_enactment(new_index, DispatchTime::At(when), call) { + Self::report_scheduler_error(new_index, "schedule_enactment", err); + return None; + } + + ReferendumCount::::put(new_index.saturating_add(1)); + ActiveCount::::mutate(|c| *c = c.saturating_add(1)); + ActivePerProposer::::mutate(&proposer, |c| *c = c.saturating_add(1)); + + let new_info = ReferendumInfo { + track, + proposal: Proposal::Review, + proposer, + submitted: now, + tally: VoteTally::default(), + decision_strategy: track_info.decision_strategy, + }; + ReferendumStatusFor::::insert(new_index, ReferendumStatus::Ongoing(new_info)); + + T::OnPollCreated::on_poll_created(new_index); + + Some(new_index) + } + + fn do_reject(index: ReferendumIndex) { + let now = T::BlockNumberProvider::current_block_number(); + Self::conclude( + index, + ReferendumStatus::Rejected(now), + Event::::Rejected { index }, + ); + } + + fn do_expire(index: ReferendumIndex) { + let now = T::BlockNumberProvider::current_block_number(); + Self::conclude( + index, + ReferendumStatus::Expired(now), + Event::::Expired { index }, + ); + } + + fn do_fast_track(index: ReferendumIndex) { + if let Err(err) = + T::Scheduler::reschedule_named(task_name(index), DispatchTime::After(Zero::zero())) + { + Self::report_scheduler_error(index, "reschedule_task", err); + return; + } + + let now = T::BlockNumberProvider::current_block_number(); + Self::conclude( + index, + ReferendumStatus::FastTracked(now), + Event::::FastTracked { index }, + ); + } + + /// The scheduler emits its own `Canceled` event for the underlying task. + /// If `cancel_named` fails and the wrapper still fires, `enact` no-ops + /// on the `Cancelled` status. + fn do_cancel(index: ReferendumIndex) { + if let Err(err) = T::Scheduler::cancel_named(task_name(index)) { + Self::report_scheduler_error(index, "cancel_task", err); + } + // See `kill` for the rationale on the manual preimage drop. + if let Some(wrapper) = EnactmentTask::::take(index) { + T::Preimages::drop(&wrapper); + } + + let now = T::BlockNumberProvider::current_block_number(); + Self::conclude( + index, + ReferendumStatus::Cancelled(now), + Event::::Cancelled { index }, + ); + } + + /// Interpolation on net votes (approval - rejection), shaped by + /// [`Config::AdjustmentCurve`]. At net = 0 the delay equals + /// `initial_delay`. Net approval shrinks the delay toward zero as the + /// net approaches `fast_track_threshold`; net rejection extends it + /// toward `max_delay` as the net approaches `-cancel_threshold`. The + /// target is anchored at `submitted` so repeated reschedules cannot + /// drift the call. + fn do_adjust_delay( + index: ReferendumIndex, + tally: &VoteTally, + submitted: BlockNumberFor, + initial_delay: BlockNumberFor, + max_delay: BlockNumberFor, + fast_track_threshold: Perbill, + cancel_threshold: Perbill, + ) { + let computed_delay: BlockNumberFor = if tally.approval >= tally.rejection { + let net = tally.approval.saturating_sub(tally.rejection); + let progress = + Perbill::from_rational(net.deconstruct(), fast_track_threshold.deconstruct()); + let curved = T::AdjustmentCurve::apply(progress); + let remaining = Perbill::one().saturating_sub(curved); + remaining.mul_floor(initial_delay) + } else { + let net = tally.rejection.saturating_sub(tally.approval); + let progress = + Perbill::from_rational(net.deconstruct(), cancel_threshold.deconstruct()); + let curved = T::AdjustmentCurve::apply(progress); + let max_extension = max_delay.saturating_sub(initial_delay); + initial_delay.saturating_add(curved.mul_floor(max_extension)) + }; + let target = submitted.saturating_add(computed_delay); + + let now = T::BlockNumberProvider::current_block_number(); + if target <= now { + Self::do_fast_track(index); + return; + } + + // Avoid `RescheduleNoChange` when the target is unchanged. + if Self::next_task_dispatch_time(index) == Some(target) { + return; + } + + if let Err(err) = T::Scheduler::reschedule_named(task_name(index), DispatchTime::At(target)) + { + Self::report_scheduler_error(index, "reschedule_task", err); + } + } + + /// Idempotent: reschedules any prior alarm with the same name, so callers + /// do not need to track whether one is currently pending. + fn set_alarm(index: ReferendumIndex, when: BlockNumberFor) -> Result<(), DispatchError> { + if let Ok(existing) = T::Scheduler::next_dispatch_time(alarm_name(index)) { + if existing == when { + return Ok(()); + } + return T::Scheduler::reschedule_named(alarm_name(index), DispatchTime::At(when)) + .map(|_| ()); + } + let call = T::Preimages::bound(CallOf::::from(Call::advance_referendum { index }))?; + let res = T::Scheduler::schedule_named( + alarm_name(index), + DispatchTime::At(when), + None, + 0, // highest priority + frame_system::RawOrigin::Root.into(), + call.clone(), + ); + T::Preimages::drop(&call); + res.map(|_| ()) + } + + /// Wraps the inner call in `Pallet::enact { index, call }`, making + /// the `Ongoing/Approved/FastTracked -> Enacted` transition atomic + /// with dispatch. Parks the handle in [`EnactmentTask`] so cancel + /// paths can release the scheduler's preimage ref. + fn schedule_enactment( + index: ReferendumIndex, + desired: DispatchTime>, + call: Box>, + ) -> DispatchResult { + let wrapper = T::Preimages::bound(CallOf::::from(Call::enact { index, call }))?; + let res = T::Scheduler::schedule_named( + task_name(index), + desired, + None, + 0, // highest priority + frame_system::RawOrigin::Root.into(), + wrapper.clone(), + ); + T::Preimages::drop(&wrapper); + res?; + EnactmentTask::::insert(index, wrapper); + Ok(()) + } + + fn ongoing_info(index: ReferendumIndex) -> Option> { + match ReferendumStatusFor::::get(index)? { + ReferendumStatus::Ongoing(info) => Some(info), + _ => None, + } + } + + /// `None` when no task with that name is currently queued. + fn next_task_dispatch_time(index: ReferendumIndex) -> Option> { + , + CallOf, + PalletsOriginOf, + >>::next_dispatch_time(task_name(index)) + .ok() + } +} + +impl Polls for Pallet { + type Index = ReferendumIndex; + type VotingScheme = VotingSchemeOf; + type VoterSet = VoterSetOf; + + fn is_ongoing(index: Self::Index) -> bool { + Self::ongoing_info(index).is_some() + } + + fn voting_scheme_of(index: Self::Index) -> Option { + let info = Self::ongoing_info(index)?; + T::Tracks::info(info.track).map(|t| t.voting_scheme) + } + + fn voter_set_of(index: Self::Index) -> Option { + let info = Self::ongoing_info(index)?; + T::Tracks::info(info.track).map(|t| t.voter_set) + } + + fn on_tally_updated(index: Self::Index, tally: &VoteTally) { + let Some(mut info) = Self::ongoing_info(index) else { + return; + }; + let now = T::BlockNumberProvider::current_block_number(); + + info.tally = *tally; + ReferendumStatusFor::::insert(index, ReferendumStatus::Ongoing(info)); + + // Defer evaluation by one block. The hook stores the new tally; the + // alarm fires next block and runs `advance_referendum` from a clean + // dispatch context, avoiding re-entrancy with caller. + if let Err(err) = Self::set_alarm(index, now.saturating_add(One::one())) { + Self::report_scheduler_error(index, "set_alarm", err); + } + } + + fn on_tally_updated_weight() -> Weight { + T::WeightInfo::on_tally_updated() + } +} diff --git a/pallets/referenda/src/mock.rs b/pallets/referenda/src/mock.rs new file mode 100644 index 0000000000..5bd3e4db33 --- /dev/null +++ b/pallets/referenda/src/mock.rs @@ -0,0 +1,831 @@ +#![allow( + clippy::arithmetic_side_effects, + clippy::unwrap_used, + clippy::expect_used +)] + +use core::cell::RefCell; + +use frame_support::{derive_impl, pallet_prelude::*, parameter_types, traits::EqualPrivilegeOnly}; +use frame_system::{EnsureRoot, limits}; +use sp_core::U256; +use sp_runtime::{BuildStorage, Perbill, traits::IdentityLookup}; +use subtensor_runtime_common::pad_name; + +use crate::{self as pallet_referenda, *}; +use pallet_multi_collective::{ + self, Collective, CollectiveInfo, CollectiveInspect, CollectivesInfo, +}; + +type Block = frame_system::mocking::MockBlock; + +frame_support::construct_runtime!( + pub enum Test { + System: frame_system = 1, + Balances: pallet_balances = 2, + Preimage: pallet_preimage = 3, + Scheduler: pallet_scheduler = 4, + Referenda: pallet_referenda = 5, + SignedVoting: pallet_signed_voting = 6, + MultiCollective: pallet_multi_collective = 7, + } +); + +#[derive( + Copy, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, +)] +pub enum CollectiveId { + Proposers, + Triumvirate, + Economic, + Building, +} + +#[derive( + Copy, + Clone, + PartialEq, + Eq, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, +)] +pub enum VotingScheme { + Signed, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MemberSet { + Single(CollectiveId), + Union(Vec), +} + +impl subtensor_runtime_common::SetLike for MemberSet { + fn contains(&self, who: &U256) -> bool { + match self { + MemberSet::Single(id) => as CollectiveInspect< + U256, + CollectiveId, + >>::is_member(*id, who), + MemberSet::Union(ids) => ids.iter().any(|id| { + as CollectiveInspect< + U256, + CollectiveId, + >>::is_member(*id, who) + }), + } + } + fn len(&self) -> u32 { + self.to_vec().len() as u32 + } + + fn is_initialized(&self) -> bool { + match self { + MemberSet::Single(id) => as CollectiveInspect< + U256, + CollectiveId, + >>::is_initialized(*id), + MemberSet::Union(ids) if ids.is_empty() => true, + MemberSet::Union(ids) => ids.iter().any(|id| { + as CollectiveInspect< + U256, + CollectiveId, + >>::is_initialized(*id) + }), + } + } + + fn to_vec(&self) -> Vec { + match self { + MemberSet::Single(id) => as CollectiveInspect< + U256, + CollectiveId, + >>::members_of(*id), + // Mirrors the production `GovernanceMemberSet` impl: members can + // overlap across collectives but a dual member can only vote + // once. Sum-of-`member_count` would inflate `total` and bias + // thresholds upward; dedup so the returned set has the true + // cardinality. + MemberSet::Union(ids) => { + let mut accounts: Vec = Vec::new(); + for id in ids { + accounts.extend( + as CollectiveInspect< + U256, + CollectiveId, + >>::members_of(*id), + ); + } + accounts.sort(); + accounts.dedup(); + accounts + } + } + } +} + +#[derive_impl(frame_system::config_preludes::TestDefaultConfig)] +impl frame_system::Config for Test { + type Block = Block; + type AccountId = U256; + type AccountData = pallet_balances::AccountData; + type Lookup = IdentityLookup; +} + +#[derive_impl(pallet_balances::config_preludes::TestDefaultConfig)] +impl pallet_balances::Config for Test { + type AccountStore = System; +} + +impl pallet_preimage::Config for Test { + type WeightInfo = pallet_preimage::weights::SubstrateWeight; + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type ManagerOrigin = EnsureRoot; + type Consideration = (); +} + +parameter_types! { + pub BlockWeights: limits::BlockWeights = limits::BlockWeights::with_sensible_defaults( + Weight::from_parts(2_000_000_000_000, u64::MAX), + Perbill::from_percent(75), + ); + pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; + pub const MaxScheduledPerBlock: u32 = 50; +} + +impl pallet_scheduler::Config for Test { + type RuntimeOrigin = RuntimeOrigin; + type RuntimeEvent = RuntimeEvent; + type PalletsOrigin = OriginCaller; + type RuntimeCall = RuntimeCall; + type MaximumWeight = MaximumSchedulerWeight; + type ScheduleOrigin = EnsureRoot; + type MaxScheduledPerBlock = MaxScheduledPerBlock; + type WeightInfo = pallet_scheduler::weights::SubstrateWeight; + type OriginPrivilegeCmp = EqualPrivilegeOnly; + type Preimages = Preimage; + type BlockNumberProvider = System; +} + +pub struct TestTracks; + +pub type MockTrack = Track; + +impl TracksInfo for TestTracks { + type Id = u8; + type ProposerSet = MemberSet; + type VotingScheme = VotingScheme; + type VoterSet = MemberSet; + + fn tracks() -> impl Iterator< + Item = Track< + Self::Id, + TrackName, + u64, + Self::ProposerSet, + Self::VoterSet, + Self::VotingScheme, + >, + > { + let overridden = current_track_override(); + if !overridden.is_empty() { + return overridden.into_iter(); + } + + vec![ + Track { + id: 0, + info: TrackInfo { + name: pad_name(b"triumvirate"), + proposer_set: Some(MemberSet::Single(CollectiveId::Proposers)), + voter_set: MemberSet::Single(CollectiveId::Triumvirate), + voting_scheme: VotingScheme::Signed, + decision_strategy: DecisionStrategy::PassOrFail { + decision_period: 20, + approve_threshold: Perbill::from_rational(2u32, 3u32), + reject_threshold: Perbill::from_rational(2u32, 3u32), + on_approval: ApprovalAction::Execute, + }, + }, + }, + Track { + id: 1, + info: TrackInfo { + name: pad_name(b"review"), + proposer_set: Some(MemberSet::Single(CollectiveId::Proposers)), + voter_set: MemberSet::Single(CollectiveId::Triumvirate), + voting_scheme: VotingScheme::Signed, + decision_strategy: DecisionStrategy::Adjustable { + initial_delay: 100, + max_delay: 200, + fast_track_threshold: Perbill::from_percent(75), + cancel_threshold: Perbill::from_percent(51), + }, + }, + }, + Track { + id: 2, + info: TrackInfo { + name: pad_name(b"delegating"), + proposer_set: Some(MemberSet::Single(CollectiveId::Proposers)), + voter_set: MemberSet::Single(CollectiveId::Triumvirate), + voting_scheme: VotingScheme::Signed, + decision_strategy: DecisionStrategy::PassOrFail { + decision_period: 20, + approve_threshold: Perbill::from_rational(2u32, 3u32), + reject_threshold: Perbill::from_rational(2u32, 3u32), + on_approval: ApprovalAction::Review { track: 1 }, + }, + }, + }, + Track { + id: 3, + info: TrackInfo { + name: pad_name(b"closed"), + proposer_set: None, + voter_set: MemberSet::Single(CollectiveId::Triumvirate), + voting_scheme: VotingScheme::Signed, + decision_strategy: DecisionStrategy::PassOrFail { + decision_period: 20, + approve_threshold: Perbill::from_rational(2u32, 3u32), + reject_threshold: Perbill::from_rational(2u32, 3u32), + on_approval: ApprovalAction::Execute, + }, + }, + }, + ] + .into_iter() + .filter(|t| !(t.id == 1 && review_track_hidden())) + .map(|mut t| { + if t.id == 1 && review_voter_set_empty() { + t.info.voter_set = MemberSet::Union(alloc::vec![]); + } + if t.id == 0 && track0_swapped_to_adjustable() { + t.info.decision_strategy = DecisionStrategy::Adjustable { + initial_delay: 100, + max_delay: 200, + fast_track_threshold: Perbill::from_percent(75), + cancel_threshold: Perbill::from_percent(51), + }; + } + t + }) + .collect::>() + .into_iter() + } + + fn authorize_proposal( + _track_info: &TrackInfo< + Self::Id, + TrackName, + u64, + Self::ProposerSet, + Self::VoterSet, + Self::VotingScheme, + >, + _call: &RuntimeCall, + ) -> bool { + AUTHORIZE_PROPOSAL_RESULT.with(|r| *r.borrow()) + } +} + +thread_local! { + static AUTHORIZE_PROPOSAL_RESULT: RefCell = const { RefCell::new(true) }; +} + +pub fn set_authorize_proposal(result: bool) { + AUTHORIZE_PROPOSAL_RESULT.with(|r| *r.borrow_mut() = result); +} + +/// Define a thread-local whose value can be temporarily replaced via an +/// RAII guard. The previous value is restored when the guard drops. +/// Used to simulate runtime-state mutations from tests without leaking +/// across cases. +macro_rules! define_scoped_state { + ($flag:ident, $guard:ident, $reader:ident, $ty:ty, $default:expr) => { + thread_local! { + static $flag: RefCell<$ty> = const { RefCell::new($default) }; + } + + #[must_use = "the guard restores the prior value on drop; bind it to a local"] + pub struct $guard { + previous: Option<$ty>, + } + + impl $guard { + pub fn new(value: $ty) -> Self { + let previous = + Some($flag.with(|r| core::mem::replace(&mut *r.borrow_mut(), value))); + Self { previous } + } + } + + impl Drop for $guard { + fn drop(&mut self) { + if let Some(prev) = self.previous.take() { + $flag.with(|r| *r.borrow_mut() = prev); + } + } + } + + fn $reader() -> $ty { + $flag.with(|r| r.borrow().clone()) + } + }; +} + +define_scoped_state!( + HIDE_REVIEW_TRACK, + HideReviewTrackGuard, + review_track_hidden, + bool, + false +); +define_scoped_state!( + EMPTY_REVIEW_VOTER_SET, + EmptyReviewVoterSetGuard, + review_voter_set_empty, + bool, + false +); +define_scoped_state!( + SWAP_PASS_OR_FAIL_TRACK_TO_ADJUSTABLE, + SwapTrack0ToAdjustableGuard, + track0_swapped_to_adjustable, + bool, + false +); +define_scoped_state!( + TRACKS_OVERRIDE, + OverrideTracksGuard, + current_track_override, + Vec, + Vec::new() +); + +pub struct TestCollectives; + +impl CollectivesInfo for TestCollectives { + type Id = CollectiveId; + + fn collectives() -> impl Iterator> { + vec![ + Collective { + id: CollectiveId::Proposers, + info: CollectiveInfo { + name: pad_name(b"proposers"), + min_members: 1, + max_members: Some(5), + term_duration: None, + }, + }, + Collective { + id: CollectiveId::Triumvirate, + info: CollectiveInfo { + name: pad_name(b"triumvirate"), + min_members: 1, + max_members: Some(3), + term_duration: None, + }, + }, + ] + .into_iter() + } +} + +parameter_types! { + pub const MaxMembers: u32 = 32; +} + +impl pallet_multi_collective::Config for Test { + type CollectiveId = CollectiveId; + type Collectives = TestCollectives; + type AddOrigin = frame_support::traits::AsEnsureOriginWithArg>; + type RemoveOrigin = frame_support::traits::AsEnsureOriginWithArg>; + type SwapOrigin = frame_support::traits::AsEnsureOriginWithArg>; + type SetOrigin = frame_support::traits::AsEnsureOriginWithArg>; + type RotateOrigin = frame_support::traits::AsEnsureOriginWithArg>; + type OnMembersChanged = (); + type OnNewTerm = (); + type MaxMembers = MaxMembers; + type WeightInfo = (); + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = ReferendaMockMcBenchmarkHelper; +} + +#[cfg(feature = "runtime-benchmarks")] +pub struct ReferendaMockMcBenchmarkHelper; + +#[cfg(feature = "runtime-benchmarks")] +impl pallet_multi_collective::BenchmarkHelper for ReferendaMockMcBenchmarkHelper { + fn collective() -> CollectiveId { + CollectiveId::Proposers + } + fn rotatable_collective() -> CollectiveId { + CollectiveId::Proposers + } +} + +parameter_types! { + pub const SignedScheme: VotingScheme = VotingScheme::Signed; + pub const VoterSetSize: u32 = 32; + pub const MaxPendingCleanup: u32 = 32; + pub const CleanupChunkSize: u32 = 4; + pub const CleanupCursorMaxLen: u32 = 128; +} + +impl pallet_signed_voting::Config for Test { + type Scheme = SignedScheme; + type Polls = Referenda; + type MaxVoterSetSize = VoterSetSize; + type MaxPendingCleanup = MaxPendingCleanup; + type CleanupChunkSize = CleanupChunkSize; + type CleanupCursorMaxLen = CleanupCursorMaxLen; + type WeightInfo = (); + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = SignedVotingMockBenchmarkHelper; +} + +#[cfg(feature = "runtime-benchmarks")] +pub struct SignedVotingMockBenchmarkHelper; + +#[cfg(feature = "runtime-benchmarks")] +impl pallet_signed_voting::benchmarking::BenchmarkHelper for SignedVotingMockBenchmarkHelper { + fn ongoing_poll() -> u32 { + let proposer = >::proposer(); + let track = >::track_adjustable(); + let call = >::call(); + let index = crate::ReferendumCount::::get(); + crate::Pallet::::submit( + frame_system::RawOrigin::Signed(proposer).into(), + track, + Box::new(call), + ) + .expect("submit must succeed in benchmark setup"); + index + } +} + +parameter_types! { + pub const MaxQueued: u32 = 10; + pub const MaxActivePerProposer: u32 = 3; +} + +pub struct LinearCurve; +impl pallet_referenda::AdjustmentCurve for LinearCurve { + fn apply(progress: Perbill) -> Perbill { + progress + } +} + +impl pallet_referenda::Config for Test { + type RuntimeCall = RuntimeCall; + type Scheduler = Scheduler; + type Preimages = Preimage; + type MaxQueued = MaxQueued; + type MaxActivePerProposer = MaxActivePerProposer; + type KillOrigin = EnsureRoot; + type Tracks = TestTracks; + type AdjustmentCurve = LinearCurve; + type BlockNumberProvider = System; + type OnPollCreated = SignedVoting; + type OnPollCompleted = SignedVoting; + type WeightInfo = (); + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = TestBenchmarkHelper; +} + +#[cfg(feature = "runtime-benchmarks")] +pub struct TestBenchmarkHelper; + +#[cfg(feature = "runtime-benchmarks")] +impl pallet_referenda::BenchmarkHelper for TestBenchmarkHelper { + /// Track 2: `PassOrFail` with `Review { track: 1 }`. Worst case for + /// the approve benchmark (creates a child referendum). + fn track_passorfail() -> u8 { + 2 + } + fn track_adjustable() -> u8 { + 1 + } + fn proposer() -> U256 { + U256::from(1) + } + fn seed_collective_members() {} + fn call() -> RuntimeCall { + RuntimeCall::System(frame_system::Call::remark { remark: vec![] }) + } +} + +pub struct TestState { + pub proposers: Vec, + pub triumvirate: Vec, +} + +impl Default for TestState { + fn default() -> Self { + Self { + proposers: vec![U256::from(1), U256::from(2)], + triumvirate: vec![U256::from(101), U256::from(102), U256::from(103)], + } + } +} + +impl TestState { + pub fn build_and_execute(self, test: impl FnOnce()) { + let mut ext = self.into_test_ext(); + ext.execute_with(test); + } + + /// Build the externalities object pre-populated with collectives. + /// Exposed for `impl_benchmark_test_suite!`, which expects a builder + /// that returns `sp_io::TestExternalities` rather than a `FnOnce`. + pub fn into_test_ext(self) -> sp_io::TestExternalities { + let mut ext: sp_io::TestExternalities = RuntimeGenesisConfig { + system: frame_system::GenesisConfig::default(), + balances: pallet_balances::GenesisConfig::default(), + } + .build_storage() + .unwrap() + .into(); + + ext.execute_with(|| { + System::set_block_number(1); + set_authorize_proposal(true); + + for p in &self.proposers { + pallet_multi_collective::Pallet::::add_member( + RuntimeOrigin::root(), + CollectiveId::Proposers, + *p, + ) + .unwrap(); + } + for t in &self.triumvirate { + pallet_multi_collective::Pallet::::add_member( + RuntimeOrigin::root(), + CollectiveId::Triumvirate, + *t, + ) + .unwrap(); + } + }); + + ext + } +} + +/// Externalities builder for `impl_benchmark_test_suite!`. +#[cfg(feature = "runtime-benchmarks")] +pub fn new_test_ext() -> sp_io::TestExternalities { + TestState::default().into_test_ext() +} + +pub fn run_to_block(n: u64) { + System::run_to_block::(n); +} + +/// Events emitted by `pallet_referenda` in insertion order. +pub fn referenda_events() -> Vec> { + System::events() + .into_iter() + .filter_map(|r| match r.event { + RuntimeEvent::Referenda(e) => Some(e), + _ => None, + }) + .collect() +} + +pub const PROPOSER: u128 = 1; +pub const PROPOSER_B: u128 = 2; +pub const VOTER_A: u128 = 101; +pub const VOTER_B: u128 = 102; +pub const VOTER_C: u128 = 103; + +pub const TRACK_PASS_OR_FAIL: u8 = 0; +pub const TRACK_ADJUSTABLE: u8 = 1; +pub const TRACK_DELEGATING: u8 = 2; +pub const TRACK_NO_PROPOSER_SET: u8 = 3; + +pub const DECISION_PERIOD: u64 = 20; +pub const INITIAL_DELAY: u64 = 100; + +pub fn make_call() -> RuntimeCall { + RuntimeCall::System(frame_system::Call::::remark { remark: vec![] }) +} + +/// Encoded length exceeds the 128-byte `BoundedInline` cap so the preimage +/// is stored as `Lookup` and contributes to the on-chain refcount, which is +/// what the preimage-cleanup tests assert against. +pub fn make_lookup_call() -> RuntimeCall { + RuntimeCall::System(frame_system::Call::::remark { + remark: vec![0u8; 256], + }) +} + +pub fn preimage_hash(call: &RuntimeCall) -> sp_core::H256 { + use sp_runtime::traits::Hash as HashT; + ::Hashing::hash_of(call) +} + +pub fn preimage_exists(hash: &sp_core::H256) -> bool { + pallet_preimage::RequestStatusFor::::contains_key(hash) +} + +pub fn enact_wrapper_hash(index: crate::ReferendumIndex, inner: RuntimeCall) -> sp_core::H256 { + preimage_hash(&RuntimeCall::Referenda(crate::Call::::enact { + index, + call: Box::new(inner), + })) +} + +pub fn submit_on(track: u8, proposer: U256) -> crate::ReferendumIndex { + use frame_support::assert_ok; + let index = crate::ReferendumCount::::get(); + assert_ok!(crate::Pallet::::submit( + RuntimeOrigin::signed(proposer), + track, + Box::new(make_call()), + )); + index +} + +pub fn vote(voter: u128, index: crate::ReferendumIndex, aye: bool) { + use frame_support::assert_ok; + assert_ok!(pallet_signed_voting::Pallet::::vote( + RuntimeOrigin::signed(U256::from(voter)), + index, + aye, + )); +} + +pub fn status_of(index: crate::ReferendumIndex) -> crate::ReferendumStatusOf { + crate::ReferendumStatusFor::::get(index).expect("referendum should exist") +} + +pub fn current_block() -> u64 { + System::block_number() +} + +pub fn scheduler_alarm_block(index: crate::ReferendumIndex) -> Option { + use frame_support::traits::schedule::v3::Named; + >::next_dispatch_time(crate::alarm_name( + index, + )) + .ok() +} + +pub fn signed_tally_exists(index: crate::ReferendumIndex) -> bool { + pallet_signed_voting::TallyOf::::get(index).is_some() +} + +pub fn has_event(matcher: impl Fn(&crate::Event) -> bool) -> bool { + referenda_events().iter().any(matcher) +} + +/// Assert the standard "concluded and cleaned up" invariants for a terminal +/// referendum: not Ongoing, no tally, no pending alarm, and the slot has +/// been released from `ActiveCount`. +pub fn assert_concluded(index: crate::ReferendumIndex, expected_active_after: u32) { + use subtensor_runtime_common::Polls; + assert!(!crate::Pallet::::is_ongoing(index)); + assert!(!signed_tally_exists(index)); + assert_eq!(crate::ActiveCount::::get(), expected_active_after); + // Conclude cancels the alarm; only Approved/FastTracked re-arm a new + // one for the Enacted transition. + if !matches!( + crate::ReferendumStatusFor::::get(index), + Some(crate::ReferendumStatus::Approved(_)) | Some(crate::ReferendumStatus::FastTracked(_)) + ) { + assert!(scheduler_alarm_block(index).is_none()); + } +} + +/// Drive the referendum forward up to `max_blocks` or until it leaves +/// `Ongoing`. +pub fn drive_to_terminal(index: crate::ReferendumIndex, max_blocks: u64) { + use subtensor_runtime_common::Polls; + let stop = current_block() + max_blocks; + while current_block() < stop && crate::Pallet::::is_ongoing(index) { + run_to_block(current_block() + 1); + } +} + +pub fn drive_to_status crate::ReferendumIndex>( + submit: F, + drive: impl Fn(crate::ReferendumIndex), +) -> crate::ReferendumIndex { + let i = submit(); + drive(i); + i +} + +pub fn check_integrity() -> Result<(), &'static str> { + >::check_integrity() +} + +pub fn passorfail_track(id: u8) -> MockTrack { + MockTrack { + id, + info: crate::TrackInfo { + name: subtensor_runtime_common::pad_name(b"test"), + proposer_set: Some(MemberSet::Single(CollectiveId::Proposers)), + voter_set: MemberSet::Single(CollectiveId::Triumvirate), + voting_scheme: VotingScheme::Signed, + decision_strategy: crate::DecisionStrategy::PassOrFail { + decision_period: 20, + approve_threshold: Perbill::from_percent(60), + reject_threshold: Perbill::from_percent(60), + on_approval: crate::ApprovalAction::Execute, + }, + }, + } +} + +pub fn adjustable_track(id: u8) -> MockTrack { + MockTrack { + id, + info: crate::TrackInfo { + name: subtensor_runtime_common::pad_name(b"test"), + proposer_set: Some(MemberSet::Single(CollectiveId::Proposers)), + voter_set: MemberSet::Single(CollectiveId::Triumvirate), + voting_scheme: VotingScheme::Signed, + decision_strategy: crate::DecisionStrategy::Adjustable { + initial_delay: 100, + max_delay: 200, + fast_track_threshold: Perbill::from_percent(75), + cancel_threshold: Perbill::from_percent(51), + }, + }, + } +} + +pub fn assert_check_integrity_err(tracks: Vec, expected: &str) { + TestState::default().build_and_execute(|| { + let _guard = OverrideTracksGuard::new(tracks); + assert_eq!(check_integrity(), Err(expected)); + }); +} + +pub fn assert_kill_drops_wrapper_after( + track: u8, + voters: &[u128], + is_intermediate: impl Fn(&crate::ReferendumStatusOf) -> bool, +) { + use frame_support::assert_ok; + TestState::default().build_and_execute(|| { + let call = make_lookup_call(); + assert_ok!(crate::Pallet::::submit( + RuntimeOrigin::signed(U256::from(PROPOSER)), + track, + Box::new(call.clone()), + )); + let index = crate::ReferendumCount::::get() - 1; + let wrapper_hash = enact_wrapper_hash(index, call); + + for v in voters { + vote(*v, index, true); + } + run_to_block(current_block() + 1); + assert!(is_intermediate(&status_of(index))); + assert!(preimage_exists(&wrapper_hash)); + + assert_ok!(crate::Pallet::::kill(RuntimeOrigin::root(), index)); + assert!(matches!( + status_of(index), + crate::ReferendumStatus::Killed(_) + )); + assert!(!preimage_exists(&wrapper_hash)); + assert!(crate::EnactmentTask::::get(index).is_none()); + assert!(has_event( + |e| matches!(e, crate::Event::Killed { index: i } if *i == index) + )); + }); +} diff --git a/pallets/referenda/src/tests.rs b/pallets/referenda/src/tests.rs new file mode 100644 index 0000000000..44abdafd7a --- /dev/null +++ b/pallets/referenda/src/tests.rs @@ -0,0 +1,1875 @@ +#![allow( + clippy::arithmetic_side_effects, + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing +)] + +use super::*; +use crate::mock::*; +use frame_support::{assert_noop, assert_ok}; +use sp_core::U256; +use sp_runtime::DispatchError; +use subtensor_runtime_common::Polls; + +#[test] +fn environment_is_initialized() { + TestState::default().build_and_execute(|| { + assert!(MemberSet::Single(CollectiveId::Proposers).contains(&U256::from(PROPOSER))); + assert_eq!(MemberSet::Single(CollectiveId::Triumvirate).len(), 3); + }); +} + +#[test] +fn submit_pass_or_fail_records_state_and_schedules_deadline_alarm() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + let now = current_block(); + + assert_eq!(ReferendumCount::::get(), 1); + assert_eq!(ActiveCount::::get(), 1); + assert!(signed_tally_exists(index)); + assert_eq!(scheduler_alarm_block(index), Some(now + DECISION_PERIOD)); + assert!(Pallet::::next_task_dispatch_time(index).is_none()); + + match status_of(index) { + ReferendumStatus::Ongoing(info) => { + assert_eq!(info.track, TRACK_PASS_OR_FAIL); + assert_eq!(info.proposer, U256::from(PROPOSER)); + assert_eq!(info.submitted, now); + assert!(matches!(info.proposal, Proposal::Action(_))); + } + _ => panic!("expected Ongoing"), + } + + assert!(has_event(|e| matches!( + e, + Event::Submitted { index: i, track, proposer } + if *i == index + && *track == TRACK_PASS_OR_FAIL + && *proposer == U256::from(PROPOSER) + ))); + }); +} + +#[test] +fn submit_adjustable_schedules_enact_wrapper_at_initial_delay() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + let now = current_block(); + + assert!(matches!( + status_of(index), + ReferendumStatus::Ongoing(ReferendumInfo { + proposal: Proposal::Review, + .. + }) + )); + assert_eq!( + Pallet::::next_task_dispatch_time(index), + Some(now + INITIAL_DELAY) + ); + assert!(scheduler_alarm_block(index).is_none()); + }); +} + +#[test] +fn submit_assigns_monotonic_indices() { + TestState::default().build_and_execute(|| { + let i0 = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + let i1 = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + let i2 = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER_B)); + assert_eq!((i0, i1, i2), (0, 1, 2)); + assert_eq!(ReferendumCount::::get(), 3); + assert_eq!(ActiveCount::::get(), 3); + }); +} + +#[test] +fn submit_rejects_invalid_origins_and_tracks() { + TestState::default().build_and_execute(|| { + // Bad track id. + assert_noop!( + Referenda::submit( + RuntimeOrigin::signed(U256::from(PROPOSER)), + 99u8, + Box::new(make_call()), + ), + Error::::BadTrack + ); + // Root and unsigned both fail; submit takes a signed origin only. + assert_noop!( + Referenda::submit( + RuntimeOrigin::root(), + TRACK_PASS_OR_FAIL, + Box::new(make_call()) + ), + DispatchError::BadOrigin + ); + // Caller is not in the proposer set. + assert_noop!( + Referenda::submit( + RuntimeOrigin::signed(U256::from(999)), + TRACK_PASS_OR_FAIL, + Box::new(make_call()), + ), + Error::::NotProposer + ); + // Track has no proposer set. + assert_noop!( + Referenda::submit( + RuntimeOrigin::signed(U256::from(PROPOSER)), + TRACK_NO_PROPOSER_SET, + Box::new(make_call()), + ), + Error::::TrackNotSubmittable + ); + }); +} + +/// A track whose voter set is currently empty would mathematically +/// freeze its tally at zero and drive the referendum to a fixed +/// outcome regardless of merit (auto-enactment on `Adjustable`, +/// expiry on `PassOrFail`). `submit` must refuse rather than create +/// such a referendum. +#[test] +fn submit_rejects_when_voter_set_is_empty() { + TestState { + proposers: vec![U256::from(PROPOSER)], + // Triumvirate is the voter set for tracks 0/1/2; leave it empty + // so `voter_set.is_empty()` triggers at submit time. + triumvirate: vec![], + } + .build_and_execute(|| { + assert_noop!( + Referenda::submit( + RuntimeOrigin::signed(U256::from(PROPOSER)), + TRACK_PASS_OR_FAIL, + Box::new(make_call()), + ), + Error::::EmptyVoterSet + ); + // No state mutated: index counter unchanged, no referendum stored. + assert_eq!(ReferendumCount::::get(), 0); + assert_eq!(ActiveCount::::get(), 0); + }); +} + +#[test] +fn submit_rejects_call_when_authorize_proposal_returns_false() { + TestState::default().build_and_execute(|| { + set_authorize_proposal(false); + assert_noop!( + Referenda::submit( + RuntimeOrigin::signed(U256::from(PROPOSER)), + TRACK_PASS_OR_FAIL, + Box::new(make_call()), + ), + Error::::ProposalNotAuthorized + ); + }); +} + +#[test] +fn submit_caps_at_max_queued_and_recycles_after_kill() { + let max_queued = ::MaxQueued::get(); + let per_proposer = ::MaxActivePerProposer::get(); + let proposer_count = max_queued.div_ceil(per_proposer); + let proposers: Vec = (1..=proposer_count).map(U256::from).collect(); + + TestState { + proposers: proposers.clone(), + ..Default::default() + } + .build_and_execute(|| { + let mut submitted = 0u32; + 'fill: for proposer in &proposers { + for _ in 0..per_proposer { + if submitted == max_queued { + break 'fill; + } + assert_ok!(Referenda::submit( + RuntimeOrigin::signed(*proposer), + TRACK_PASS_OR_FAIL, + Box::new(make_call()), + )); + submitted += 1; + } + } + assert_eq!(ActiveCount::::get(), max_queued); + + let next_proposer = U256::from(proposer_count + 1); + pallet_multi_collective::Pallet::::add_member( + RuntimeOrigin::root(), + CollectiveId::Proposers, + next_proposer, + ) + .unwrap(); + assert_noop!( + Referenda::submit( + RuntimeOrigin::signed(next_proposer), + TRACK_PASS_OR_FAIL, + Box::new(make_call()), + ), + Error::::QueueFull + ); + + assert_ok!(Referenda::kill(RuntimeOrigin::root(), 5)); + assert_ok!(Referenda::submit( + RuntimeOrigin::signed(next_proposer), + TRACK_PASS_OR_FAIL, + Box::new(make_call()), + )); + assert_eq!(ActiveCount::::get(), max_queued); + }); +} + +#[test] +fn submit_caps_at_per_proposer_quota_and_recycles_after_kill() { + let cap = ::MaxActivePerProposer::get(); + TestState::default().build_and_execute(|| { + let mut indices = Vec::new(); + for _ in 0..cap { + indices.push(submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER))); + } + assert_eq!(ActivePerProposer::::get(U256::from(PROPOSER)), cap); + + assert_noop!( + Referenda::submit( + RuntimeOrigin::signed(U256::from(PROPOSER)), + TRACK_PASS_OR_FAIL, + Box::new(make_call()), + ), + Error::::ProposerQuotaExceeded + ); + + assert_ok!(Referenda::submit( + RuntimeOrigin::signed(U256::from(PROPOSER_B)), + TRACK_PASS_OR_FAIL, + Box::new(make_call()), + )); + + assert_ok!(Referenda::kill(RuntimeOrigin::root(), indices[0])); + assert_eq!( + ActivePerProposer::::get(U256::from(PROPOSER)), + cap - 1 + ); + + assert_ok!(Referenda::submit( + RuntimeOrigin::signed(U256::from(PROPOSER)), + TRACK_PASS_OR_FAIL, + Box::new(make_call()), + )); + assert_eq!(ActivePerProposer::::get(U256::from(PROPOSER)), cap); + }); +} + +#[test] +fn kill_concludes_with_killed_status_and_full_cleanup() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + run_to_block(current_block() + 5); + let killed_at = current_block(); + + assert_ok!(Referenda::kill(RuntimeOrigin::root(), index)); + + assert!(matches!(status_of(index), ReferendumStatus::Killed(b) if b == killed_at)); + assert_concluded(index, 0); + assert!(Pallet::::next_task_dispatch_time(index).is_none()); + assert!(has_event( + |e| matches!(e, Event::Killed { index: i } if *i == index) + )); + }); +} + +#[test] +fn kill_rejects_non_kill_origin_and_unknown_index() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + assert_noop!( + Referenda::kill(RuntimeOrigin::signed(U256::from(PROPOSER)), index), + DispatchError::BadOrigin + ); + assert_noop!( + Referenda::kill(RuntimeOrigin::root(), 999), + Error::::ReferendumNotFound + ); + }); +} + +#[test] +fn kill_rejects_already_finalized_referendum_for_every_terminal_status() { + // `kill` accepts states that still hold scheduler hooks + // (`Ongoing`, `Approved`, `FastTracked`); it must reject every other + // terminal status with `ReferendumFinalized`. + TestState::default().build_and_execute(|| { + // Killed. + let i = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + assert_ok!(Referenda::kill(RuntimeOrigin::root(), i)); + assert_noop!( + Referenda::kill(RuntimeOrigin::root(), i), + Error::::ReferendumFinalized + ); + + // Enacted (after the wrapper dispatches). + let i = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + vote(VOTER_A, i, true); + vote(VOTER_B, i, true); + run_to_block(current_block() + 2); + assert!(matches!(status_of(i), ReferendumStatus::Enacted(_))); + assert_noop!( + Referenda::kill(RuntimeOrigin::root(), i), + Error::::ReferendumFinalized + ); + + // Rejected. + let i = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + vote(VOTER_A, i, false); + vote(VOTER_B, i, false); + run_to_block(current_block() + 2); + assert!(matches!(status_of(i), ReferendumStatus::Rejected(_))); + assert_noop!( + Referenda::kill(RuntimeOrigin::root(), i), + Error::::ReferendumFinalized + ); + + // Expired. + let i = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + run_to_block(current_block() + DECISION_PERIOD + 1); + assert!(matches!(status_of(i), ReferendumStatus::Expired(_))); + assert_noop!( + Referenda::kill(RuntimeOrigin::root(), i), + Error::::ReferendumFinalized + ); + + // Cancelled. + let i = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + vote(VOTER_A, i, false); + vote(VOTER_B, i, false); + run_to_block(current_block() + 2); + assert!(matches!(status_of(i), ReferendumStatus::Cancelled(_))); + assert_noop!( + Referenda::kill(RuntimeOrigin::root(), i), + Error::::ReferendumFinalized + ); + + // Delegated. + let i = submit_on(TRACK_DELEGATING, U256::from(PROPOSER)); + vote(VOTER_A, i, true); + vote(VOTER_B, i, true); + run_to_block(current_block() + 2); + assert!(matches!(status_of(i), ReferendumStatus::Delegated(_))); + assert_noop!( + Referenda::kill(RuntimeOrigin::root(), i), + Error::::ReferendumFinalized + ); + }); +} + +#[test] +fn kill_succeeds_on_approved_and_releases_wrapper_preimage() { + assert_kill_drops_wrapper_after(TRACK_PASS_OR_FAIL, &[VOTER_A, VOTER_B], |s| { + matches!(s, ReferendumStatus::Approved(_)) + }); +} + +#[test] +fn kill_succeeds_on_fast_tracked_and_releases_wrapper_preimage() { + assert_kill_drops_wrapper_after(TRACK_ADJUSTABLE, &[VOTER_A, VOTER_B, VOTER_C], |s| { + matches!(s, ReferendumStatus::FastTracked(_)) + }); +} + +#[test] +fn advance_referendum_origin_and_index_validation() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + assert_noop!( + Referenda::advance_referendum(RuntimeOrigin::signed(U256::from(PROPOSER)), index), + DispatchError::BadOrigin + ); + assert_noop!( + Referenda::advance_referendum(RuntimeOrigin::root(), 999), + Error::::ReferendumNotFound + ); + }); +} + +#[test] +fn advance_referendum_on_ongoing_runs_the_decision_logic() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + vote(VOTER_A, index, true); + vote(VOTER_B, index, true); + // Manual advance instead of waiting for the alarm. + assert_ok!(Referenda::advance_referendum(RuntimeOrigin::root(), index)); + assert!(matches!(status_of(index), ReferendumStatus::Approved(_))); + }); +} + +#[test] +fn advance_referendum_is_a_noop_for_every_terminal_status() { + TestState::default().build_and_execute(|| { + // Killed. + let i = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + assert_ok!(Referenda::kill(RuntimeOrigin::root(), i)); + let snapshot = status_of(i); + assert_ok!(Referenda::advance_referendum(RuntimeOrigin::root(), i)); + assert_eq!(status_of(i), snapshot); + + // Rejected. + let i = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + vote(VOTER_A, i, false); + vote(VOTER_B, i, false); + run_to_block(current_block() + 2); + let snapshot = status_of(i); + assert_ok!(Referenda::advance_referendum(RuntimeOrigin::root(), i)); + assert_eq!(status_of(i), snapshot); + + // Enacted. + let i = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + run_to_block(current_block() + INITIAL_DELAY + 5); + let snapshot = status_of(i); + assert_ok!(Referenda::advance_referendum(RuntimeOrigin::root(), i)); + assert_eq!(status_of(i), snapshot); + + // Delegated. + let i = submit_on(TRACK_DELEGATING, U256::from(PROPOSER)); + vote(VOTER_A, i, true); + vote(VOTER_B, i, true); + run_to_block(current_block() + 2); + let snapshot = status_of(i); + assert_ok!(Referenda::advance_referendum(RuntimeOrigin::root(), i)); + assert_eq!(status_of(i), snapshot); + + // Expired. + let i = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + run_to_block(current_block() + DECISION_PERIOD + 1); + assert!(matches!(status_of(i), ReferendumStatus::Expired(_))); + let snapshot = status_of(i); + assert_ok!(Referenda::advance_referendum(RuntimeOrigin::root(), i)); + assert_eq!(status_of(i), snapshot); + + // Cancelled. + let i = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + vote(VOTER_A, i, false); + vote(VOTER_B, i, false); + run_to_block(current_block() + 2); + assert!(matches!(status_of(i), ReferendumStatus::Cancelled(_))); + let snapshot = status_of(i); + assert_ok!(Referenda::advance_referendum(RuntimeOrigin::root(), i)); + assert_eq!(status_of(i), snapshot); + + // Approved (transient one-block window before the wrapper dispatches). + let i = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + vote(VOTER_A, i, true); + vote(VOTER_B, i, true); + run_to_block(current_block() + 1); + assert!(matches!(status_of(i), ReferendumStatus::Approved(_))); + let snapshot = status_of(i); + assert_ok!(Referenda::advance_referendum(RuntimeOrigin::root(), i)); + assert_eq!(status_of(i), snapshot); + + // FastTracked (transient one-block window before the wrapper dispatches). + let i = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + vote(VOTER_A, i, true); + vote(VOTER_B, i, true); + vote(VOTER_C, i, true); + run_to_block(current_block() + 1); + assert!(matches!(status_of(i), ReferendumStatus::FastTracked(_))); + let snapshot = status_of(i); + assert_ok!(Referenda::advance_referendum(RuntimeOrigin::root(), i)); + assert_eq!(status_of(i), snapshot); + }); +} + +#[test] +fn enact_rejects_non_root_origin() { + TestState::default().build_and_execute(|| { + assert_noop!( + Referenda::enact( + RuntimeOrigin::signed(U256::from(PROPOSER)), + 0, + Box::new(make_call()) + ), + DispatchError::BadOrigin + ); + }); +} + +#[test] +fn enact_noops_on_terminal_status_so_stale_task_cannot_dispatch() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + + assert_ok!(Referenda::kill(RuntimeOrigin::root(), index)); + assert!(matches!(status_of(index), ReferendumStatus::Killed(_))); + + assert_ok!(Referenda::enact( + RuntimeOrigin::root(), + index, + Box::new(make_call()) + )); + assert!(matches!(status_of(index), ReferendumStatus::Killed(_))); + }); +} + +#[test] +fn enact_noops_on_unknown_index() { + TestState::default().build_and_execute(|| { + assert_ok!(Referenda::enact( + RuntimeOrigin::root(), + 999, + Box::new(make_call()) + )); + }); +} + +#[test] +fn enact_event_carries_inner_dispatch_result() { + TestState::default().build_and_execute(|| { + let ok_index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + assert_ok!(Referenda::enact( + RuntimeOrigin::root(), + ok_index, + Box::new(make_call()) + )); + assert!(has_event(|e| matches!( + e, + Event::Enacted { index: i, error: None, .. } if *i == ok_index + ))); + + // pallet_balances::transfer_keep_alive requires a signed origin; + // dispatching it with Root yields BadOrigin. + let bad_index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + let bad_call = RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive { + dest: U256::from(VOTER_A), + value: 1, + }); + assert_ok!(Referenda::enact( + RuntimeOrigin::root(), + bad_index, + Box::new(bad_call) + )); + assert!(has_event(|e| matches!( + e, + Event::Enacted { index: i, error: Some(_), .. } if *i == bad_index + ))); + }); +} + +#[test] +fn pass_or_fail_below_threshold_stays_ongoing() { + TestState::default().build_and_execute(|| { + let aye_only = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + vote(VOTER_A, aye_only, true); + run_to_block(current_block() + 2); + assert!(Referenda::is_ongoing(aye_only)); + + let nay_only = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + vote(VOTER_A, nay_only, false); + run_to_block(current_block() + 2); + assert!(Referenda::is_ongoing(nay_only)); + }); +} + +#[test] +fn pass_or_fail_approves_at_threshold_and_reaches_enacted() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + + vote(VOTER_A, index, true); + vote(VOTER_B, index, true); + run_to_block(current_block() + 1); + + assert!(matches!(status_of(index), ReferendumStatus::Approved(_))); + assert!(has_event( + |e| matches!(e, Event::Approved { index: i } if *i == index) + )); + + run_to_block(current_block() + 1); + assert!(matches!(status_of(index), ReferendumStatus::Enacted(_))); + assert!(has_event(|e| matches!( + e, + Event::Enacted { index: i, error: None, .. } if *i == index + ))); + }); +} + +#[test] +fn pass_or_fail_rejects_at_threshold_with_full_cleanup() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + + vote(VOTER_A, index, false); + vote(VOTER_B, index, false); + run_to_block(current_block() + 2); + + assert!(matches!(status_of(index), ReferendumStatus::Rejected(_))); + assert_concluded(index, 0); + assert!(has_event( + |e| matches!(e, Event::Rejected { index: i } if *i == index) + )); + }); +} + +#[test] +fn pass_or_fail_expires_at_deadline_with_full_cleanup() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + let submitted = current_block(); + + run_to_block(submitted + DECISION_PERIOD - 1); + assert!(Referenda::is_ongoing(index)); + + run_to_block(submitted + DECISION_PERIOD); + assert!(matches!(status_of(index), ReferendumStatus::Expired(_))); + assert_concluded(index, 0); + assert!(has_event( + |e| matches!(e, Event::Expired { index: i } if *i == index) + )); + }); +} + +#[test] +fn pass_or_fail_non_decisive_vote_does_not_prematurely_expire() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + let submitted = current_block(); + + vote(VOTER_A, index, true); + run_to_block(current_block() + 5); + + assert!(Referenda::is_ongoing(index)); + assert_eq!( + scheduler_alarm_block(index), + Some(submitted + DECISION_PERIOD), + "deadline alarm should be restored" + ); + + // Without further votes, the deadline alarm still fires the expiry. + run_to_block(submitted + DECISION_PERIOD + 1); + assert!(matches!(status_of(index), ReferendumStatus::Expired(_))); + }); +} + +#[test] +fn pass_or_fail_decisive_vote_at_last_block_of_deadline_approves() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + let submitted = current_block(); + + run_to_block(submitted + DECISION_PERIOD - 1); + vote(VOTER_A, index, true); + vote(VOTER_B, index, true); + run_to_block(current_block() + 1); + + assert!(matches!(status_of(index), ReferendumStatus::Approved(_))); + }); +} + +#[test] +fn pass_or_fail_vote_change_can_flip_outcome_before_alarm_fires() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + + vote(VOTER_A, index, true); + vote(VOTER_B, index, true); + // Voter B changes mind before the alarm fires; tally drops below + // approval threshold. + vote(VOTER_B, index, false); + + run_to_block(current_block() + 2); + assert!(Referenda::is_ongoing(index)); + }); +} + +#[test] +fn do_approve_fails_closed_when_review_target_is_unusable() { + TestState::default().build_and_execute(|| { + let parent = submit_on(TRACK_DELEGATING, U256::from(PROPOSER)); + let submitted = current_block(); + + let _guard = HideReviewTrackGuard::new(true); + + vote(VOTER_A, parent, true); + vote(VOTER_B, parent, true); + run_to_block(current_block() + 2); + + assert!(matches!(status_of(parent), ReferendumStatus::Ongoing(_))); + assert!(ReferendumStatusFor::::get(parent + 1).is_none()); + + let events = referenda_events(); + assert!(!events.iter().any(|e| matches!(e, Event::Approved { .. }))); + assert!(!events.iter().any(|e| matches!(e, Event::Delegated { .. }))); + assert!(!events.iter().any(|e| matches!(e, Event::Enacted { .. }))); + assert!(events.iter().any(|e| matches!( + e, + Event::ReviewSchedulingFailed { index, track } + if *index == parent && *track == TRACK_ADJUSTABLE + ))); + + let deadline = submitted + DECISION_PERIOD; + assert_eq!(scheduler_alarm_block(parent), Some(deadline)); + }); +} + +#[test] +fn do_approve_review_failure_expires_at_deadline() { + TestState::default().build_and_execute(|| { + let parent = submit_on(TRACK_DELEGATING, U256::from(PROPOSER)); + + let _guard = HideReviewTrackGuard::new(true); + + vote(VOTER_A, parent, true); + vote(VOTER_B, parent, true); + run_to_block(current_block() + 2); + assert!(matches!(status_of(parent), ReferendumStatus::Ongoing(_))); + + run_to_block(current_block() + DECISION_PERIOD + 1); + + assert!(matches!(status_of(parent), ReferendumStatus::Expired(_))); + assert_concluded(parent, 0); + }); +} + +#[test] +fn do_approve_fails_closed_when_review_voter_set_is_empty() { + TestState::default().build_and_execute(|| { + let parent = submit_on(TRACK_DELEGATING, U256::from(PROPOSER)); + + let _guard = EmptyReviewVoterSetGuard::new(true); + + vote(VOTER_A, parent, true); + vote(VOTER_B, parent, true); + run_to_block(current_block() + 2); + + assert!(matches!(status_of(parent), ReferendumStatus::Ongoing(_))); + assert!(ReferendumStatusFor::::get(parent + 1).is_none()); + + let events = referenda_events(); + assert!(events.iter().any(|e| matches!( + e, + Event::ReviewSchedulingFailed { index, track } + if *index == parent && *track == TRACK_ADJUSTABLE + ))); + }); +} + +#[test] +fn do_approve_review_recovers_when_track_is_restored() { + TestState::default().build_and_execute(|| { + let parent = submit_on(TRACK_DELEGATING, U256::from(PROPOSER)); + + { + let _guard = HideReviewTrackGuard::new(true); + vote(VOTER_A, parent, true); + vote(VOTER_B, parent, true); + run_to_block(current_block() + 2); + assert!(matches!(status_of(parent), ReferendumStatus::Ongoing(_))); + } + + assert_ok!(Referenda::advance_referendum(RuntimeOrigin::root(), parent)); + + let child = parent + 1; + assert!(matches!(status_of(parent), ReferendumStatus::Delegated(_))); + assert!(matches!(status_of(child), ReferendumStatus::Ongoing(_))); + }); +} + +#[test] +fn do_approve_fails_closed_when_schedule_enactment_fails() { + use frame_support::traits::{ + StorePreimage, + schedule::{DispatchTime, v3::Named}, + }; + + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + let submitted = current_block(); + + let dummy = ::bound::(make_call()).unwrap(); + >::schedule_named( + task_name(index), + DispatchTime::At(submitted + 1000), + None, + 0, + frame_system::RawOrigin::Root.into(), + dummy, + ) + .unwrap(); + + vote(VOTER_A, index, true); + vote(VOTER_B, index, true); + run_to_block(current_block() + 1); + + assert!(matches!(status_of(index), ReferendumStatus::Ongoing(_))); + let events = referenda_events(); + assert!(!events.iter().any(|e| matches!(e, Event::Approved { .. }))); + assert!(!events.iter().any(|e| matches!(e, Event::Enacted { .. }))); + assert!( + events + .iter() + .any(|e| matches!(e, Event::SchedulerOperationFailed { index: i } if *i == index)) + ); + assert_eq!( + scheduler_alarm_block(index), + Some(submitted + DECISION_PERIOD) + ); + }); +} + +#[test] +fn adjustable_without_votes_keeps_initial_delay() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + let submitted = current_block(); + assert_eq!( + Pallet::::next_task_dispatch_time(index), + Some(submitted + INITIAL_DELAY) + ); + }); +} + +#[test] +fn adjustable_lapses_to_enacted_when_no_decisive_votes() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + let submitted = current_block(); + + run_to_block(submitted + INITIAL_DELAY + 5); + + assert!(matches!(status_of(index), ReferendumStatus::Enacted(_))); + assert_concluded(index, 0); + + let events = referenda_events(); + assert!( + events + .iter() + .any(|e| matches!(e, Event::Enacted { index: i, .. } if *i == index)) + ); + assert!( + !events + .iter() + .any(|e| matches!(e, Event::Approved { .. } | Event::FastTracked { .. })), + "lapse should not emit Approved or FastTracked" + ); + }); +} + +#[test] +fn adjustable_progresses_through_approval_curve_into_fast_track() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + let start = current_block(); + let initial_target = start + INITIAL_DELAY; + + vote(VOTER_A, index, true); + run_to_block(start + 1); + let after_one = Pallet::::next_task_dispatch_time(index).unwrap(); + assert!(after_one < initial_target); + + vote(VOTER_B, index, true); + run_to_block(start + 2); + let after_two = Pallet::::next_task_dispatch_time(index).unwrap(); + assert!( + after_two < after_one, + "each successive aye should pull the target strictly earlier" + ); + + vote(VOTER_C, index, true); + run_to_block(start + 5); + assert!(matches!(status_of(index), ReferendumStatus::Enacted(_))); + assert!(has_event( + |e| matches!(e, Event::FastTracked { index: i } if *i == index) + )); + }); +} + +#[test] +fn adjustable_progresses_through_rejection_curve_into_cancel() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + let start = current_block(); + let initial_target = start + INITIAL_DELAY; + + vote(VOTER_A, index, false); + run_to_block(start + 1); + let after_one = Pallet::::next_task_dispatch_time(index).unwrap(); + assert!(after_one > initial_target); + + vote(VOTER_B, index, false); + run_to_block(start + 2); + assert!(matches!(status_of(index), ReferendumStatus::Cancelled(_))); + assert!(has_event( + |e| matches!(e, Event::Cancelled { index: i } if *i == index) + )); + }); +} + +#[test] +fn adjustable_balanced_votes_keep_initial_delay() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + let start = current_block(); + + vote(VOTER_A, index, true); + vote(VOTER_B, index, false); + run_to_block(start + 1); + + assert_eq!( + Pallet::::next_task_dispatch_time(index), + Some(start + INITIAL_DELAY), + "net-zero votes should leave the target at initial_delay" + ); + }); +} + +#[test] +fn adjustable_repeated_flips_return_target_to_same_value() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + let start = current_block(); + let initial_target = start + INITIAL_DELAY; + + vote(VOTER_A, index, false); + run_to_block(start + 1); + let nay_1 = Pallet::::next_task_dispatch_time(index).unwrap(); + assert!(nay_1 > initial_target); + + vote(VOTER_A, index, true); + run_to_block(start + 2); + let aye_1 = Pallet::::next_task_dispatch_time(index).unwrap(); + assert!(aye_1 < initial_target); + + vote(VOTER_A, index, false); + run_to_block(start + 3); + let nay_2 = Pallet::::next_task_dispatch_time(index).unwrap(); + assert_eq!( + nay_1, nay_2, + "flipping back to the same tally should land at the same target" + ); + + vote(VOTER_A, index, true); + run_to_block(start + 4); + let aye_2 = Pallet::::next_task_dispatch_time(index).unwrap(); + assert_eq!(aye_1, aye_2); + }); +} + +#[test] +fn adjustable_target_is_stable_across_elapsed_blocks() { + // The interpolation is anchored at `submitted`, so sitting through + // blocks without new votes does not drift the target forward. + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + + vote(VOTER_A, index, true); + run_to_block(current_block() + 2); + let target_after_vote = Pallet::::next_task_dispatch_time(index).unwrap(); + + run_to_block(current_block() + 10); + let target_later = Pallet::::next_task_dispatch_time(index).unwrap(); + assert_eq!(target_after_vote, target_later); + }); +} + +#[test] +fn adjustable_late_vote_when_target_is_in_the_past_fast_tracks() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + let submitted = current_block(); + + // Run forward past where the partial-approval target would land. + run_to_block(submitted + INITIAL_DELAY / 2 + 10); + + vote(VOTER_A, index, true); + run_to_block(current_block() + 5); + + assert!(matches!(status_of(index), ReferendumStatus::Enacted(_))); + assert!(has_event( + |e| matches!(e, Event::FastTracked { index: i } if *i == index) + )); + }); +} + +#[test] +fn adjustable_delayed_then_accelerated_fast_tracks_via_past_target() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + let start = current_block(); + let initial_target = start + INITIAL_DELAY; + + // Push the enactment task past `initial_target` with a nay. + vote(VOTER_A, index, false); + run_to_block(start + 1); + let extended = Pallet::::next_task_dispatch_time(index).unwrap(); + assert!(extended > initial_target); + + // Cross the original deadline without firing (target is now extended). + run_to_block(initial_target + 10); + + // Counter-vote pulls the recomputed target back to `initial_target`, + // which is already in the past; `do_adjust_delay` flips to fast-track. + vote(VOTER_B, index, true); + run_to_block(initial_target + 15); + + assert!(matches!(status_of(index), ReferendumStatus::Enacted(_))); + assert!(has_event( + |e| matches!(e, Event::FastTracked { index: i } if *i == index) + )); + }); +} + +#[test] +fn adjustable_fast_tracks_at_threshold_and_reaches_enacted() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + + vote(VOTER_A, index, true); + vote(VOTER_B, index, true); + vote(VOTER_C, index, true); + run_to_block(current_block() + 5); + + assert!(matches!(status_of(index), ReferendumStatus::Enacted(_))); + let events = referenda_events(); + assert!( + events + .iter() + .any(|e| matches!(e, Event::FastTracked { index: i } if *i == index)) + ); + assert!( + events + .iter() + .any(|e| matches!(e, Event::Enacted { index: i, .. } if *i == index)) + ); + }); +} + +#[test] +fn adjustable_cancels_at_threshold_and_cleans_up_task() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + + vote(VOTER_A, index, false); + vote(VOTER_B, index, false); + run_to_block(current_block() + 2); + + assert!(matches!(status_of(index), ReferendumStatus::Cancelled(_))); + assert_concluded(index, 0); + assert!(Pallet::::next_task_dispatch_time(index).is_none()); + assert!(has_event( + |e| matches!(e, Event::Cancelled { index: i } if *i == index) + )); + }); +} + +#[test] +fn adjustable_non_decisive_vote_still_reaches_enacted_via_enact_wrapper() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + let submitted = current_block(); + + vote(VOTER_A, index, true); + run_to_block(current_block() + 3); + assert!(Referenda::is_ongoing(index)); + + run_to_block(submitted + INITIAL_DELAY + 1); + assert!(matches!(status_of(index), ReferendumStatus::Enacted(_))); + }); +} + +#[test] +fn do_fast_track_fails_closed_when_reschedule_fails() { + use frame_support::traits::schedule::v3::Named; + + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + + // Drop the wrapper task so reschedule_named fails with NotFound. + assert!( + >::cancel_named(task_name(index)) + .is_ok() + ); + + Pallet::::do_fast_track(index); + + assert!(matches!(status_of(index), ReferendumStatus::Ongoing(_))); + let events = referenda_events(); + assert!( + !events + .iter() + .any(|e| matches!(e, Event::FastTracked { .. })) + ); + assert!( + events + .iter() + .any(|e| matches!(e, Event::SchedulerOperationFailed { index: i } if *i == index)) + ); + }); +} + +#[test] +fn delegation_creates_child_review_and_keeps_active_count_net_zero() { + TestState::default().build_and_execute(|| { + let parent = submit_on(TRACK_DELEGATING, U256::from(PROPOSER)); + assert_eq!(ActiveCount::::get(), 1); + + vote(VOTER_A, parent, true); + vote(VOTER_B, parent, true); + run_to_block(current_block() + 2); + + let child = parent + 1; + + assert!(matches!(status_of(parent), ReferendumStatus::Delegated(_))); + match status_of(child) { + ReferendumStatus::Ongoing(info) => { + assert_eq!(info.track, TRACK_ADJUSTABLE); + assert!(matches!(info.proposal, Proposal::Review)); + assert_eq!(info.proposer, U256::from(PROPOSER)); + } + _ => panic!("child should be Ongoing"), + } + + // ActiveCount: parent -1, child +1, net unchanged. + assert_eq!(ActiveCount::::get(), 1); + + let events = referenda_events(); + assert!(events.iter().any(|e| matches!( + e, + Event::Delegated { index, review, track } + if *index == parent && *review == child && *track == TRACK_ADJUSTABLE + ))); + // No Submitted for the child, no Approved for the parent. + assert_eq!( + events + .iter() + .filter(|e| matches!(e, Event::Submitted { .. })) + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|e| matches!(e, Event::Approved { .. })) + .count(), + 0 + ); + }); +} + +#[test] +fn delegated_parent_is_terminal_and_child_progresses_independently() { + TestState::default().build_and_execute(|| { + let parent = submit_on(TRACK_DELEGATING, U256::from(PROPOSER)); + vote(VOTER_A, parent, true); + vote(VOTER_B, parent, true); + run_to_block(current_block() + 2); + let child = parent + 1; + + // Manual advance does not promote Delegated. + let snapshot = status_of(parent); + assert_ok!(Referenda::advance_referendum(RuntimeOrigin::root(), parent)); + assert_eq!(status_of(parent), snapshot); + + // Child reaches Enacted via natural execution. Parent unchanged. + run_to_block(current_block() + INITIAL_DELAY + 5); + assert!(matches!(status_of(child), ReferendumStatus::Enacted(_))); + assert!(matches!(status_of(parent), ReferendumStatus::Delegated(_))); + }); +} + +#[test] +fn killing_child_does_not_change_parent_delegated_status() { + TestState::default().build_and_execute(|| { + let parent = submit_on(TRACK_DELEGATING, U256::from(PROPOSER)); + vote(VOTER_A, parent, true); + vote(VOTER_B, parent, true); + run_to_block(current_block() + 2); + let child = parent + 1; + + assert_ok!(Referenda::kill(RuntimeOrigin::root(), child)); + assert!(matches!(status_of(parent), ReferendumStatus::Delegated(_))); + assert!(matches!(status_of(child), ReferendumStatus::Killed(_))); + }); +} + +#[test] +fn schedule_for_review_returns_none_for_invalid_targets() { + TestState::default().build_and_execute(|| { + assert!( + Pallet::::schedule_for_review(Box::new(make_call()), U256::from(PROPOSER), 99u8,) + .is_none() + ); + + assert!( + Pallet::::schedule_for_review( + Box::new(make_call()), + U256::from(PROPOSER), + TRACK_PASS_OR_FAIL, + ) + .is_none() + ); + + let _guard = EmptyReviewVoterSetGuard::new(true); + assert!( + Pallet::::schedule_for_review( + Box::new(make_call()), + U256::from(PROPOSER), + TRACK_ADJUSTABLE, + ) + .is_none() + ); + }); +} + +#[test] +fn schedule_for_review_increments_per_proposer_even_above_cap() { + let cap = ::MaxActivePerProposer::get(); + TestState::default().build_and_execute(|| { + for _ in 0..cap { + submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + } + assert_eq!(ActivePerProposer::::get(U256::from(PROPOSER)), cap); + + let child = Pallet::::schedule_for_review( + Box::new(make_call()), + U256::from(PROPOSER), + TRACK_ADJUSTABLE, + ) + .expect("schedule_for_review must succeed"); + assert!(matches!(status_of(child), ReferendumStatus::Ongoing(_))); + assert_eq!( + ActivePerProposer::::get(U256::from(PROPOSER)), + cap + 1 + ); + }); +} + +#[test] +fn polls_returns_some_for_ongoing_and_none_for_every_terminal_status() { + TestState::default().build_and_execute(|| { + // Ongoing: the trait returns Some. + let ongoing = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + assert!(Referenda::is_ongoing(ongoing)); + assert_eq!( + Referenda::voting_scheme_of(ongoing), + Some(VotingScheme::Signed) + ); + assert!(Referenda::voter_set_of(ongoing).is_some()); + + // Helper closures that drive a fresh referendum to each terminal state. + let killed = drive_to_status( + || submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)), + |i| { + assert_ok!(Referenda::kill(RuntimeOrigin::root(), i)); + }, + ); + + let approved_or_enacted = drive_to_status( + || submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)), + |i| { + vote(VOTER_A, i, true); + vote(VOTER_B, i, true); + drive_to_terminal(i, 50); + }, + ); + + let rejected = drive_to_status( + || submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)), + |i| { + vote(VOTER_A, i, false); + vote(VOTER_B, i, false); + drive_to_terminal(i, 50); + }, + ); + + let expired = drive_to_status( + || submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)), + |i| { + run_to_block(current_block() + DECISION_PERIOD + 1); + let _ = i; + }, + ); + + let cancelled = drive_to_status( + || submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)), + |i| { + vote(VOTER_A, i, false); + vote(VOTER_B, i, false); + drive_to_terminal(i, 50); + }, + ); + + let lapsed = drive_to_status( + || submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)), + |i| { + run_to_block(current_block() + INITIAL_DELAY + 5); + let _ = i; + }, + ); + + let delegated = drive_to_status( + || submit_on(TRACK_DELEGATING, U256::from(PROPOSER)), + |i| { + vote(VOTER_A, i, true); + vote(VOTER_B, i, true); + run_to_block(current_block() + 2); + }, + ); + + for terminal in [ + killed, + approved_or_enacted, + rejected, + expired, + cancelled, + lapsed, + delegated, + ] { + assert!(!Referenda::is_ongoing(terminal)); + assert!(Referenda::voting_scheme_of(terminal).is_none()); + assert!(Referenda::voter_set_of(terminal).is_none()); + } + }); +} + +#[test] +fn polls_returns_none_for_unknown_index() { + TestState::default().build_and_execute(|| { + assert!(!Referenda::is_ongoing(999)); + assert!(Referenda::voting_scheme_of(999).is_none()); + assert!(Referenda::voter_set_of(999).is_none()); + }); +} + +#[test] +fn rejected_drops_submit_time_preimage() { + TestState::default().build_and_execute(|| { + let call = make_lookup_call(); + let hash = preimage_hash(&call); + + assert_ok!(Referenda::submit( + RuntimeOrigin::signed(U256::from(PROPOSER)), + TRACK_PASS_OR_FAIL, + Box::new(call), + )); + let index = ReferendumCount::::get() - 1; + assert!(preimage_exists(&hash)); + + vote(VOTER_A, index, false); + vote(VOTER_B, index, false); + run_to_block(current_block() + 2); + + assert!(matches!(status_of(index), ReferendumStatus::Rejected(_))); + assert!(!preimage_exists(&hash)); + }); +} + +#[test] +fn expired_drops_submit_time_preimage() { + TestState::default().build_and_execute(|| { + let call = make_lookup_call(); + let hash = preimage_hash(&call); + + assert_ok!(Referenda::submit( + RuntimeOrigin::signed(U256::from(PROPOSER)), + TRACK_PASS_OR_FAIL, + Box::new(call), + )); + let index = ReferendumCount::::get() - 1; + let submitted = current_block(); + assert!(preimage_exists(&hash)); + + run_to_block(submitted + DECISION_PERIOD); + assert!(matches!(status_of(index), ReferendumStatus::Expired(_))); + assert!(!preimage_exists(&hash)); + }); +} + +#[test] +fn killed_drops_submit_time_preimage_when_action_was_pending() { + TestState::default().build_and_execute(|| { + let call = make_lookup_call(); + let hash = preimage_hash(&call); + + assert_ok!(Referenda::submit( + RuntimeOrigin::signed(U256::from(PROPOSER)), + TRACK_PASS_OR_FAIL, + Box::new(call), + )); + let index = ReferendumCount::::get() - 1; + assert!(preimage_exists(&hash)); + + assert_ok!(Referenda::kill(RuntimeOrigin::root(), index)); + assert!(matches!(status_of(index), ReferendumStatus::Killed(_))); + assert!(!preimage_exists(&hash)); + }); +} + +#[test] +fn approve_then_enact_drops_both_submit_and_wrapper_preimages() { + TestState::default().build_and_execute(|| { + let call = make_lookup_call(); + let submit_hash = preimage_hash(&call); + + assert_ok!(Referenda::submit( + RuntimeOrigin::signed(U256::from(PROPOSER)), + TRACK_PASS_OR_FAIL, + Box::new(call.clone()), + )); + let index = ReferendumCount::::get() - 1; + let wrapper_hash = enact_wrapper_hash(index, call); + assert!(preimage_exists(&submit_hash)); + assert!(!preimage_exists(&wrapper_hash)); + + vote(VOTER_A, index, true); + vote(VOTER_B, index, true); + run_to_block(current_block() + 1); + assert!(matches!(status_of(index), ReferendumStatus::Approved(_))); + assert!(!preimage_exists(&submit_hash)); + assert!(preimage_exists(&wrapper_hash)); + + run_to_block(current_block() + 1); + assert!(matches!(status_of(index), ReferendumStatus::Enacted(_))); + assert!(!preimage_exists(&wrapper_hash)); + }); +} + +#[test] +fn adjustable_cancel_drops_wrapper_preimage() { + TestState::default().build_and_execute(|| { + let call = make_lookup_call(); + let submit_hash = preimage_hash(&call); + + assert_ok!(Referenda::submit( + RuntimeOrigin::signed(U256::from(PROPOSER)), + TRACK_ADJUSTABLE, + Box::new(call.clone()), + )); + let index = ReferendumCount::::get() - 1; + let wrapper_hash = enact_wrapper_hash(index, call); + assert!(!preimage_exists(&submit_hash)); + assert!(preimage_exists(&wrapper_hash)); + + vote(VOTER_A, index, false); + vote(VOTER_B, index, false); + vote(VOTER_C, index, false); + run_to_block(current_block() + 1); + assert!(matches!(status_of(index), ReferendumStatus::Cancelled(_))); + assert!(!preimage_exists(&wrapper_hash)); + }); +} + +#[test] +fn approve_then_enact_only_decrements_active_count_once() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + assert_eq!(ActiveCount::::get(), 1); + assert_eq!(ActivePerProposer::::get(U256::from(PROPOSER)), 1); + + vote(VOTER_A, index, true); + vote(VOTER_B, index, true); + run_to_block(current_block() + 1); + assert!(matches!(status_of(index), ReferendumStatus::Approved(_))); + assert_eq!(ActiveCount::::get(), 0); + assert_eq!(ActivePerProposer::::get(U256::from(PROPOSER)), 0); + + run_to_block(current_block() + 1); + assert!(matches!(status_of(index), ReferendumStatus::Enacted(_))); + assert_eq!(ActiveCount::::get(), 0); + assert_eq!(ActivePerProposer::::get(U256::from(PROPOSER)), 0); + }); +} + +#[test] +fn fast_track_then_enact_only_decrements_active_count_once() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + assert_eq!(ActiveCount::::get(), 1); + assert_eq!(ActivePerProposer::::get(U256::from(PROPOSER)), 1); + + vote(VOTER_A, index, true); + vote(VOTER_B, index, true); + vote(VOTER_C, index, true); + run_to_block(current_block() + 1); + assert!(matches!(status_of(index), ReferendumStatus::FastTracked(_))); + assert_eq!(ActiveCount::::get(), 0); + assert_eq!(ActivePerProposer::::get(U256::from(PROPOSER)), 0); + + run_to_block(current_block() + 1); + assert!(matches!(status_of(index), ReferendumStatus::Enacted(_))); + assert_eq!(ActiveCount::::get(), 0); + assert_eq!(ActivePerProposer::::get(U256::from(PROPOSER)), 0); + }); +} + +#[test] +fn delegated_handoff_keeps_proposer_active_count_at_one() { + TestState::default().build_and_execute(|| { + let parent = submit_on(TRACK_DELEGATING, U256::from(PROPOSER)); + assert_eq!(ActivePerProposer::::get(U256::from(PROPOSER)), 1); + + vote(VOTER_A, parent, true); + vote(VOTER_B, parent, true); + run_to_block(current_block() + 2); + + assert!(matches!(status_of(parent), ReferendumStatus::Delegated(_))); + assert_eq!(ActivePerProposer::::get(U256::from(PROPOSER)), 1); + }); +} + +#[test] +fn submit_snapshots_decision_strategy_into_referendum_info() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + match status_of(index) { + ReferendumStatus::Ongoing(info) => { + assert!(matches!( + info.decision_strategy, + DecisionStrategy::PassOrFail { .. } + )); + } + _ => panic!("expected Ongoing"), + } + }); +} + +#[test] +fn live_referendum_uses_snapshot_when_track_strategy_changes_at_runtime() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + + let _guard = SwapTrack0ToAdjustableGuard::new(true); + + vote(VOTER_A, index, true); + vote(VOTER_B, index, true); + run_to_block(current_block() + 1); + + assert!(matches!(status_of(index), ReferendumStatus::Approved(_))); + }); +} + +#[test] +fn alarm_driven_completion_does_not_emit_scheduler_operation_failed() { + TestState::default().build_and_execute(|| { + let approved = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + vote(VOTER_A, approved, true); + vote(VOTER_B, approved, true); + run_to_block(current_block() + 1); + assert!(matches!(status_of(approved), ReferendumStatus::Approved(_))); + run_to_block(current_block() + 1); + assert!(matches!(status_of(approved), ReferendumStatus::Enacted(_))); + + let rejected = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + vote(VOTER_A, rejected, false); + vote(VOTER_B, rejected, false); + run_to_block(current_block() + 2); + assert!(matches!(status_of(rejected), ReferendumStatus::Rejected(_))); + + let expired = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + let submitted = current_block(); + run_to_block(submitted + DECISION_PERIOD); + assert!(matches!(status_of(expired), ReferendumStatus::Expired(_))); + + assert!( + !System::events().iter().any(|record| matches!( + record.event, + RuntimeEvent::Referenda(Event::SchedulerOperationFailed { .. }) + )), + "no SchedulerOperationFailed should fire on routine alarm-driven completions", + ); + }); +} + +#[test] +fn set_alarm_replaces_existing_or_arms_fresh() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + let submitted = current_block(); + assert_eq!( + scheduler_alarm_block(index), + Some(submitted + DECISION_PERIOD) + ); + + let events_before_noop = System::events().len(); + assert_ok!(Pallet::::set_alarm( + index, + submitted + DECISION_PERIOD + )); + assert_eq!(System::events().len(), events_before_noop); + assert_eq!( + scheduler_alarm_block(index), + Some(submitted + DECISION_PERIOD) + ); + + // Replace. + assert_ok!(Pallet::::set_alarm(index, current_block() + 5)); + assert_eq!(scheduler_alarm_block(index), Some(current_block() + 5)); + + // Cancel manually, then arm again. + use frame_support::traits::schedule::v3::Named; + let _ = + >::cancel_named(alarm_name(index)); + assert!(scheduler_alarm_block(index).is_none()); + + assert_ok!(Pallet::::set_alarm(index, current_block() + 10)); + assert_eq!(scheduler_alarm_block(index), Some(current_block() + 10)); + }); +} + +#[test] +fn parallel_referenda_have_independent_lifecycles() { + TestState::default().build_and_execute(|| { + let pf = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + let adj = submit_on(TRACK_ADJUSTABLE, U256::from(PROPOSER)); + let submitted = current_block(); + assert_eq!(ActiveCount::::get(), 2); + + // Approve pf; adj must keep its scheduling untouched. + vote(VOTER_A, pf, true); + vote(VOTER_B, pf, true); + run_to_block(current_block() + 5); + + assert!(matches!(status_of(pf), ReferendumStatus::Enacted(_))); + assert!(Referenda::is_ongoing(adj)); + assert_eq!( + Pallet::::next_task_dispatch_time(adj), + Some(submitted + INITIAL_DELAY) + ); + }); +} + +#[test] +fn vote_after_termination_does_not_mutate_referenda_state() { + TestState::default().build_and_execute(|| { + let index = submit_on(TRACK_PASS_OR_FAIL, U256::from(PROPOSER)); + assert_ok!(Referenda::kill(RuntimeOrigin::root(), index)); + + let active_before = ActiveCount::::get(); + let status_before = status_of(index); + let _ = SignedVoting::vote(RuntimeOrigin::signed(U256::from(VOTER_A)), index, true); + + assert_eq!(ActiveCount::::get(), active_before); + assert_eq!(status_of(index), status_before); + assert!(scheduler_alarm_block(index).is_none()); + }); +} + +#[test] +fn integrity_test_passes_for_valid_track_table() { + TestState::default().build_and_execute(|| { + use frame_support::traits::Hooks; + Pallet::::integrity_test(); + }); +} + +#[test] +fn check_integrity_rejects_duplicate_track_ids() { + assert_check_integrity_err( + vec![passorfail_track(0), passorfail_track(0)], + "track ids must be unique", + ); +} + +#[test] +fn check_integrity_rejects_review_referencing_unknown_track() { + let mut t = passorfail_track(0); + if let DecisionStrategy::PassOrFail { + ref mut on_approval, + .. + } = t.info.decision_strategy + { + *on_approval = ApprovalAction::Review { track: 99 }; + } + assert_check_integrity_err(vec![t], "ApprovalAction::Review references unknown track"); +} + +#[test] +fn check_integrity_rejects_review_referencing_passorfail_track() { + let mut t = passorfail_track(0); + if let DecisionStrategy::PassOrFail { + ref mut on_approval, + .. + } = t.info.decision_strategy + { + *on_approval = ApprovalAction::Review { track: 1 }; + } + let target = passorfail_track(1); + assert_check_integrity_err( + vec![t, target], + "ApprovalAction::Review target track must be Adjustable", + ); +} + +#[test] +fn check_integrity_rejects_zero_decision_period() { + let mut t = passorfail_track(0); + if let DecisionStrategy::PassOrFail { + ref mut decision_period, + .. + } = t.info.decision_strategy + { + *decision_period = 0; + } + assert_check_integrity_err(vec![t], "PassOrFail: decision_period must be non-zero"); +} + +#[test] +fn check_integrity_rejects_zero_approve_threshold() { + let mut t = passorfail_track(0); + if let DecisionStrategy::PassOrFail { + ref mut approve_threshold, + .. + } = t.info.decision_strategy + { + *approve_threshold = Perbill::zero(); + } + assert_check_integrity_err(vec![t], "PassOrFail: approve_threshold must be non-zero"); +} + +#[test] +fn check_integrity_rejects_zero_reject_threshold() { + let mut t = passorfail_track(0); + if let DecisionStrategy::PassOrFail { + ref mut reject_threshold, + .. + } = t.info.decision_strategy + { + *reject_threshold = Perbill::zero(); + } + assert_check_integrity_err(vec![t], "PassOrFail: reject_threshold must be non-zero"); +} + +#[test] +fn check_integrity_rejects_passorfail_thresholds_summing_to_at_most_100_percent() { + let mut t = passorfail_track(0); + if let DecisionStrategy::PassOrFail { + ref mut approve_threshold, + ref mut reject_threshold, + .. + } = t.info.decision_strategy + { + *approve_threshold = Perbill::from_percent(50); + *reject_threshold = Perbill::from_percent(50); + } + assert_check_integrity_err( + vec![t], + "PassOrFail: approve_threshold + reject_threshold must exceed 100%", + ); +} + +#[test] +fn check_integrity_rejects_zero_initial_delay() { + let mut t = adjustable_track(0); + if let DecisionStrategy::Adjustable { + ref mut initial_delay, + .. + } = t.info.decision_strategy + { + *initial_delay = 0; + } + assert_check_integrity_err(vec![t], "Adjustable: initial_delay must be non-zero"); +} + +#[test] +fn check_integrity_rejects_zero_fast_track_threshold() { + let mut t = adjustable_track(0); + if let DecisionStrategy::Adjustable { + ref mut fast_track_threshold, + .. + } = t.info.decision_strategy + { + *fast_track_threshold = Perbill::zero(); + } + assert_check_integrity_err(vec![t], "Adjustable: fast_track_threshold must be non-zero"); +} + +#[test] +fn check_integrity_rejects_zero_cancel_threshold() { + let mut t = adjustable_track(0); + if let DecisionStrategy::Adjustable { + ref mut cancel_threshold, + .. + } = t.info.decision_strategy + { + *cancel_threshold = Perbill::zero(); + } + assert_check_integrity_err(vec![t], "Adjustable: cancel_threshold must be non-zero"); +} + +#[test] +fn check_integrity_rejects_max_delay_below_initial_delay() { + let mut t = adjustable_track(0); + if let DecisionStrategy::Adjustable { + ref mut max_delay, .. + } = t.info.decision_strategy + { + *max_delay = 50; + } + assert_check_integrity_err(vec![t], "Adjustable: max_delay must be >= initial_delay"); +} + +#[test] +fn check_integrity_rejects_adjustable_thresholds_summing_to_at_most_100_percent() { + let mut t = adjustable_track(0); + if let DecisionStrategy::Adjustable { + ref mut fast_track_threshold, + ref mut cancel_threshold, + .. + } = t.info.decision_strategy + { + *fast_track_threshold = Perbill::from_percent(50); + *cancel_threshold = Perbill::from_percent(50); + } + assert_check_integrity_err( + vec![t], + "Adjustable: fast_track_threshold + cancel_threshold must exceed 100%", + ); +} + +#[test] +fn try_state_passes_with_populated_voter_sets() { + TestState::default().build_and_execute(|| { + assert!(Pallet::::do_try_state().is_ok()); + }); +} + +#[test] +fn try_state_allows_uninitialized_collectives() { + TestState { + proposers: vec![], + triumvirate: vec![], + } + .build_and_execute(|| { + assert!(Pallet::::do_try_state().is_ok()); + }); +} + +#[test] +fn try_state_fails_when_a_track_has_empty_voter_set() { + TestState::default().build_and_execute(|| { + let _guard = EmptyReviewVoterSetGuard::new(true); + assert!(Pallet::::do_try_state().is_err()); + }); +} + +#[test] +fn try_state_rejects_some_empty_proposer_set() { + TestState::default().build_and_execute(|| { + let mut t = passorfail_track(0); + t.info.proposer_set = Some(MemberSet::Union(vec![])); + let _guard = OverrideTracksGuard::new(vec![t]); + assert!(Pallet::::do_try_state().is_err()); + }); +} + +#[test] +fn try_state_accepts_none_proposer_set() { + TestState::default().build_and_execute(|| { + let mut t = passorfail_track(0); + t.info.proposer_set = None; + let _guard = OverrideTracksGuard::new(vec![t]); + assert!(Pallet::::do_try_state().is_ok()); + }); +} diff --git a/pallets/referenda/src/types.rs b/pallets/referenda/src/types.rs new file mode 100644 index 0000000000..35898c1ed1 --- /dev/null +++ b/pallets/referenda/src/types.rs @@ -0,0 +1,422 @@ +//! Type definitions for the referenda pallet. + +use frame_support::{ + pallet_prelude::*, + sp_runtime::{Perbill, traits::Zero}, + traits::{Bounded, LockIdentifier, schedule::v3::TaskName}, +}; +use frame_system::pallet_prelude::*; +use subtensor_macros::freeze_struct; +use subtensor_runtime_common::{SetLike, VoteTally}; + +use crate::Config; + +/// Maximum length of a track's display name. +pub const MAX_TRACK_NAME_LEN: usize = 32; + +/// Fixed-width track name. Padded with zeros if shorter than the maximum. +pub type TrackName = [u8; MAX_TRACK_NAME_LEN]; + +/// Monotonic referendum identifier. Issued by `submit`. +pub type ReferendumIndex = u32; + +/// Hash-keyed name used to identify a scheduler entry. +pub type ProposalTaskName = [u8; 32]; + +/// Lock identifier reserved by this pallet for any locks placed by the +/// voting layer on behalf of a referendum. +pub const REFERENDA_ID: LockIdentifier = *b"referend"; + +/// `PalletsOrigin` re-exported from the runtime for use in scheduler calls. +pub type PalletsOriginOf = + <::RuntimeOrigin as OriginTrait>::PalletsOrigin; + +pub(crate) type AccountIdOf = ::AccountId; + +/// The runtime call type used for proposed calls and the pallet's own +/// scheduled `advance_referendum` invocations. +pub type CallOf = ::RuntimeCall; + +/// Bounded reference to a runtime call. Stored on-chain as the preimage +/// hash plus length; the actual call bytes live in the preimage pallet. +pub type BoundedCallOf = Bounded, ::Hashing>; + +/// The runtime's track table type. +pub type TracksOf = ::Tracks; + +/// Stable identifier used to reference a track from referenda and from +/// `ApprovalAction::Review`. +pub type TrackIdOf = + as TracksInfo, CallOf, BlockNumberFor>>::Id; + +/// The voting scheme tag carried on each track. The voting pallet uses it +/// to dispatch tally updates to the correct backend. +pub type VotingSchemeOf = as TracksInfo< + TrackName, + AccountIdOf, + CallOf, + BlockNumberFor, +>>::VotingScheme; + +/// Set of accounts entitled to vote on referenda on a track. +pub type VoterSetOf = + as TracksInfo, CallOf, BlockNumberFor>>::VoterSet; + +/// [`ReferendumStatus`] specialized to the runtime configuration. +pub type ReferendumStatusOf = + ReferendumStatus, TrackIdOf, BoundedCallOf, BlockNumberFor>; + +/// [`ReferendumInfo`] specialized to the runtime configuration. +pub type ReferendumInfoOf = + ReferendumInfo, TrackIdOf, BoundedCallOf, BlockNumberFor>; + +/// What a referendum proposes. Determined by the track's strategy at +/// submit time. +#[derive( + Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, Clone, PartialEq, Eq, TypeInfo, Debug, +)] +pub enum Proposal { + /// A call to dispatch on approval. Used by `PassOrFail` tracks. + Action(Call), + /// A scheduled call whose timing is governed by votes. Used by + /// `Adjustable` tracks. The actual call lives on the scheduler under + /// the referendum's `task_name`; the proposal carries no payload. + Review, +} + +/// How a track decides outcomes for the referenda filed against it. +#[derive( + Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, Clone, PartialEq, Eq, TypeInfo, Debug, +)] +pub enum DecisionStrategy { + /// Binary decision before a deadline. The referendum is approved if + /// `tally.approval` reaches `approve_threshold`, rejected if + /// `tally.rejection` reaches `reject_threshold`, and expired if neither + /// happens by `submitted + decision_period`. On approval, the action + /// in `on_approval` runs. + PassOrFail { + /// Number of blocks after submission within which a decision must + /// be reached. Past this point the referendum expires. + decision_period: BlockNumber, + /// Approval ratio required to pass. + approve_threshold: Perbill, + /// Rejection ratio required to fail. + reject_threshold: Perbill, + /// Action taken once the referendum is approved. + on_approval: ApprovalAction, + }, + /// Timing decision over a call already scheduled at submit time. The + /// call runs after `initial_delay` by default. Voters can fast-track, + /// cancel, or shift the dispatch time via interpolation on net votes: + /// net approval pulls the target earlier toward `submitted`, net + /// rejection pushes it later toward `submitted + max_delay`. + Adjustable { + /// Default delay between submission and dispatch when net votes + /// are zero. + initial_delay: BlockNumber, + /// Upper bound on the dispatch delay. Reached as net rejection + /// approaches `cancel_threshold`. Must be `>= initial_delay`; + /// equal disables the rejection-side extension. + max_delay: BlockNumber, + /// Approval ratio at which the task is rescheduled to next block + /// and the referendum concludes as `FastTracked`. + fast_track_threshold: Perbill, + /// Rejection ratio at which the scheduled task is cancelled and the + /// referendum concludes as `Cancelled`. + cancel_threshold: Perbill, + }, +} + +/// What happens when a `PassOrFail` referendum is approved. +#[derive( + Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, Clone, PartialEq, Eq, TypeInfo, Debug, +)] +pub enum ApprovalAction { + /// Schedule the call for next-block dispatch on this referendum's index. + Execute, + /// Hand the call off to a fresh `Adjustable` referendum on `track`. + /// The parent concludes as `Delegated` and the new referendum drives + /// the rest of the lifecycle. + Review { + /// Target track for the review referendum. Must be `Adjustable`; + /// validated by [`Pallet::integrity_test`]. + track: TrackId, + }, +} + +/// Per-track configuration carried in the runtime track table. +#[derive(Clone, Debug)] +pub struct TrackInfo { + /// Display name. Padded to fixed width. + pub name: Name, + /// Accounts allowed to submit referenda on this track. `None` means + /// the track is currently closed to new submissions; existing + /// referenda continue their lifecycle normally. + pub proposer_set: Option, + /// Voting scheme tag. Routes tally updates to the correct backend. + pub voting_scheme: VotingScheme, + /// Accounts entitled to vote on referenda on this track. + pub voter_set: VoterSet, + /// How outcomes are decided on this track. + pub decision_strategy: DecisionStrategy, +} + +/// A track entry in the runtime track table: an id paired with its +/// configuration. +#[derive(Clone, Debug)] +pub struct Track { + /// Stable id used to reference this track from referenda and from + /// `ApprovalAction::Review { track }`. + pub id: Id, + /// Track configuration. + pub info: TrackInfo, +} + +/// Runtime configuration of available tracks. Implementors define the +/// available tracks at compile time; the pallet queries this trait at +/// submit time and during state-machine evaluation. +pub trait TracksInfo { + /// Stable identifier for a track. + type Id: Parameter + MaxEncodedLen + Copy + Ord + PartialOrd + Send + Sync + 'static; + /// Accounts allowed to submit referenda. + type ProposerSet: SetLike; + /// Voting scheme tag carried on each track. + type VotingScheme: PartialEq; + /// Accounts entitled to vote. + type VoterSet: SetLike; + + /// Iterate over every track defined in the runtime. + fn tracks() -> impl Iterator< + Item = Track< + Self::Id, + Name, + BlockNumber, + Self::ProposerSet, + Self::VoterSet, + Self::VotingScheme, + >, + >; + + /// Look up the configuration for a single track id. + fn info( + id: Self::Id, + ) -> Option< + TrackInfo< + Self::Id, + Name, + BlockNumber, + Self::ProposerSet, + Self::VoterSet, + Self::VotingScheme, + >, + > { + Self::tracks().find(|t| t.id == id).map(|t| t.info) + } + + /// Optional per-track authorization of a proposed call. Defaults to + /// allow-all. Runtimes can override to filter calls based on track. + fn authorize_proposal( + _track_info: &TrackInfo< + Self::Id, + Name, + BlockNumber, + Self::ProposerSet, + Self::VoterSet, + Self::VotingScheme, + >, + _call: &Call, + ) -> bool { + true + } + + /// Validate the runtime track table once at startup. Returns `Err` + /// with a static message describing the first broken invariant. + /// + /// Structural invariants: + /// + /// 1. Track ids are unique. Lookups by id silently pick the first + /// match, so duplicates would mask later entries. + /// 2. Every `ApprovalAction::Review { track }` references a track + /// that exists and uses the `Adjustable` strategy. Otherwise an + /// approval that delegates would either find no track or hand off + /// to a track that cannot model a review. + /// + /// Per-strategy parameter invariants (the threshold comparisons in + /// `advance_ongoing` are `>=`, so a zero threshold against the + /// default-zero tally auto-concludes on first alarm fire): + /// + /// * `PassOrFail`: `decision_period`, `approve_threshold`, and + /// `reject_threshold` must all be non-zero; + /// `approve_threshold + reject_threshold > 100%` so the reject + /// branch cannot be masked by an approval that fires first on the + /// same tally split. + /// * `Adjustable`: `initial_delay`, `fast_track_threshold`, and + /// `cancel_threshold` must all be non-zero; + /// `max_delay >= initial_delay` (else net rejection cannot extend + /// the delay); and `fast_track_threshold + cancel_threshold > 100%` + /// so the cancel branch cannot be masked by a fast-track that + /// fires first on the same tally split. + fn check_integrity() -> Result<(), &'static str> + where + BlockNumber: Zero + PartialOrd, + { + let tracks: alloc::vec::Vec<_> = Self::tracks().collect(); + + let mut ids: alloc::vec::Vec<_> = tracks.iter().map(|t| t.id).collect(); + let total = ids.len(); + ids.sort_unstable(); + ids.dedup(); + if ids.len() != total { + return Err("track ids must be unique"); + } + + for track in &tracks { + match &track.info.decision_strategy { + DecisionStrategy::PassOrFail { + decision_period, + approve_threshold, + reject_threshold, + on_approval, + } => { + if decision_period.is_zero() { + return Err("PassOrFail: decision_period must be non-zero"); + } + if *approve_threshold == Perbill::zero() { + return Err("PassOrFail: approve_threshold must be non-zero"); + } + if *reject_threshold == Perbill::zero() { + return Err("PassOrFail: reject_threshold must be non-zero"); + } + let sum = approve_threshold + .deconstruct() + .saturating_add(reject_threshold.deconstruct()); + if sum <= Perbill::one().deconstruct() { + return Err( + "PassOrFail: approve_threshold + reject_threshold must exceed 100%", + ); + } + if let ApprovalAction::Review { + track: review_track, + } = on_approval + { + let referenced = Self::info(*review_track) + .ok_or("ApprovalAction::Review references unknown track")?; + if !matches!( + referenced.decision_strategy, + DecisionStrategy::Adjustable { .. } + ) { + return Err("ApprovalAction::Review target track must be Adjustable"); + } + } + } + DecisionStrategy::Adjustable { + initial_delay, + max_delay, + fast_track_threshold, + cancel_threshold, + } => { + if initial_delay.is_zero() { + return Err("Adjustable: initial_delay must be non-zero"); + } + if max_delay < initial_delay { + return Err("Adjustable: max_delay must be >= initial_delay"); + } + if *fast_track_threshold == Perbill::zero() { + return Err("Adjustable: fast_track_threshold must be non-zero"); + } + if *cancel_threshold == Perbill::zero() { + return Err("Adjustable: cancel_threshold must be non-zero"); + } + let sum = fast_track_threshold + .deconstruct() + .saturating_add(cancel_threshold.deconstruct()); + if sum <= Perbill::one().deconstruct() { + return Err( + "Adjustable: fast_track_threshold + cancel_threshold must exceed 100%", + ); + } + } + } + } + + Ok(()) + } +} + +/// Curve applied to net-vote progress on `Adjustable` tracks. Maps +/// `progress` (the position of the net vote between zero and the +/// side-specific threshold) to the fraction of the delay range to +/// apply. +pub trait AdjustmentCurve { + fn apply(progress: Perbill) -> Perbill; +} + +/// Per-referendum data captured at submit time and updated as votes arrive. +#[freeze_struct("b7609aee357fa7ab")] +#[derive( + Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, Clone, PartialEq, Eq, TypeInfo, Debug, +)] +pub struct ReferendumInfo { + /// Track this referendum was filed against. + pub track: TrackId, + /// What this referendum proposes. + pub proposal: Proposal, + /// Account that submitted the referendum. + pub proposer: AccountId, + /// Submission block. Anchors timing computations in `Adjustable` + /// strategies. + pub submitted: BlockNumber, + /// Latest tally observed from the voting layer. + pub tally: VoteTally, + /// Snapshot of the track's decision strategy taken at submit time. + /// State-machine evaluation reads from this snapshot, so a runtime + /// upgrade that changes track config does not change the rules under + /// which a live referendum resolves. + pub decision_strategy: DecisionStrategy, +} + +/// Lifecycle status of a referendum. Each terminal variant carries the +/// block number at which it was reached. +#[derive( + Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, Clone, PartialEq, Eq, TypeInfo, Debug, +)] +pub enum ReferendumStatus { + /// Voting is in progress. + Ongoing(ReferendumInfo), + /// Approval threshold reached on a `PassOrFail` track. The call has + /// been scheduled for dispatch on this referendum's index. Transitions + /// to [`Enacted`](Self::Enacted) once the scheduled task has run. + Approved(BlockNumber), + /// Approval reached with `ApprovalAction::Review`. The call now lives + /// on a fresh referendum on the configured review track; this index + /// is a terminal audit trail. + Delegated(BlockNumber), + /// Rejection threshold reached on a `PassOrFail` track. + Rejected(BlockNumber), + /// Decision period elapsed without crossing approve or reject + /// thresholds. + Expired(BlockNumber), + /// Fast-track threshold reached on an `Adjustable` track. The + /// scheduled task was rescheduled to next block. Transitions to + /// [`Enacted`](Self::Enacted). + FastTracked(BlockNumber), + /// Cancel threshold reached on an `Adjustable` track. The scheduled + /// task was cancelled. + Cancelled(BlockNumber), + /// The dispatch attempt completed. Terminal regardless of whether + /// the inner call returned `Ok` or `Err`. + Enacted(BlockNumber), + /// Terminated by [`Config::KillOrigin`](crate::Config::KillOrigin) + /// before reaching a vote-driven outcome. + Killed(BlockNumber), +} + +/// Stable scheduler name for a referendum's enactment task. +pub fn task_name(index: ReferendumIndex) -> TaskName { + (REFERENDA_ID, "enactment", index).using_encoded(sp_io::hashing::blake2_256) +} + +/// Stable scheduler name for a referendum's alarm. +pub fn alarm_name(index: ReferendumIndex) -> TaskName { + (REFERENDA_ID, "alarm", index).using_encoded(sp_io::hashing::blake2_256) +} diff --git a/pallets/referenda/src/weights.rs b/pallets/referenda/src/weights.rs new file mode 100644 index 0000000000..156ee2e7f9 --- /dev/null +++ b/pallets/referenda/src/weights.rs @@ -0,0 +1,252 @@ + +//! Autogenerated weights for `pallet_referenda` +//! +//! 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_referenda +// --extrinsic=* +// --steps=50 +// --repeat=20 +// --no-storage-info +// --no-min-squares +// --no-median-slopes +// --output=/tmp/tmp.3gOgexNnQo +// --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_referenda`. +pub trait WeightInfo { + fn submit() -> Weight; + fn kill() -> Weight; + fn advance_referendum() -> Weight; + fn on_tally_updated() -> Weight; +} + +/// Weights for `pallet_referenda` using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + /// Storage: `MultiCollective::Members` (r:2 w:0) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ActiveCount` (r:1 w:1) + /// Proof: `Referenda::ActiveCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ActivePerProposer` (r:1 w:1) + /// Proof: `Referenda::ActivePerProposer` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ReferendumCount` (r:1 w:1) + /// Proof: `Referenda::ReferendumCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Lookup` (r:1 w:1) + /// Proof: `Scheduler::Lookup` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Agenda` (r:1 w:1) + /// Proof: `Scheduler::Agenda` (`max_values`: None, `max_size`: Some(10463), added: 12938, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::TallyOf` (r:1 w:1) + /// Proof: `SignedVoting::TallyOf` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ReferendumStatusFor` (r:0 w:1) + /// Proof: `Referenda::ReferendumStatusFor` (`max_values`: None, `max_size`: Some(219), added: 2694, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VoterSetOf` (r:0 w:1) + /// Proof: `SignedVoting::VoterSetOf` (`max_values`: None, `max_size`: Some(2062), added: 4537, mode: `MaxEncodedLen`) + fn submit() -> Weight { + // Proof Size summary in bytes: + // Measured: `375` + // Estimated: `13928` + // Minimum execution time: 56_345_000 picoseconds. + Weight::from_parts(57_508_000, 13928) + .saturating_add(T::DbWeight::get().reads(8_u64)) + .saturating_add(T::DbWeight::get().writes(8_u64)) + } + /// Storage: `Referenda::ReferendumStatusFor` (r:1 w:1) + /// Proof: `Referenda::ReferendumStatusFor` (`max_values`: None, `max_size`: Some(219), added: 2694, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Lookup` (r:2 w:1) + /// Proof: `Scheduler::Lookup` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Agenda` (r:1 w:1) + /// Proof: `Scheduler::Agenda` (`max_values`: None, `max_size`: Some(10463), added: 12938, mode: `MaxEncodedLen`) + /// Storage: `Referenda::EnactmentTask` (r:1 w:0) + /// Proof: `Referenda::EnactmentTask` (`max_values`: None, `max_size`: Some(151), added: 2626, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ActiveCount` (r:1 w:1) + /// Proof: `Referenda::ActiveCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ActivePerProposer` (r:1 w:1) + /// Proof: `Referenda::ActivePerProposer` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::TallyOf` (r:1 w:1) + /// Proof: `SignedVoting::TallyOf` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::PendingCleanup` (r:1 w:1) + /// Proof: `SignedVoting::PendingCleanup` (`max_values`: Some(1), `max_size`: Some(5401), added: 5896, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VoterSetOf` (r:0 w:1) + /// Proof: `SignedVoting::VoterSetOf` (`max_values`: None, `max_size`: Some(2062), added: 4537, mode: `MaxEncodedLen`) + fn kill() -> Weight { + // Proof Size summary in bytes: + // Measured: `608` + // Estimated: `13928` + // Minimum execution time: 56_235_000 picoseconds. + Weight::from_parts(57_437_000, 13928) + .saturating_add(T::DbWeight::get().reads(9_u64)) + .saturating_add(T::DbWeight::get().writes(8_u64)) + } + /// Storage: `Referenda::ReferendumStatusFor` (r:1 w:2) + /// Proof: `Referenda::ReferendumStatusFor` (`max_values`: None, `max_size`: Some(219), added: 2694, mode: `MaxEncodedLen`) + /// Storage: `MultiCollective::Members` (r:2 w:0) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ReferendumCount` (r:1 w:1) + /// Proof: `Referenda::ReferendumCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Lookup` (r:1 w:1) + /// Proof: `Scheduler::Lookup` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Agenda` (r:1 w:1) + /// Proof: `Scheduler::Agenda` (`max_values`: None, `max_size`: Some(10463), added: 12938, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ActiveCount` (r:1 w:1) + /// Proof: `Referenda::ActiveCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ActivePerProposer` (r:1 w:1) + /// Proof: `Referenda::ActivePerProposer` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::TallyOf` (r:2 w:2) + /// Proof: `SignedVoting::TallyOf` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::PendingCleanup` (r:1 w:1) + /// Proof: `SignedVoting::PendingCleanup` (`max_values`: Some(1), `max_size`: Some(5401), added: 5896, mode: `MaxEncodedLen`) + /// Storage: `Referenda::EnactmentTask` (r:0 w:1) + /// Proof: `Referenda::EnactmentTask` (`max_values`: None, `max_size`: Some(151), added: 2626, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VoterSetOf` (r:0 w:2) + /// Proof: `SignedVoting::VoterSetOf` (`max_values`: None, `max_size`: Some(2062), added: 4537, mode: `MaxEncodedLen`) + fn advance_referendum() -> Weight { + // Proof Size summary in bytes: + // Measured: `840` + // Estimated: `13928` + // Minimum execution time: 84_328_000 picoseconds. + Weight::from_parts(87_023_000, 13928) + .saturating_add(T::DbWeight::get().reads(11_u64)) + .saturating_add(T::DbWeight::get().writes(13_u64)) + } + /// Storage: `Referenda::ReferendumStatusFor` (r:1 w:1) + /// Proof: `Referenda::ReferendumStatusFor` (`max_values`: None, `max_size`: Some(219), added: 2694, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Lookup` (r:1 w:1) + /// Proof: `Scheduler::Lookup` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Agenda` (r:2 w:2) + /// Proof: `Scheduler::Agenda` (`max_values`: None, `max_size`: Some(10463), added: 12938, mode: `MaxEncodedLen`) + fn on_tally_updated() -> Weight { + // Proof Size summary in bytes: + // Measured: `420` + // Estimated: `26866` + // Minimum execution time: 35_226_000 picoseconds. + Weight::from_parts(36_468_000, 26866) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } +} + +// For backwards compatibility and tests. +impl WeightInfo for () { + /// Storage: `MultiCollective::Members` (r:2 w:0) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ActiveCount` (r:1 w:1) + /// Proof: `Referenda::ActiveCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ActivePerProposer` (r:1 w:1) + /// Proof: `Referenda::ActivePerProposer` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ReferendumCount` (r:1 w:1) + /// Proof: `Referenda::ReferendumCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Lookup` (r:1 w:1) + /// Proof: `Scheduler::Lookup` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Agenda` (r:1 w:1) + /// Proof: `Scheduler::Agenda` (`max_values`: None, `max_size`: Some(10463), added: 12938, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::TallyOf` (r:1 w:1) + /// Proof: `SignedVoting::TallyOf` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ReferendumStatusFor` (r:0 w:1) + /// Proof: `Referenda::ReferendumStatusFor` (`max_values`: None, `max_size`: Some(219), added: 2694, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VoterSetOf` (r:0 w:1) + /// Proof: `SignedVoting::VoterSetOf` (`max_values`: None, `max_size`: Some(2062), added: 4537, mode: `MaxEncodedLen`) + fn submit() -> Weight { + // Proof Size summary in bytes: + // Measured: `375` + // Estimated: `13928` + // Minimum execution time: 56_345_000 picoseconds. + Weight::from_parts(57_508_000, 13928) + .saturating_add(ParityDbWeight::get().reads(8_u64)) + .saturating_add(ParityDbWeight::get().writes(8_u64)) + } + /// Storage: `Referenda::ReferendumStatusFor` (r:1 w:1) + /// Proof: `Referenda::ReferendumStatusFor` (`max_values`: None, `max_size`: Some(219), added: 2694, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Lookup` (r:2 w:1) + /// Proof: `Scheduler::Lookup` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Agenda` (r:1 w:1) + /// Proof: `Scheduler::Agenda` (`max_values`: None, `max_size`: Some(10463), added: 12938, mode: `MaxEncodedLen`) + /// Storage: `Referenda::EnactmentTask` (r:1 w:0) + /// Proof: `Referenda::EnactmentTask` (`max_values`: None, `max_size`: Some(151), added: 2626, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ActiveCount` (r:1 w:1) + /// Proof: `Referenda::ActiveCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ActivePerProposer` (r:1 w:1) + /// Proof: `Referenda::ActivePerProposer` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::TallyOf` (r:1 w:1) + /// Proof: `SignedVoting::TallyOf` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::PendingCleanup` (r:1 w:1) + /// Proof: `SignedVoting::PendingCleanup` (`max_values`: Some(1), `max_size`: Some(5401), added: 5896, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VoterSetOf` (r:0 w:1) + /// Proof: `SignedVoting::VoterSetOf` (`max_values`: None, `max_size`: Some(2062), added: 4537, mode: `MaxEncodedLen`) + fn kill() -> Weight { + // Proof Size summary in bytes: + // Measured: `608` + // Estimated: `13928` + // Minimum execution time: 56_235_000 picoseconds. + Weight::from_parts(57_437_000, 13928) + .saturating_add(ParityDbWeight::get().reads(9_u64)) + .saturating_add(ParityDbWeight::get().writes(8_u64)) + } + /// Storage: `Referenda::ReferendumStatusFor` (r:1 w:2) + /// Proof: `Referenda::ReferendumStatusFor` (`max_values`: None, `max_size`: Some(219), added: 2694, mode: `MaxEncodedLen`) + /// Storage: `MultiCollective::Members` (r:2 w:0) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ReferendumCount` (r:1 w:1) + /// Proof: `Referenda::ReferendumCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Lookup` (r:1 w:1) + /// Proof: `Scheduler::Lookup` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Agenda` (r:1 w:1) + /// Proof: `Scheduler::Agenda` (`max_values`: None, `max_size`: Some(10463), added: 12938, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ActiveCount` (r:1 w:1) + /// Proof: `Referenda::ActiveCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Referenda::ActivePerProposer` (r:1 w:1) + /// Proof: `Referenda::ActivePerProposer` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::TallyOf` (r:2 w:2) + /// Proof: `SignedVoting::TallyOf` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::PendingCleanup` (r:1 w:1) + /// Proof: `SignedVoting::PendingCleanup` (`max_values`: Some(1), `max_size`: Some(5401), added: 5896, mode: `MaxEncodedLen`) + /// Storage: `Referenda::EnactmentTask` (r:0 w:1) + /// Proof: `Referenda::EnactmentTask` (`max_values`: None, `max_size`: Some(151), added: 2626, mode: `MaxEncodedLen`) + /// Storage: `SignedVoting::VoterSetOf` (r:0 w:2) + /// Proof: `SignedVoting::VoterSetOf` (`max_values`: None, `max_size`: Some(2062), added: 4537, mode: `MaxEncodedLen`) + fn advance_referendum() -> Weight { + // Proof Size summary in bytes: + // Measured: `840` + // Estimated: `13928` + // Minimum execution time: 84_328_000 picoseconds. + Weight::from_parts(87_023_000, 13928) + .saturating_add(ParityDbWeight::get().reads(11_u64)) + .saturating_add(ParityDbWeight::get().writes(13_u64)) + } + /// Storage: `Referenda::ReferendumStatusFor` (r:1 w:1) + /// Proof: `Referenda::ReferendumStatusFor` (`max_values`: None, `max_size`: Some(219), added: 2694, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Lookup` (r:1 w:1) + /// Proof: `Scheduler::Lookup` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `Scheduler::Agenda` (r:2 w:2) + /// Proof: `Scheduler::Agenda` (`max_values`: None, `max_size`: Some(10463), added: 12938, mode: `MaxEncodedLen`) + fn on_tally_updated() -> Weight { + // Proof Size summary in bytes: + // Measured: `420` + // Estimated: `26866` + // Minimum execution time: 35_226_000 picoseconds. + Weight::from_parts(36_468_000, 26866) + .saturating_add(ParityDbWeight::get().reads(4_u64)) + .saturating_add(ParityDbWeight::get().writes(4_u64)) + } +} From 2bd4f8dfb9654e2546eb5293229eb9dea7f88cb7 Mon Sep 17 00:00:00 2001 From: Loris Moulin Date: Sun, 28 Jun 2026 23:25:47 -0300 Subject: [PATCH 5/6] Placeholder From d0910bde91ebcfc43169c94a08393b9adc6bf2ec Mon Sep 17 00:00:00 2001 From: Loris Moulin Date: Wed, 3 Jun 2026 16:46:09 -0300 Subject: [PATCH 6/6] Wire governance into the runtime --- .github/workflows/typescript-e2e.yml | 19 +- Cargo.lock | 4 + chain-extensions/src/mock.rs | 3 + docs/governance/README.md | 203 +++++ eco-tests/src/mock.rs | 3 + pallets/admin-utils/src/tests/mock.rs | 4 +- pallets/subtensor/src/coinbase/root.rs | 15 +- pallets/subtensor/src/lib.rs | 32 + pallets/subtensor/src/macros/config.rs | 11 +- pallets/subtensor/src/macros/dispatches.rs | 20 +- pallets/subtensor/src/macros/hooks.rs | 39 +- ...grate_init_root_registered_hotkey_count.rs | 42 ++ pallets/subtensor/src/migrations/mod.rs | 1 + pallets/subtensor/src/root_registered/ema.rs | 135 ++++ pallets/subtensor/src/root_registered/mod.rs | 121 +++ .../src/root_registered/ref_count.rs | 31 + .../src/root_registered/try_state.rs | 66 ++ pallets/subtensor/src/subnets/uids.rs | 4 + pallets/subtensor/src/swap/swap_coldkey.rs | 4 + pallets/subtensor/src/tests/coinbase.rs | 300 ++++++++ pallets/subtensor/src/tests/migration.rs | 48 ++ pallets/subtensor/src/tests/mock.rs | 184 ++++- pallets/subtensor/src/tests/mock_high_ed.rs | 3 + pallets/subtensor/src/tests/mod.rs | 1 + .../subtensor/src/tests/root_registered.rs | 708 ++++++++++++++++++ pallets/subtensor/src/tests/swap_coldkey.rs | 78 ++ pallets/subtensor/src/tests/swap_hotkey.rs | 43 +- pallets/subtensor/src/weights.rs | 12 + pallets/transaction-fee/src/tests/mock.rs | 4 +- precompiles/src/mock.rs | 3 + runtime/Cargo.toml | 18 +- runtime/src/governance/README.md | 158 ++++ runtime/src/governance/benchmarking.rs | 207 +++++ runtime/src/governance/collectives.rs | 194 +++++ runtime/src/governance/ema_provider.rs | 415 ++++++++++ runtime/src/governance/member_set.rs | 147 ++++ runtime/src/governance/mod.rs | 301 ++++++++ runtime/src/governance/term_management.rs | 430 +++++++++++ runtime/src/governance/tracks.rs | 179 +++++ runtime/src/governance/weights.rs | 147 ++++ runtime/src/lib.rs | 17 +- scripts/benchmark_action.sh | 4 +- scripts/benchmark_all.sh | 20 +- scripts/discover_pallets.sh | 15 +- support/procedural-fork/Cargo.toml | 2 +- ts-tests/moonwall.config.json | 38 +- ts-tests/scripts/build-fast-runtime.sh | 52 ++ ts-tests/scripts/build-upgrade-runtime.sh | 60 ++ .../dev/subtensor/governance/test-capacity.ts | 143 ++++ .../subtensor/governance/test-full-flow.ts | 124 +++ .../governance/test-origin-guards.ts | 184 +++++ .../governance/test-runtime-config.ts | 217 ++++++ .../governance/test-runtime-upgrade.ts | 126 ++++ .../governance/test-track0-lifecycle.ts | 105 +++ .../governance/test-track1-lifecycle.ts | 211 ++++++ .../subtensor/governance/test-voter-sets.ts | 142 ++++ .../governance/test-track0-expired.ts | 108 +++ .../governance/test-track1-delay-curve.ts | 157 ++++ .../test-track1-natural-enactment.ts | 108 +++ ts-tests/utils/governance.ts | 305 ++++++++ 60 files changed, 6423 insertions(+), 52 deletions(-) create mode 100644 docs/governance/README.md create mode 100644 pallets/subtensor/src/migrations/migrate_init_root_registered_hotkey_count.rs create mode 100644 pallets/subtensor/src/root_registered/ema.rs create mode 100644 pallets/subtensor/src/root_registered/mod.rs create mode 100644 pallets/subtensor/src/root_registered/ref_count.rs create mode 100644 pallets/subtensor/src/root_registered/try_state.rs create mode 100644 pallets/subtensor/src/tests/root_registered.rs create mode 100644 runtime/src/governance/README.md create mode 100644 runtime/src/governance/benchmarking.rs create mode 100644 runtime/src/governance/collectives.rs create mode 100644 runtime/src/governance/ema_provider.rs create mode 100644 runtime/src/governance/member_set.rs create mode 100644 runtime/src/governance/mod.rs create mode 100644 runtime/src/governance/term_management.rs create mode 100644 runtime/src/governance/tracks.rs create mode 100644 runtime/src/governance/weights.rs create mode 100755 ts-tests/scripts/build-fast-runtime.sh create mode 100755 ts-tests/scripts/build-upgrade-runtime.sh create mode 100644 ts-tests/suites/dev/subtensor/governance/test-capacity.ts create mode 100644 ts-tests/suites/dev/subtensor/governance/test-full-flow.ts create mode 100644 ts-tests/suites/dev/subtensor/governance/test-origin-guards.ts create mode 100644 ts-tests/suites/dev/subtensor/governance/test-runtime-config.ts create mode 100644 ts-tests/suites/dev/subtensor/governance/test-runtime-upgrade.ts create mode 100644 ts-tests/suites/dev/subtensor/governance/test-track0-lifecycle.ts create mode 100644 ts-tests/suites/dev/subtensor/governance/test-track1-lifecycle.ts create mode 100644 ts-tests/suites/dev/subtensor/governance/test-voter-sets.ts create mode 100644 ts-tests/suites/dev_fast/governance/test-track0-expired.ts create mode 100644 ts-tests/suites/dev_fast/governance/test-track1-delay-curve.ts create mode 100644 ts-tests/suites/dev_fast/governance/test-track1-natural-enactment.ts create mode 100644 ts-tests/utils/governance.ts diff --git a/.github/workflows/typescript-e2e.yml b/.github/workflows/typescript-e2e.yml index 822c104c12..24d3a9e2c8 100644 --- a/.github/workflows/typescript-e2e.yml +++ b/.github/workflows/typescript-e2e.yml @@ -139,11 +139,24 @@ jobs: working-directory: ts-tests run: pnpm install --frozen-lockfile - - name: Install lsof (dev foundation RPC port discovery) - if: matrix.test == 'dev' + - name: Install system dependencies run: | sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends lsof + sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends \ + -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ + build-essential clang curl git make libssl-dev llvm libudev-dev protobuf-compiler pkg-config lsof + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + + - name: Utilize Shared Rust Cache + uses: Swatinem/rust-cache@v2 + with: + key: e2e-runtime-upgrade + cache-on-failure: true + workspaces: ". -> ts-tests/tmp/cargo-target" - name: Run tests run: | diff --git a/Cargo.lock b/Cargo.lock index b76f3446db..bee23ba7a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8529,15 +8529,19 @@ dependencies = [ "pallet-hotfix-sufficients", "pallet-insecure-randomness-collective-flip", "pallet-limit-orders", + "pallet-multi-collective", "pallet-multisig", "pallet-nomination-pools", "pallet-nomination-pools-runtime-api", "pallet-offences", "pallet-preimage", + "pallet-referenda 1.0.0", + "pallet-registry", "pallet-safe-mode", "pallet-scheduler", "pallet-session", "pallet-shield", + "pallet-signed-voting", "pallet-staking", "pallet-staking-reward-curve", "pallet-staking-reward-fn", diff --git a/chain-extensions/src/mock.rs b/chain-extensions/src/mock.rs index db25a9df68..8532e88d97 100644 --- a/chain-extensions/src/mock.rs +++ b/chain-extensions/src/mock.rs @@ -438,6 +438,9 @@ impl pallet_subtensor::Config for Test { type CommitmentsInterface = CommitmentsI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; + type OnRootRegistrationChange = (); + type RootRegisteredInspector = (); + type EmaValueProvider = (); type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type InitialMaxEpochsPerBlock = MaxEpochsPerBlock; diff --git a/docs/governance/README.md b/docs/governance/README.md new file mode 100644 index 0000000000..8b4357ff30 --- /dev/null +++ b/docs/governance/README.md @@ -0,0 +1,203 @@ +# On-Chain Governance + +Subtensor governance is implemented as track-based referenda backed by +signed collective voting. The live runtime wiring is in +`runtime/src/governance`; the generic building blocks are +`pallets/referenda`, `pallets/signed-voting`, and +`pallets/multi-collective`. + +Governance has two stages: + +1. A proposal is submitted by an authorized proposer and decided by the + three-member Triumvirate. +2. If the Triumvirate approves it, the call is handed to a separate + collective review track where the Economic and Building collectives can + accelerate, delay, or cancel enactment. + +The governed call is dispatched as root only if it survives this flow. + +## Runtime Tracks + +| Track | Name | Submitters | Voters | Decision | +| ---- | ---- | ---- | ---- | ---- | +| `0` | `triumvirate` | `Proposers` collective | `Triumvirate` collective | `PassOrFail`: 7 day decision period, 2/3 approve, 2/3 reject. Approval delegates to track `1`. | +| `1` | `review` | None | Union of `Economic` and `Building` | `Adjustable`: scheduled at 24 hours by default, adjustable up to 2 days, 75% approval fast-tracks, 51% rejection cancels. | + +Track `1` is intentionally not directly submittable. Its only entry point +is the `ApprovalAction::Review` handoff from track `0`, so a proposer +cannot bypass Triumvirate approval and place a root call directly into the +review delay. + +Both tracks use `pallet-signed-voting`. When a referendum opens, the voting +backend snapshots the eligible voter set and uses that snapshot for the +entire poll. Members rotated out after the poll opens keep their vote on +that poll; members rotated in later cannot vote on old polls. For the review +track, the Economic and Building member lists are unioned and deduplicated, +so an account present in both collectives counts once. + +## Collectives + +| Collective | Size | Rotation | Purpose | +| ---- | ---- | ---- | ---- | +| `Proposers` | min `1`, max `20` | Manual | Accounts allowed to submit on the Triumvirate track. | +| `Triumvirate` | exactly `3` | Manual | Approval body for submitted proposals. | +| `Economic` | exactly `16` | Every 60 days | Top root-registered validator coldkeys by smoothed stake value. | +| `Building` | exactly `16` | Every 60 days | Top subnet-owner coldkeys by their best mature subnet price. | +| `EconomicEligible` | max `64` | Automatic sync, no voting role | Candidate pool for `Economic`; mirrors coldkeys with at least one root-registered hotkey. | + +Membership is stored by `pallet-multi-collective`. In the runtime all +membership mutation origins are root-gated, so changes to curated +collectives are expected to go through governance once sudo/root authority +is replaced by the governance flow. The rotating collectives can also be +force-rotated by root. + +The rotating collectives have `min_members == max_members == 16`. If a +rotation computes fewer than 16 eligible accounts, `set_members` fails the +minimum-member check and the previous membership remains in storage. The +failure is logged instead of partially rotating the set. + +## Economic Selection + +The Economic collective is selected from `EconomicEligible`, not directly +from every account on chain. + +`EconomicEligible` is synchronized from root registration: + +- When a coldkey's root-registered hotkey count moves from `0` to `1`, the + coldkey is added to `EconomicEligible` and its root-registered EMA is + initialized at zero. +- When the count moves from `1` to `0`, the coldkey is removed and its EMA + state is cleared. +- The cap is `64`, matching the root subnet UID limit. + +Each block, `pallet-subtensor` advances the root-registered EMA sampler. +The governance runtime provides the sample value through +`StakeValueProvider`: liquid TAO balance plus the TAO value of alpha held +by the coldkey's owned hotkeys across all subnets. The provider works in +chunks of 8 subnets and values at most 256 owned hotkeys per sample. + +The EMA uses alpha `0.02`. A coldkey must have at least `210` completed +samples before it can be selected for `Economic` membership. With the +current sampler cadence this is roughly a 30 day warmup. At rotation time, +the runtime ranks eligible coldkeys by descending EMA value and takes the +top 16. + +## Building Selection + +The Building collective represents subnet owners. + +At rotation time, the runtime iterates all subnets and ignores any subnet +younger than `MIN_SUBNET_AGE`, which is 180 days in production. For each +remaining subnet it reads: + +- `NetworkRegisteredAt` +- `SubnetMovingPrice` +- `SubnetOwner` + +An owner may control more than one mature subnet. The runtime keeps only +that owner's highest observed `SubnetMovingPrice`, then ranks owners by +that best price and takes the top 16. This means one coldkey can receive at +most one Building seat, even if it owns multiple high-priced subnets. + +## Referendum Lifecycle + +1. A member of `Proposers` calls `referenda.submit(0, call)`. +2. `pallet-referenda` checks the proposer set, global queue limit + (`MaxQueued = 20`), and per-proposer limit (`MaxActivePerProposer = 5`). +3. Triumvirate voters use `signed_voting.vote(index, approve)` or + `signed_voting.remove_vote(index)`. +4. If 2/3 of the Triumvirate snapshot votes approve before 7 days elapse, + the parent referendum becomes `Delegated` and a child review referendum + is created on track `1`. +5. If 2/3 reject, the referendum becomes `Rejected`. If neither threshold + is reached before the deadline, it becomes `Expired`. +6. The review child schedules the root call at `submitted + 24 hours`. + Economic and Building voters can approve, reject, change their vote, or + remove their vote while the review is ongoing. +7. If review approval reaches 75% of the snapshot, the call is rescheduled + for the next block and the referendum becomes `FastTracked`. +8. If review rejection reaches 51%, the scheduled call is cancelled and the + referendum becomes `Cancelled`. +9. Otherwise, net approval moves the scheduled block earlier and net + rejection moves it later, up to the 2 day maximum delay. +10. When the scheduler invokes `referenda.enact`, the inner call is + dispatched with root origin and the referendum becomes `Enacted`. The + event records whether the inner dispatch returned an error. + +There is no proposer-only withdraw or cancel extrinsic in the current +implementation. Privileged termination is `referenda.kill`, gated by root, +and can kill an ongoing, approved, or fast-tracked referendum before +dispatch. + +## Review Delay Formula + +Review uses the runtime's `EaseOutAdjustmentCurve`, so net vote progress is +shaped as `1 - (1 - p)^3`. Early net collective signal has a visible effect +on the dispatch delay, then the curve tapers off as the vote approaches the +hard fast-track or cancel threshold. + +If approval is greater than or equal to rejection: + +```text +net = approval - rejection +progress = net / fast_track_threshold +curved = 1 - (1 - progress)^3 +delay = initial_delay * (1 - curved) +``` + +If rejection is greater than approval: + +```text +net = rejection - approval +progress = net / cancel_threshold +curved = 1 - (1 - progress)^3 +delay = initial_delay + curved * (max_delay - initial_delay) +``` + +With production constants, `initial_delay = 24 hours`, +`max_delay = 2 days`, `fast_track_threshold = 75%`, and +`cancel_threshold = 51%`. If a recomputed target is already in the past, +the referendum is fast-tracked. + +## Storage and Audit Trail + +Referendum statuses remain queryable after conclusion. Votes are stored by +`pallet-signed-voting` while a poll is active, then cleaned lazily after the +poll completes. Per-voter records are no longer read after the tally is +removed, so lazy cleanup affects storage hygiene rather than governance +correctness. + +Relevant events: + +- `referenda.Submitted` +- `referenda.Delegated` +- `referenda.Rejected` +- `referenda.Expired` +- `referenda.FastTracked` +- `referenda.Cancelled` +- `referenda.Killed` +- `referenda.Enacted` +- `signed_voting.Voted` +- `signed_voting.VoteRemoved` +- `multi_collective.MemberAdded` +- `multi_collective.MemberRemoved` +- `multi_collective.MemberSwapped` +- `multi_collective.MembersSet` + +## Implementation Map + +- `runtime/src/governance/collectives.rs`: collective ids, sizes, term + duration, and root-registration sync for `EconomicEligible`. +- `runtime/src/governance/tracks.rs`: track ids, thresholds, delays, and + decision strategies. +- `runtime/src/governance/member_set.rs`: single and union collective voter + sets with deduplication. +- `runtime/src/governance/term_management.rs`: Economic and Building + rotation selection. +- `runtime/src/governance/ema_provider.rs`: Economic stake-value sample + provider. +- `pallets/referenda`: generic track state machine and scheduler wrapping. +- `pallets/signed-voting`: per-account aye/nay voting with frozen voter-set + snapshots. +- `pallets/multi-collective`: named collective membership and term + rotation hooks. diff --git a/eco-tests/src/mock.rs b/eco-tests/src/mock.rs index 5ce8bc9b6b..f89b0c40ac 100644 --- a/eco-tests/src/mock.rs +++ b/eco-tests/src/mock.rs @@ -332,6 +332,9 @@ impl pallet_subtensor::Config for Test { type CommitmentsInterface = CommitmentsI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; + type OnRootRegistrationChange = (); + type RootRegisteredInspector = (); + type EmaValueProvider = (); type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type InitialMaxEpochsPerBlock = MaxEpochsPerBlock; diff --git a/pallets/admin-utils/src/tests/mock.rs b/pallets/admin-utils/src/tests/mock.rs index e3b658a0a8..8b35693102 100644 --- a/pallets/admin-utils/src/tests/mock.rs +++ b/pallets/admin-utils/src/tests/mock.rs @@ -247,6 +247,9 @@ impl pallet_subtensor::Config for Test { type CommitmentsInterface = CommitmentsI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; + type OnRootRegistrationChange = (); + type RootRegisteredInspector = (); + type EmaValueProvider = (); type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type InitialMaxEpochsPerBlock = MaxEpochsPerBlock; @@ -408,7 +411,6 @@ parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; pub const MaxScheduledPerBlock: u32 = 50; - pub const NoPreimagePostponement: Option = Some(10); } impl pallet_scheduler::Config for Test { diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index b4db388621..784ec9887f 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -121,14 +121,15 @@ impl Pallet { // --- 8. Check if the root net is below its allowed size. // max allowed is senate size. if current_num_root_validators < Self::get_max_root_validators() { - // --- 12.1.1 We can append to the subnetwork as it's not full. + // We can append to the subnetwork as it's not full. subnetwork_uid = current_num_root_validators; - // --- 12.1.2 Add the new account and make them a member of the Senate. + // Add the new account and make them a member of the Senate. Self::append_neuron(NetUid::ROOT, &hotkey, current_block_number); log::debug!("add new neuron: {hotkey:?} on uid {subnetwork_uid:?}"); + Self::increment_root_registered_hotkey_count(&coldkey); } else { - // --- 13.1.1 The network is full. Perform replacement. + // The network is full. Perform replacement. // Find the neuron with the lowest stake value to replace. let mut lowest_stake = AlphaBalance::MAX; let mut lowest_uid: u16 = 0; @@ -145,19 +146,23 @@ impl Pallet { let replaced_hotkey: T::AccountId = Self::get_hotkey_for_net_and_uid(NetUid::ROOT, subnetwork_uid)?; - // --- 13.1.2 The new account has a higher stake than the one being replaced. + // The new account has a higher stake than the one being replaced. ensure!( lowest_stake < Self::get_stake_for_hotkey_on_subnet(&hotkey, NetUid::ROOT), Error::::StakeTooLowForRoot ); - // --- 13.1.3 The new account has a higher stake than the one being replaced. + // The new account has a higher stake than the one being replaced. // Replace the neuron account with new information. Self::replace_neuron(NetUid::ROOT, lowest_uid, &hotkey, current_block_number); log::debug!( "replace neuron: {replaced_hotkey:?} with {hotkey:?} on uid {subnetwork_uid:?}" ); + + let replaced_owner = Owner::::get(&replaced_hotkey); + Self::decrement_root_registered_hotkey_count(&replaced_owner); + Self::increment_root_registered_hotkey_count(&coldkey); } // --- 13. Force all members on root to become a delegate. diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 8896ceaf0f..c6ddd4bddf 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -37,6 +37,7 @@ pub mod extensions; pub mod guards; pub mod macros; pub mod migrations; +pub mod root_registered; pub mod rpc_info; pub mod staking; pub mod subnets; @@ -83,6 +84,7 @@ pub const ACCOUNT_FLAGS_ACCEPT_LOCKED_ALPHA: u128 = 1u128 << 0; pub mod pallet { use crate::RateLimitKey; use crate::migrations; + use crate::root_registered::{EmaState, EmaValueProvider, InFlightEmaSample}; use crate::staking::lock::LockState; use crate::subnets::leasing::{LeaseId, SubnetLeaseOf}; use frame_support::Twox64Concat; @@ -1409,6 +1411,36 @@ pub mod pallet { pub type OwnedHotkeys = StorageMap<_, Blake2_128Concat, T::AccountId, Vec, ValueQuery>; + /// Number of hotkeys controlled by this coldkey that are currently registered on the root subnet. + #[pallet::storage] + pub type RootRegisteredHotkeyCount = + StorageMap<_, Blake2_128Concat, T::AccountId, u32, ValueQuery>; + + /// EMA state for each root-registered coldkey. + #[pallet::storage] + pub type RootRegisteredEma = + StorageMap<_, Blake2_128Concat, T::AccountId, EmaState, ValueQuery>; + + /// Fixed coldkey snapshot used by the current EMA sampling cycle. + #[pallet::storage] + pub type CurrentCycleMembers = + StorageValue<_, BoundedVec>, ValueQuery>; + + /// Internal: the EMA value provider for the runtime. + pub type EmaProviderOf = ::EmaValueProvider; + + /// Internal: provider-owned progress for the coldkey currently being sampled. + pub type EmaProgressOf = as EmaValueProvider>>::Progress; + + /// Internal: in-flight sample for the current coldkey. Present only + /// while `T::EmaValueProvider` has returned `SampleStep::Continue`. + pub type InFlightEmaSampleOf = InFlightEmaSample, EmaProgressOf>; + + /// Cursor and in-flight provider progress for the EMA sampling cycle. + #[pallet::storage] + pub type EmaSamplerState = + StorageValue<_, (u32, Option>), ValueQuery>; + /// --- DMAP ( cold, netuid )--> hot | Returns the hotkey a coldkey will autostake to with mining rewards. #[pallet::storage] pub type AutoStakeDestination = StorageDoubleMap< diff --git a/pallets/subtensor/src/macros/config.rs b/pallets/subtensor/src/macros/config.rs index ad372d1e0e..066cce5590 100644 --- a/pallets/subtensor/src/macros/config.rs +++ b/pallets/subtensor/src/macros/config.rs @@ -6,7 +6,7 @@ use frame_support::pallet_macros::pallet_section; #[pallet_section] mod config { - use crate::{CommitmentsInterface, GetAlphaForTao, GetTaoForAlpha}; + use crate::{CommitmentsInterface, GetAlphaForTao, GetTaoForAlpha, root_registered::*}; use frame_support::PalletId; use pallet_alpha_assets::AlphaAssetsInterface; use pallet_commitments::GetCommitments; @@ -71,6 +71,15 @@ mod config { /// Provider of current block author type AuthorshipProvider: AuthorshipInfo; + /// Handler for root-registration transitions. + type OnRootRegistrationChange: OnRootRegistrationChange; + + /// External snapshot of the root-registered coldkey set. + type RootRegisteredInspector: RootRegisteredInspector; + + /// Provider for the value sampled by root-registered EMAs. + type EmaValueProvider: EmaValueProvider; + /// Weight information for extrinsics in this pallet. type WeightInfo: crate::weights::WeightInfo; diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index a4596c9add..df4deed3cb 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -5,6 +5,7 @@ use frame_support::pallet_macros::pallet_section; /// This can later be imported into the pallet using [`import_section`]. #[pallet_section] mod dispatches { + use crate::root_registered::OnRootRegistrationChange; use crate::weights::WeightInfo; use frame_support::traits::schedule::v3::Anon as ScheduleAnon; use frame_system::pallet_prelude::BlockNumberFor; @@ -1003,7 +1004,12 @@ mod dispatches { /// Register the hotkey to root network #[pallet::call_index(62)] - #[pallet::weight(::WeightInfo::root_register())] + #[pallet::weight( + ::WeightInfo::root_register() + // Worst case: we kick someone off and we take their place. + .saturating_add(::OnRootRegistrationChange::on_added_weight()) + .saturating_add(::OnRootRegistrationChange::on_removed_weight()) + )] pub fn root_register(origin: OriginFor, hotkey: T::AccountId) -> DispatchResult { Self::do_root_register(origin, hotkey) } @@ -1070,7 +1076,11 @@ mod dispatches { /// /// Only callable by root as it doesn't require an announcement and can be used to swap any coldkey. #[pallet::call_index(71)] - #[pallet::weight(::WeightInfo::swap_coldkey())] + #[pallet::weight( + ::WeightInfo::swap_coldkey() + .saturating_add(::OnRootRegistrationChange::on_added_weight()) + .saturating_add(::OnRootRegistrationChange::on_removed_weight()) + )] pub fn swap_coldkey( origin: OriginFor, old_coldkey: T::AccountId, @@ -2297,7 +2307,11 @@ mod dispatches { /// /// The `ColdkeySwapped` event is emitted on successful swap. #[pallet::call_index(126)] - #[pallet::weight(::WeightInfo::swap_coldkey_announced())] + #[pallet::weight( + ::WeightInfo::swap_coldkey_announced() + .saturating_add(::OnRootRegistrationChange::on_added_weight()) + .saturating_add(::OnRootRegistrationChange::on_removed_weight()) + )] pub fn swap_coldkey_announced( origin: OriginFor, new_coldkey: T::AccountId, diff --git a/pallets/subtensor/src/macros/hooks.rs b/pallets/subtensor/src/macros/hooks.rs index 6371f30e46..3df7e7a66a 100644 --- a/pallets/subtensor/src/macros/hooks.rs +++ b/pallets/subtensor/src/macros/hooks.rs @@ -15,27 +15,27 @@ mod hooks { // * 'n': (BlockNumberFor): // - The number of the block we are initializing. fn on_initialize(block_number: BlockNumberFor) -> Weight { - let hotkey_swap_clean_up_weight = Self::clean_up_hotkey_swap_records(block_number); + let mut weight = Weight::zero(); - let block_step_result = Self::block_step(); - match block_step_result { + weight.saturating_accrue(Self::clean_up_hotkey_swap_records(block_number)); + weight.saturating_accrue(Self::tick_root_registered_ema()); + + match Self::block_step() { Ok(_) => { - // --- If the block step was successful, return the weight. - log::debug!("Successfully ran block step."); - Weight::from_parts(110_634_229_000_u64, 0) - .saturating_add(T::DbWeight::get().reads(8304_u64)) - .saturating_add(T::DbWeight::get().writes(110_u64)) - .saturating_add(hotkey_swap_clean_up_weight) + log::debug!("Successfully ran block step.") } Err(e) => { - // --- If the block step was unsuccessful, return the weight anyway. - log::error!("Error while stepping block: {:?}", e); - Weight::from_parts(110_634_229_000_u64, 0) - .saturating_add(T::DbWeight::get().reads(8304_u64)) - .saturating_add(T::DbWeight::get().writes(110_u64)) - .saturating_add(hotkey_swap_clean_up_weight) + log::error!("Error while stepping block: {:?}", e) } - } + }; + // TODO: benchmark properly + weight.saturating_accrue( + Weight::from_parts(110_634_229_000_u64, 0) + .saturating_add(T::DbWeight::get().reads(8304_u64)) + .saturating_add(T::DbWeight::get().writes(110_u64)), + ); + + weight } fn on_runtime_upgrade() -> frame_support::weights::Weight { @@ -177,7 +177,9 @@ mod hooks { // Capture the runtime-upgrade block for TAO-in refund cutover. .saturating_add(migrations::migrate_tao_in_refund_deployment_block::migrate_tao_in_refund_deployment_block::()) // Fix lock state left behind by subnet-scoped hotkey swaps. - .saturating_add(migrations::migrate_fix_subnet_hotkey_lock_swaps::migrate_fix_subnet_hotkey_lock_swaps::()); + .saturating_add(migrations::migrate_fix_subnet_hotkey_lock_swaps::migrate_fix_subnet_hotkey_lock_swaps::()) + // Backfill `RootRegisteredHotkeyCount` from the root-subnet `Keys` map + .saturating_add(migrations::migrate_init_root_registered_hotkey_count::migrate_init_root_registered_hotkey_count::()); weight } @@ -185,6 +187,9 @@ mod hooks { fn try_state(_n: BlockNumberFor) -> Result<(), sp_runtime::TryRuntimeError> { // Disabled: https://github.com/opentensor/subtensor/pull/1166 // Self::check_total_stake()?; + Self::check_root_registered_hotkey_count()?; + Self::check_root_registered_matches_inspector()?; + Self::check_root_registered_ema_matches_count()?; Ok(()) } } diff --git a/pallets/subtensor/src/migrations/migrate_init_root_registered_hotkey_count.rs b/pallets/subtensor/src/migrations/migrate_init_root_registered_hotkey_count.rs new file mode 100644 index 0000000000..1463e9cf70 --- /dev/null +++ b/pallets/subtensor/src/migrations/migrate_init_root_registered_hotkey_count.rs @@ -0,0 +1,42 @@ +use alloc::string::String; + +use frame_support::{traits::Get, weights::Weight}; +use subtensor_runtime_common::NetUid; + +use super::*; + +pub fn migrate_init_root_registered_hotkey_count() -> Weight { + let migration_name = b"migrate_init_root_registered_hotkey_count".to_vec(); + + let mut weight = T::DbWeight::get().reads(1); + + if HasMigrationRun::::get(&migration_name) { + log::info!( + "Migration '{:?}' has already run. Skipping.", + String::from_utf8_lossy(&migration_name) + ); + return weight; + } + log::info!( + "Running migration '{}'", + String::from_utf8_lossy(&migration_name) + ); + + let mut entries: u64 = 0; + for (_uid, hotkey) in Keys::::iter_prefix(NetUid::ROOT) { + let coldkey = Owner::::get(&hotkey); + Pallet::::increment_root_registered_hotkey_count(&coldkey); + weight.saturating_accrue(T::DbWeight::get().reads_writes(5, 2)); + entries = entries.saturating_add(1); + } + + HasMigrationRun::::insert(&migration_name, true); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + + log::info!( + "Migration '{:?}' completed. {entries} root hotkeys indexed.", + String::from_utf8_lossy(&migration_name) + ); + + weight +} diff --git a/pallets/subtensor/src/migrations/mod.rs b/pallets/subtensor/src/migrations/mod.rs index e846d325dc..158f821a1b 100644 --- a/pallets/subtensor/src/migrations/mod.rs +++ b/pallets/subtensor/src/migrations/mod.rs @@ -27,6 +27,7 @@ pub mod migrate_fix_root_tao_and_alpha_in; pub mod migrate_fix_staking_hot_keys; pub mod migrate_fix_subnet_hotkey_lock_swaps; pub mod migrate_fix_total_issuance_evm_fees; +pub mod migrate_init_root_registered_hotkey_count; pub mod migrate_init_tao_flow; pub mod migrate_init_total_issuance; pub mod migrate_kappa_map_to_default; diff --git a/pallets/subtensor/src/root_registered/ema.rs b/pallets/subtensor/src/root_registered/ema.rs new file mode 100644 index 0000000000..8342cfb4a4 --- /dev/null +++ b/pallets/subtensor/src/root_registered/ema.rs @@ -0,0 +1,135 @@ +use alloc::vec::Vec; +use frame_support::weights::Weight; +use substrate_fixed::types::U64F64; + +use super::*; +use crate::root_registered::{EmaState, EmaValueProvider, InFlightEmaSample, SampleStep}; + +/// EMA mixing constant numerator (alpha = 2/100 = 0.02). +const EMA_ALPHA_NUM: u64 = 2; +const EMA_ALPHA_DEN: u64 = 100; + +impl Pallet { + /// Advances the root-registered EMA sampler by one provider step. + pub fn tick_root_registered_ema() -> Weight { + let (sample, mut weight) = Self::load_current_sample(); + let Some((cursor, coldkey, in_flight)) = sample else { + return weight; + }; + + let has_ema = RootRegisteredEma::::contains_key(&coldkey); + weight.saturating_accrue(T::DbWeight::get().reads(1)); + + if !has_ema { + return weight.saturating_add(Self::skip_missing_sample(cursor)); + } + + let progress = Self::resume_progress(&coldkey, in_flight); + + let (step, step_weight) = T::EmaValueProvider::step(&coldkey, progress); + weight.saturating_accrue(step_weight); + + weight.saturating_add(match step { + SampleStep::Continue { progress } => Self::store_progress(cursor, coldkey, progress), + SampleStep::Complete { sample } => Self::complete_sample(cursor, coldkey, sample), + }) + } + + fn load_current_sample() -> ( + Option<(u32, T::AccountId, Option>)>, + Weight, + ) { + let db = T::DbWeight::get(); + let (mut cursor, mut in_flight) = EmaSamplerState::::get(); + let mut members = CurrentCycleMembers::::get(); + let mut weight = db.reads(2); + + // Cursor wrap starts a new fixed snapshot. Keeping the snapshot + // stable avoids mid-cycle joins reshuffling the round-robin order. + if (cursor as usize) >= members.len() { + let collected: Vec = + RootRegisteredEma::::iter().map(|(k, _)| k).collect(); + weight.saturating_accrue(db.reads(collected.len() as u64)); + + members = BoundedVec::try_from(collected).unwrap_or_default(); + cursor = 0; + in_flight = None; + + CurrentCycleMembers::::put(&members); + EmaSamplerState::::put((cursor, None::>)); + weight.saturating_accrue(db.writes(2)); + } + + let sample = members + .get(cursor as usize) + .map(|coldkey| (cursor, coldkey.clone(), in_flight)); + (sample, weight) + } + + fn resume_progress( + coldkey: &T::AccountId, + in_flight: Option>, + ) -> >::Progress { + // Progress is only reusable for the exact coldkey at the current + // cursor. Otherwise start a fresh provider sample. + match in_flight { + Some(p) if &p.coldkey == coldkey => p.progress, + _ => >::Progress::default(), + } + } + + fn skip_missing_sample(cursor: u32) -> Weight { + // A coldkey can disappear from storage while it is still present + // in the fixed cycle snapshot. Skip it and let the next cycle + // rebuild without it. + EmaSamplerState::::put((cursor.saturating_add(1), None::>)); + T::DbWeight::get().writes(1) + } + + fn store_progress( + cursor: u32, + coldkey: T::AccountId, + progress: >::Progress, + ) -> Weight { + EmaSamplerState::::put((cursor, Some(InFlightEmaSample { coldkey, progress }))); + T::DbWeight::get().writes(1) + } + + fn complete_sample(cursor: u32, coldkey: T::AccountId, sample: U64F64) -> Weight { + RootRegisteredEma::::mutate(&coldkey, |state| { + *state = EmaState { + ema: blend(sample, *state), + samples: state.samples.saturating_add(1), + }; + }); + EmaSamplerState::::put((cursor.saturating_add(1), None::>)); + T::DbWeight::get().reads_writes(1, 2) + } + + /// Seeds a fresh EMA slot at zero. The zero value enforces a + /// warmup window before the EMA carries meaningful weight. + pub(crate) fn init_root_registered_ema(coldkey: &T::AccountId) { + RootRegisteredEma::::insert(coldkey, EmaState::default()); + } + + pub(crate) fn clear_root_registered_ema(coldkey: &T::AccountId) { + RootRegisteredEma::::remove(coldkey); + EmaSamplerState::::mutate(|(_, progress)| { + if progress + .as_ref() + .is_some_and(|in_flight| &in_flight.coldkey == coldkey) + { + *progress = None; + } + }); + } +} + +fn blend(sample: U64F64, previous: EmaState) -> U64F64 { + let alpha = U64F64::saturating_from_num(EMA_ALPHA_NUM) + .saturating_div(U64F64::saturating_from_num(EMA_ALPHA_DEN)); + let one_minus_alpha = U64F64::saturating_from_num(1).saturating_sub(alpha); + alpha + .saturating_mul(sample) + .saturating_add(one_minus_alpha.saturating_mul(previous.ema)) +} diff --git a/pallets/subtensor/src/root_registered/mod.rs b/pallets/subtensor/src/root_registered/mod.rs new file mode 100644 index 0000000000..7a488d91dc --- /dev/null +++ b/pallets/subtensor/src/root_registered/mod.rs @@ -0,0 +1,121 @@ +use super::*; +use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use frame_support::{pallet_prelude::Parameter, weights::Weight}; +use scale_info::TypeInfo; +use substrate_fixed::types::U64F64; + +pub mod ema; +pub mod ref_count; +#[cfg(any(feature = "try-runtime", test))] +pub mod try_state; + +/// Per-coldkey EMA state. +#[freeze_struct("f4bb10f7c2fb2cc1")] +#[derive( + Clone, + Copy, + Default, + PartialEq, + Eq, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, +)] +pub struct EmaState { + /// Current EMA value. + pub ema: U64F64, + /// Samples folded in so far. + pub samples: u32, +} + +/// In-flight EMA sample for the coldkey at the current cursor. +/// The provider owns the inner progress shape; the root-registered EMA +/// engine only ties it to the coldkey being sampled. +#[freeze_struct("f9307bf115ed1bae")] +#[derive( + Clone, PartialEq, Eq, Debug, Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo, +)] +pub struct InFlightEmaSample { + /// Coldkey whose sample is in progress. Used to discard stale + /// progress if the cursor moves or the account leaves mid-sample. + pub coldkey: AccountId, + /// Provider-owned progress for the current sample. + pub progress: Progress, +} + +/// Result of one provider sampling step. +pub enum SampleStep { + /// More work remains for this coldkey; persist `progress` and resume + /// on a later tick. + Continue { progress: Progress }, + /// The current sample is complete and ready to be folded into the EMA. + Complete { sample: U64F64 }, +} + +/// Provides the raw sample value over which the root-registered EMA is +/// computed. The EMA engine owns blending and sample counters; providers +/// only own how to incrementally measure one current value. +pub trait EmaValueProvider { + /// Opaque in-flight progress for a single sample. + type Progress: Parameter + MaxEncodedLen + Default; + + /// Process one chunk of work for `coldkey`. + fn step(coldkey: &AccountId, progress: Self::Progress) -> (SampleStep, Weight); + + /// Worst-case weight of `step`. + fn step_weight() -> Weight; +} + +/// Zero-valued provider for runtimes / test mocks that do not compute EMAs. +impl EmaValueProvider for () { + type Progress = (); + + fn step(_: &AccountId, _: Self::Progress) -> (SampleStep, Weight) { + let sample = U64F64::saturating_from_num(0u64); + (SampleStep::Complete { sample }, Weight::zero()) + } + + fn step_weight() -> Weight { + Weight::zero() + } +} + +/// Hook for coldkey root-registration transitions. Callers accrue +/// `on_added_weight` / `on_removed_weight` when a 0↔1 transition is +/// possible. +pub trait OnRootRegistrationChange { + /// Called when `coldkey` enters the root-registered set. + fn on_added(coldkey: &AccountId); + /// Called when `coldkey` leaves the root-registered set. + fn on_removed(coldkey: &AccountId); + /// Worst-case weight of `on_added`. + fn on_added_weight() -> Weight; + /// Worst-case weight of `on_removed`. + fn on_removed_weight() -> Weight; +} + +impl OnRootRegistrationChange for () { + fn on_added(_: &AccountId) {} + fn on_removed(_: &AccountId) {} + fn on_added_weight() -> Weight { + Weight::zero() + } + fn on_removed_weight() -> Weight { + Weight::zero() + } +} + +/// Snapshot of the root-registered coldkey set. +pub trait RootRegisteredInspector { + /// Returns the current snapshot, or `None` if unavailable. + fn members() -> Option>; +} + +impl RootRegisteredInspector for () { + fn members() -> Option> { + None + } +} diff --git a/pallets/subtensor/src/root_registered/ref_count.rs b/pallets/subtensor/src/root_registered/ref_count.rs new file mode 100644 index 0000000000..c5b85e0f90 --- /dev/null +++ b/pallets/subtensor/src/root_registered/ref_count.rs @@ -0,0 +1,31 @@ +use super::*; +use crate::root_registered::OnRootRegistrationChange; + +impl Pallet { + pub fn coldkey_has_root_hotkey(coldkey: &T::AccountId) -> bool { + RootRegisteredHotkeyCount::::get(coldkey) > 0 + } + + pub fn increment_root_registered_hotkey_count(coldkey: &T::AccountId) { + let was_zero = RootRegisteredHotkeyCount::::get(coldkey) == 0; + RootRegisteredHotkeyCount::::mutate(coldkey, |c| *c = c.saturating_add(1)); + if was_zero { + Self::init_root_registered_ema(coldkey); + T::OnRootRegistrationChange::on_added(coldkey); + } + } + + pub fn decrement_root_registered_hotkey_count(coldkey: &T::AccountId) { + let mut became_zero = false; + RootRegisteredHotkeyCount::::mutate_exists(coldkey, |c| { + let prev = c.unwrap_or(0); + let next = prev.saturating_sub(1); + became_zero = prev > 0 && next == 0; + *c = if next == 0 { None } else { Some(next) }; + }); + if became_zero { + Self::clear_root_registered_ema(coldkey); + T::OnRootRegistrationChange::on_removed(coldkey); + } + } +} diff --git a/pallets/subtensor/src/root_registered/try_state.rs b/pallets/subtensor/src/root_registered/try_state.rs new file mode 100644 index 0000000000..7555418059 --- /dev/null +++ b/pallets/subtensor/src/root_registered/try_state.rs @@ -0,0 +1,66 @@ +use alloc::collections::{BTreeMap, BTreeSet}; + +use super::*; +use subtensor_runtime_common::NetUid; + +impl Pallet { + /// Stored per-coldkey count equals the actual number of owned hotkeys registered on root. + pub(crate) fn check_root_registered_hotkey_count() -> Result<(), sp_runtime::TryRuntimeError> { + let mut expected: BTreeMap = BTreeMap::new(); + for (_uid, hotkey) in Keys::::iter_prefix(NetUid::ROOT) { + let owner = Owner::::get(&hotkey); + expected + .entry(owner) + .and_modify(|c| *c = c.saturating_add(1)) + .or_insert(1); + } + + for (coldkey, stored) in RootRegisteredHotkeyCount::::iter() { + let expected_count = expected.remove(&coldkey).unwrap_or(0); + ensure!( + stored == expected_count, + "RootRegisteredHotkeyCount mismatch for coldkey", + ); + } + + ensure!( + expected.is_empty(), + "RootRegisteredHotkeyCount missing entries for coldkeys with root hotkeys", + ); + + Ok(()) + } + + /// External inspector's coldkey set matches `RootRegisteredHotkeyCount`; skipped when unwired. + pub(crate) fn check_root_registered_matches_inspector() + -> Result<(), sp_runtime::TryRuntimeError> { + let Some(actual_members) = T::RootRegisteredInspector::members() else { + return Ok(()); + }; + let actual: BTreeSet = actual_members.into_iter().collect(); + let expected: BTreeSet = RootRegisteredHotkeyCount::::iter() + .map(|(coldkey, _)| coldkey) + .collect(); + ensure!( + actual == expected, + "RootRegisteredInspector members do not match root-registered coldkey set", + ); + Ok(()) + } + + /// `RootRegisteredEma` and `RootRegisteredHotkeyCount` always share the same key set. + #[cfg_attr(test, allow(dead_code))] + pub(crate) fn check_root_registered_ema_matches_count() + -> Result<(), sp_runtime::TryRuntimeError> { + let ema_keys: BTreeSet = + RootRegisteredEma::::iter().map(|(c, _)| c).collect(); + let count_keys: BTreeSet = RootRegisteredHotkeyCount::::iter() + .map(|(c, _)| c) + .collect(); + ensure!( + ema_keys == count_keys, + "RootRegisteredEma keys do not match RootRegisteredHotkeyCount keys", + ); + Ok(()) + } +} diff --git a/pallets/subtensor/src/subnets/uids.rs b/pallets/subtensor/src/subnets/uids.rs index 3665b139ff..768b5971c5 100644 --- a/pallets/subtensor/src/subnets/uids.rs +++ b/pallets/subtensor/src/subnets/uids.rs @@ -200,6 +200,10 @@ impl Pallet { // Remove hotkey related storage items if hotkey exists if let Ok(hotkey) = Keys::::try_get(netuid, neuron_uid) { + if netuid == NetUid::ROOT { + let owner = Owner::::get(&hotkey); + Self::decrement_root_registered_hotkey_count(&owner); + } Uids::::remove(netuid, &hotkey); IsNetworkMember::::remove(&hotkey, netuid); LastHotkeyEmissionOnNetuid::::remove(&hotkey, netuid); diff --git a/pallets/subtensor/src/swap/swap_coldkey.rs b/pallets/subtensor/src/swap/swap_coldkey.rs index 7b1b4d838c..0b4d9b19de 100644 --- a/pallets/subtensor/src/swap/swap_coldkey.rs +++ b/pallets/subtensor/src/swap/swap_coldkey.rs @@ -159,6 +159,10 @@ impl Pallet { let old_owned_hotkeys: Vec = OwnedHotkeys::::get(old_coldkey); let mut new_owned_hotkeys: Vec = OwnedHotkeys::::get(new_coldkey); for owned_hotkey in old_owned_hotkeys.iter() { + if Uids::::contains_key(NetUid::ROOT, owned_hotkey) { + Self::decrement_root_registered_hotkey_count(old_coldkey); + Self::increment_root_registered_hotkey_count(new_coldkey); + } // Remove the hotkey from the old coldkey. Owner::::remove(owned_hotkey); // Add the hotkey to the new coldkey. diff --git a/pallets/subtensor/src/tests/coinbase.rs b/pallets/subtensor/src/tests/coinbase.rs index 0e3e8038ff..1d2f0b12d9 100644 --- a/pallets/subtensor/src/tests/coinbase.rs +++ b/pallets/subtensor/src/tests/coinbase.rs @@ -4378,3 +4378,303 @@ fn test_reveal_crv3_defers_with_capped_epoch() { ); }); } + +fn ref_count(coldkey: &U256) -> u32 { + RootRegisteredHotkeyCount::::get(coldkey) +} + +fn root_register_with_stake(coldkey: &U256, hotkey: &U256, alpha_netuid: NetUid) { + register_ok_neuron(alpha_netuid, *hotkey, *coldkey, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + coldkey, + NetUid::ROOT, + AlphaBalance::from(1_000_000_000), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(*coldkey), + *hotkey, + )); +} + +#[test] +fn root_register_increments_ref_count_for_new_coldkey() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + let hotkey = U256::from(11); + + assert_eq!(ref_count(&coldkey), 0); + assert!(!SubtensorModule::coldkey_has_root_hotkey(&coldkey)); + + root_register_with_stake(&coldkey, &hotkey, alpha); + + assert_eq!(ref_count(&coldkey), 1); + assert!(SubtensorModule::coldkey_has_root_hotkey(&coldkey)); + }); +} + +#[test] +fn root_register_accumulates_ref_count_for_same_coldkey() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + let h1 = U256::from(11); + let h2 = U256::from(12); + let h3 = U256::from(13); + + root_register_with_stake(&coldkey, &h1, alpha); + root_register_with_stake(&coldkey, &h2, alpha); + root_register_with_stake(&coldkey, &h3, alpha); + + assert_eq!(ref_count(&coldkey), 3); + assert!(SubtensorModule::coldkey_has_root_hotkey(&coldkey)); + }); +} + +#[test] +fn root_register_replace_path_shifts_ref_count_to_new_coldkey() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + // Cap the root subnet at 1 so the second registration follows the + // replace path rather than the append path. + MaxAllowedUids::::set(NetUid::ROOT, 1); + + let cold_old = U256::from(10); + let hot_old = U256::from(11); + register_ok_neuron(alpha, hot_old, cold_old, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &hot_old, + &cold_old, + NetUid::ROOT, + AlphaBalance::from(1_000_000_000), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(cold_old), + hot_old, + )); + assert_eq!(ref_count(&cold_old), 1); + + // Higher-stake new entrant displaces hot_old. + let cold_new = U256::from(20); + let hot_new = U256::from(21); + register_ok_neuron(alpha, hot_new, cold_new, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &hot_new, + &cold_new, + NetUid::ROOT, + AlphaBalance::from(10_000_000_000_u64), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(cold_new), + hot_new, + )); + + assert_eq!(ref_count(&cold_old), 0); + assert_eq!(ref_count(&cold_new), 1); + assert!(!SubtensorModule::coldkey_has_root_hotkey(&cold_old)); + assert!(SubtensorModule::coldkey_has_root_hotkey(&cold_new)); + }); +} + +#[test] +fn root_register_replace_with_same_coldkey_keeps_ref_count_stable() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + // Same coldkey registers two hotkeys in a capacity-1 root subnet: + // the second registration goes through the replace path. The + // counter should land back at 1, not 0 or 2. + MaxAllowedUids::::set(NetUid::ROOT, 1); + + let coldkey = U256::from(10); + let hot1 = U256::from(11); + let hot2 = U256::from(12); + + register_ok_neuron(alpha, hot1, coldkey, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &hot1, + &coldkey, + NetUid::ROOT, + AlphaBalance::from(1_000_000_000), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(coldkey), + hot1, + )); + + register_ok_neuron(alpha, hot2, coldkey, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &hot2, + &coldkey, + NetUid::ROOT, + AlphaBalance::from(10_000_000_000_u64), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(coldkey), + hot2, + )); + + assert_eq!(ref_count(&coldkey), 1); + }); +} + +#[test] +fn trim_root_decrements_ref_count_for_evicted_hotkeys() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + // The trim must satisfy `max_n >= MinAllowedUids`. Setting the + // immunity period to zero stops the freshly-registered neurons + // from counting against the immune-percentage cap. + MinAllowedUids::::set(NetUid::ROOT, 1); + MaxAllowedUids::::set(NetUid::ROOT, 2); + ImmunityPeriod::::set(NetUid::ROOT, 0); + + // Two distinct coldkeys, each with one root-registered hotkey. The + // trim drops the lowest-emitter UID, which we force to be `hot_b` + // by giving `hot_a` the higher emission. + let cold_a = U256::from(10); + let hot_a = U256::from(11); + let cold_b = U256::from(20); + let hot_b = U256::from(21); + + root_register_with_stake(&cold_a, &hot_a, alpha); + root_register_with_stake(&cold_b, &hot_b, alpha); + assert_eq!(ref_count(&cold_a), 1); + assert_eq!(ref_count(&cold_b), 1); + + let uid_a = SubtensorModule::get_uid_for_net_and_hotkey(NetUid::ROOT, &hot_a) + .expect("hot_a registered"); + let uid_b = SubtensorModule::get_uid_for_net_and_hotkey(NetUid::ROOT, &hot_b) + .expect("hot_b registered"); + Emission::::mutate(NetUid::ROOT, |v| { + v[uid_a as usize] = AlphaBalance::from(100); + v[uid_b as usize] = AlphaBalance::from(1); + }); + + assert_ok!(SubtensorModule::trim_to_max_allowed_uids(NetUid::ROOT, 1)); + + assert!(!RootRegisteredHotkeyCount::::contains_key(cold_b)); + assert_eq!(ref_count(&cold_a), 1); + }); +} + +#[test] +fn root_register_fires_on_added_for_fresh_coldkey() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + let _ = take_root_registration_log(); + + root_register_with_stake(&coldkey, &U256::from(11), alpha); + assert_eq!( + take_root_registration_log(), + vec![RootRegistrationChange::Added(coldkey)] + ); + + // Second root hotkey under the same coldkey: the ref count goes + // 1→2, no membership edge to report. + root_register_with_stake(&coldkey, &U256::from(12), alpha); + assert!(take_root_registration_log().is_empty()); + }); +} + +#[test] +fn root_register_replace_fires_removed_and_added_when_owners_differ() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + MaxAllowedUids::::set(NetUid::ROOT, 1); + + let outgoing = U256::from(10); + let incoming = U256::from(20); + root_register_with_stake(&outgoing, &U256::from(11), alpha); + let _ = take_root_registration_log(); + + // Replacement path: incoming coldkey displaces the outgoing one. + let h2 = U256::from(21); + register_ok_neuron(alpha, h2, incoming, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &h2, + &incoming, + NetUid::ROOT, + AlphaBalance::from(10_000_000_000_u64), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(incoming), + h2, + )); + + assert_eq!( + take_root_registration_log(), + vec![ + RootRegistrationChange::Removed(outgoing), + RootRegistrationChange::Added(incoming), + ] + ); + }); +} + +#[test] +fn trim_to_max_allowed_uids_fires_removed_for_evicted_coldkey() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + let cold1 = U256::from(10); + let cold2 = U256::from(20); + root_register_with_stake(&cold1, &U256::from(11), alpha); + root_register_with_stake(&cold2, &U256::from(21), alpha); + let _ = take_root_registration_log(); + + // Lifts the immunity guard so trim can pick a fresh UID; `MinAllowedUids` + // is dropped to 1 (the floor `trim_to_max_allowed_uids` honors) so the + // call doesn't bounce on the lower bound either. + ImmunityPeriod::::set(NetUid::ROOT, 0); + MinAllowedUids::::set(NetUid::ROOT, 1); + + assert_ok!(SubtensorModule::trim_to_max_allowed_uids(NetUid::ROOT, 1)); + + // Exactly one of the two coldkeys was evicted; the corresponding + // Removed must fire and no spurious events should appear. + let log = take_root_registration_log(); + let removed: Vec<_> = log + .iter() + .filter_map(|c| match c { + RootRegistrationChange::Removed(c) => Some(*c), + _ => None, + }) + .collect(); + assert_eq!( + removed.len(), + 1, + "one Removed per evicted coldkey, got {log:?}" + ); + assert!(removed[0] == cold1 || removed[0] == cold2); + }); +} diff --git a/pallets/subtensor/src/tests/migration.rs b/pallets/subtensor/src/tests/migration.rs index ec24697522..efffe2934f 100644 --- a/pallets/subtensor/src/tests/migration.rs +++ b/pallets/subtensor/src/tests/migration.rs @@ -4934,3 +4934,51 @@ fn test_migrate_dynamic_tempo_idempotent() { ); }); } + +#[test] +fn test_migrate_init_root_registered_hotkey_count_backfills_counts_and_fires_hooks() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + // Two hotkeys under cold1, one under cold2: the migration must + // reconstruct counts {cold1: 2, cold2: 1} and fire exactly one + // `on_added` per distinct coldkey. + let cold1 = U256::from(10); + let cold2 = U256::from(20); + root_register_with_stake(&cold1, &U256::from(11), alpha); + root_register_with_stake(&cold1, &U256::from(12), alpha); + root_register_with_stake(&cold2, &U256::from(21), alpha); + + // Simulate pre-migration state: `Keys[ROOT]` populated, reverse + // index empty, and the hook log clean. + let _ = RootRegisteredHotkeyCount::::clear(u32::MAX, None); + let _ = take_root_registration_log(); + + crate::migrations::migrate_init_root_registered_hotkey_count::migrate_init_root_registered_hotkey_count::(); + + // Counts reconstructed. + assert_eq!(RootRegisteredHotkeyCount::::get(cold1), 2); + assert_eq!(RootRegisteredHotkeyCount::::get(cold2), 1); + assert!(HasMigrationRun::::get( + b"migrate_init_root_registered_hotkey_count".to_vec() + )); + + // One Added per distinct coldkey, regardless of hotkey count. + let log = take_root_registration_log(); + let added: Vec<_> = log + .iter() + .filter_map(|c| match c { + RootRegistrationChange::Added(c) => Some(*c), + _ => None, + }) + .collect(); + assert_eq!(added.len(), 2, "one Added per distinct coldkey, got {log:?}"); + assert!(added.contains(&cold1)); + assert!(added.contains(&cold2)); + }); +} diff --git a/pallets/subtensor/src/tests/mock.rs b/pallets/subtensor/src/tests/mock.rs index 0e3347ec89..e8af71da23 100644 --- a/pallets/subtensor/src/tests/mock.rs +++ b/pallets/subtensor/src/tests/mock.rs @@ -6,6 +6,9 @@ use core::num::NonZeroU64; +use crate::root_registered::{ + EmaValueProvider, OnRootRegistrationChange, RootRegisteredInspector, SampleStep, +}; use crate::utils::rate_limiting::TransactionType; use crate::*; pub use frame_support::traits::Imbalance; @@ -182,6 +185,169 @@ impl AuthorshipInfo for MockAuthorshipProvider { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RootRegistrationChange { + Added(U256), + Removed(U256), +} + +thread_local! { + static ROOT_REGISTRATION_LOG: core::cell::RefCell> = + const { core::cell::RefCell::new(Vec::new()) }; +} + +pub fn take_root_registration_log() -> Vec { + ROOT_REGISTRATION_LOG.with(|log| log.borrow_mut().drain(..).collect()) +} + +pub struct MockOnRootRegistrationChange; + +impl OnRootRegistrationChange for MockOnRootRegistrationChange { + fn on_added(coldkey: &U256) { + ROOT_REGISTRATION_LOG.with(|log| { + log.borrow_mut() + .push(RootRegistrationChange::Added(*coldkey)) + }); + } + fn on_removed(coldkey: &U256) { + ROOT_REGISTRATION_LOG.with(|log| { + log.borrow_mut() + .push(RootRegistrationChange::Removed(*coldkey)) + }); + } + fn on_added_weight() -> Weight { + Weight::zero() + } + fn on_removed_weight() -> Weight { + Weight::zero() + } +} + +thread_local! { + static MOCK_ROOT_REGISTERED_INSPECTOR_MEMBERS: core::cell::RefCell>> = + const { core::cell::RefCell::new(None) }; +} + +/// Override the membership exposed by `MockRootRegisteredInspector` to +/// `pallet_subtensor`'s try_state check. `None` (the default) makes +/// the check a no-op; `Some(_)` opts the test in. +pub fn set_mock_root_registered_inspector_members(members: Option>) { + MOCK_ROOT_REGISTERED_INSPECTOR_MEMBERS.with(|m| *m.borrow_mut() = members); +} + +pub struct MockRootRegisteredInspector; + +impl RootRegisteredInspector for MockRootRegisteredInspector { + fn members() -> Option> { + MOCK_ROOT_REGISTERED_INSPECTOR_MEMBERS.with(|m| m.borrow().clone()) + } +} + +thread_local! { + static EMA_VALUE_PROVIDER_LOG: RefCell> = + const { RefCell::new(Vec::new()) }; +} + +pub fn take_ema_value_provider_log() -> Vec<(U256, U64F64)> { + EMA_VALUE_PROVIDER_LOG.with(|log| log.borrow_mut().drain(..).collect()) +} + +/// Define a thread-local whose value can be temporarily replaced via an +/// RAII guard. The previous value is restored when the guard drops, so +/// tests do not need to manually undo their setup (and inherit nothing +/// from a panicking neighbor). +macro_rules! define_scoped_state { + ($flag:ident, $guard:ident, $reader:ident, $ty:ty, $default:expr) => { + thread_local! { + static $flag: RefCell<$ty> = const { RefCell::new($default) }; + } + + #[must_use = "the guard restores the prior value on drop; bind it to a local"] + pub struct $guard { + previous: Option<$ty>, + } + + impl $guard { + pub fn new(value: $ty) -> Self { + let previous = + Some($flag.with(|r| core::mem::replace(&mut *r.borrow_mut(), value))); + Self { previous } + } + } + + impl Drop for $guard { + fn drop(&mut self) { + if let Some(prev) = self.previous.take() { + $flag.with(|r| *r.borrow_mut() = prev); + } + } + } + + fn $reader() -> $ty { + $flag.with(|r| r.borrow().clone()) + } + }; +} + +define_scoped_state!( + EMA_VALUE_PROVIDER_STEP, + EmaValueProviderStepGuard, + ema_value_provider_step, + Option (SampleStep, Weight)>, + None +); +define_scoped_state!( + EMA_VALUE_PROVIDER_STEP_WEIGHT, + EmaValueProviderStepWeightGuard, + ema_value_provider_step_weight, + Weight, + Weight::zero() +); + +#[freeze_struct("79e67cd33ad5c63b")] +#[derive( + Clone, + Copy, + Default, + PartialEq, + Eq, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, +)] +pub struct MockEmaProgress { + pub offset: u32, + pub partial: u128, +} + +pub struct MockEmaValueProvider; + +impl EmaValueProvider for MockEmaValueProvider { + type Progress = MockEmaProgress; + + fn step(coldkey: &U256, progress: Self::Progress) -> (SampleStep, Weight) { + let (step, weight) = match ema_value_provider_step() { + Some(f) => f(*coldkey, progress), + None => ( + SampleStep::Complete { + sample: U64F64::from_num(0u64), + }, + ema_value_provider_step_weight(), + ), + }; + EMA_VALUE_PROVIDER_LOG + .with(|log| log.borrow_mut().push((*coldkey, U64F64::from_num(0u64)))); + (step, weight) + } + + fn step_weight() -> Weight { + ema_value_provider_step_weight() + } +} + parameter_types! { pub const InitialMinAllowedWeights: u16 = 0; pub const InitialEmissionValue: u16 = 0; @@ -346,6 +512,9 @@ impl crate::Config for Test { type AlphaAssets = AlphaAssets; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; + type OnRootRegistrationChange = MockOnRootRegistrationChange; + type RootRegisteredInspector = MockRootRegisteredInspector; + type EmaValueProvider = MockEmaValueProvider; type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type InitialMaxEpochsPerBlock = MaxEpochsPerBlock; @@ -391,7 +560,6 @@ parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; pub const MaxScheduledPerBlock: u32 = 50; - pub const NoPreimagePostponement: Option = Some(10); } impl pallet_scheduler::Config for Test { @@ -1188,3 +1356,17 @@ pub fn remove_owner_registration_stake(netuid: NetUid) { AlphaBalance::ZERO ); } + +pub fn root_register_with_stake(coldkey: &U256, hotkey: &U256, alpha_netuid: NetUid) { + register_ok_neuron(alpha_netuid, *hotkey, *coldkey, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + coldkey, + NetUid::ROOT, + AlphaBalance::from(1_000_000_000), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(*coldkey), + *hotkey, + )); +} diff --git a/pallets/subtensor/src/tests/mock_high_ed.rs b/pallets/subtensor/src/tests/mock_high_ed.rs index 6f5c69a89c..a84249dec1 100644 --- a/pallets/subtensor/src/tests/mock_high_ed.rs +++ b/pallets/subtensor/src/tests/mock_high_ed.rs @@ -306,6 +306,9 @@ impl crate::Config for Test { type AlphaAssets = AlphaAssets; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; + type OnRootRegistrationChange = (); + type RootRegisteredInspector = (); + type EmaValueProvider = (); type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type InitialMaxEpochsPerBlock = MaxEpochsPerBlock; diff --git a/pallets/subtensor/src/tests/mod.rs b/pallets/subtensor/src/tests/mod.rs index 2a9f71e021..246bab8731 100644 --- a/pallets/subtensor/src/tests/mod.rs +++ b/pallets/subtensor/src/tests/mod.rs @@ -22,6 +22,7 @@ mod networks; mod neuron_info; mod recycle_alpha; mod registration; +mod root_registered; mod serving; mod staking; mod staking2; diff --git a/pallets/subtensor/src/tests/root_registered.rs b/pallets/subtensor/src/tests/root_registered.rs new file mode 100644 index 0000000000..d3cc10a148 --- /dev/null +++ b/pallets/subtensor/src/tests/root_registered.rs @@ -0,0 +1,708 @@ +#![allow( + clippy::indexing_slicing, + clippy::unwrap_used, + clippy::expect_used, + clippy::arithmetic_side_effects +)] + +use super::mock::*; +use crate::root_registered::{EmaState, InFlightEmaSample, SampleStep}; +use crate::*; +use frame_support::assert_ok; +use frame_support::weights::Weight; +use sp_core::U256; +use substrate_fixed::types::U64F64; +use subtensor_runtime_common::{AlphaBalance, NetUid}; + +fn ref_count(coldkey: &U256) -> u32 { + RootRegisteredHotkeyCount::::get(coldkey) +} + +#[test] +fn ref_count_helpers_basic_behavior() { + new_test_ext(1).execute_with(|| { + let coldkey = U256::from(7); + + // Reader on an unset key. + assert_eq!(ref_count(&coldkey), 0); + assert!(!SubtensorModule::coldkey_has_root_hotkey(&coldkey)); + + // Saturating decrement at zero must not underflow. + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert_eq!(ref_count(&coldkey), 0); + assert!(!RootRegisteredHotkeyCount::::contains_key(coldkey)); + + // Increment populates storage and flips the reader. + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + assert!(RootRegisteredHotkeyCount::::contains_key(coldkey)); + assert!(SubtensorModule::coldkey_has_root_hotkey(&coldkey)); + + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + assert_eq!(ref_count(&coldkey), 2); + + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert_eq!(ref_count(&coldkey), 1); + + // Decrement to zero removes the storage entry. + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert!(!RootRegisteredHotkeyCount::::contains_key(coldkey)); + + // Saturating decrement on an absent key must not resurrect the entry. + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert!(!RootRegisteredHotkeyCount::::contains_key(coldkey)); + }); +} + +#[test] +fn ref_count_increment_fires_added_hook_only_on_zero_to_one_transition() { + new_test_ext(1).execute_with(|| { + let coldkey = U256::from(10); + let _ = take_root_registration_log(); + + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + assert_eq!( + take_root_registration_log(), + vec![RootRegistrationChange::Added(coldkey)] + ); + + // Subsequent increments stay above zero and must not re-fire. + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + assert!(take_root_registration_log().is_empty()); + }); +} + +#[test] +fn ref_count_decrement_fires_removed_hook_only_on_one_to_zero_transition() { + new_test_ext(1).execute_with(|| { + let coldkey = U256::from(10); + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + let _ = take_root_registration_log(); + + // Above-zero decrements are silent. + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert!(take_root_registration_log().is_empty()); + + // The 1→0 edge fires once. + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert_eq!( + take_root_registration_log(), + vec![RootRegistrationChange::Removed(coldkey)] + ); + + // Decrementing a zero count must not fire a spurious `Removed`. + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert!(take_root_registration_log().is_empty()); + }); +} + +#[test] +fn ref_count_invariant_holds_across_mutations() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + // Lift the per-block / per-interval registration caps so the test + // can register five hotkeys without stepping blocks. + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + assert_ok!(SubtensorModule::check_root_registered_hotkey_count()); + + let cold1 = U256::from(10); + let cold2 = U256::from(20); + let cold3 = U256::from(30); + let h1 = U256::from(11); + let h2 = U256::from(12); + let h3 = U256::from(21); + let h4 = U256::from(31); + + // Mix of registrations across multiple coldkeys. + root_register_with_stake(&cold1, &h1, alpha); + root_register_with_stake(&cold1, &h2, alpha); + root_register_with_stake(&cold2, &h3, alpha); + root_register_with_stake(&cold3, &h4, alpha); + assert_ok!(SubtensorModule::check_root_registered_hotkey_count()); + + // Replace path through `do_root_register` at the cap. + MaxAllowedUids::::set(NetUid::ROOT, 4); + let cold4 = U256::from(40); + let h5 = U256::from(41); + register_ok_neuron(alpha, h5, cold4, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &h5, + &cold4, + NetUid::ROOT, + AlphaBalance::from(10_000_000_000_u64), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(cold4), + h5, + )); + assert_ok!(SubtensorModule::check_root_registered_hotkey_count()); + + // Coldkey swap moves a multi-hotkey holder's count to a fresh coldkey. + let cold1_new = U256::from(99); + assert_ok!(SubtensorModule::do_swap_coldkey(&cold1, &cold1_new)); + assert_ok!(SubtensorModule::check_root_registered_hotkey_count()); + + // Trim drops the lowest emitter; tightens the invariant under + // bulk removal. + ImmunityPeriod::::set(NetUid::ROOT, 0); + MinAllowedUids::::set(NetUid::ROOT, 1); + assert_ok!(SubtensorModule::trim_to_max_allowed_uids(NetUid::ROOT, 1)); + assert_ok!(SubtensorModule::check_root_registered_hotkey_count()); + }); +} + +#[test] +fn ref_count_invariant_detects_stale_overcount() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + assert_ok!(SubtensorModule::check_root_registered_hotkey_count()); + + // Simulate a buggy code path that incremented the counter without a + // matching root registration. The invariant must surface the drift. + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + assert!(SubtensorModule::check_root_registered_hotkey_count().is_err()); + }); +} + +#[test] +fn ref_count_invariant_detects_missing_index_entry() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + assert_ok!(SubtensorModule::check_root_registered_hotkey_count()); + + // Simulate a buggy path that registered a root hotkey without + // updating the reverse index. The invariant must catch the + // coldkey that now has root hotkeys but no counter entry. + RootRegisteredHotkeyCount::::remove(coldkey); + assert!(SubtensorModule::check_root_registered_hotkey_count().is_err()); + }); +} + +#[test] +fn inspector_invariant_passes_when_members_match_root_registered_coldkeys() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + let cold1 = U256::from(10); + let cold2 = U256::from(20); + // Two hotkeys under cold1, one under cold2: the expected root-registered + // set is the two distinct coldkeys, not three. + root_register_with_stake(&cold1, &U256::from(11), alpha); + root_register_with_stake(&cold1, &U256::from(12), alpha); + root_register_with_stake(&cold2, &U256::from(21), alpha); + + set_mock_root_registered_inspector_members(Some(vec![cold1, cold2])); + assert_ok!(SubtensorModule::check_root_registered_matches_inspector()); + }); +} + +#[test] +fn inspector_invariant_skips_when_inspector_is_unset() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + root_register_with_stake(&U256::from(10), &U256::from(11), alpha); + + // Inspector unset by default: the check must silently no-op even + // when the on-chain root set is non-empty. + set_mock_root_registered_inspector_members(None); + assert_ok!(SubtensorModule::check_root_registered_matches_inspector()); + }); +} + +#[test] +fn inspector_invariant_fails_when_members_differ_from_root_registered_coldkeys() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let cold = U256::from(10); + root_register_with_stake(&cold, &U256::from(11), alpha); + + // Inspector forgot to include the root-registered coldkey. + set_mock_root_registered_inspector_members(Some(vec![])); + assert!(SubtensorModule::check_root_registered_matches_inspector().is_err()); + + // Inspector holds a coldkey that has no root hotkey. + set_mock_root_registered_inspector_members(Some(vec![cold, U256::from(999)])); + assert!(SubtensorModule::check_root_registered_matches_inspector().is_err()); + }); +} + +#[test] +fn ema_count_invariant_passes_when_ema_keys_match_root_registered_coldkeys() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + root_register_with_stake(&U256::from(10), &U256::from(11), alpha); + + assert_ok!(SubtensorModule::check_root_registered_ema_matches_count()); + }); +} + +#[test] +fn ema_count_invariant_detects_missing_ema_entry_for_registered_coldkey() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + RootRegisteredEma::::remove(coldkey); + + assert!(SubtensorModule::check_root_registered_ema_matches_count().is_err()); + }); +} + +#[test] +fn ema_count_invariant_detects_stale_ema_entry_for_unregistered_coldkey() { + new_test_ext(1).execute_with(|| { + let stale = U256::from(99); + RootRegisteredEma::::insert(stale, EmaState::default()); + + assert!(SubtensorModule::check_root_registered_ema_matches_count().is_err()); + }); +} + +#[test] +fn ema_slot_is_initialized_cleared_and_reinitialized_on_reentry() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + assert!(!RootRegisteredEma::::contains_key(coldkey)); + + // First root registration seeds a zero-valued slot. + root_register_with_stake(&coldkey, &U256::from(11), alpha); + let state = RootRegisteredEma::::get(coldkey); + assert_eq!(state.ema, U64F64::from_num(0)); + assert_eq!(state.samples, 0); + + // The default mock provider completes a sample per tick, so two + // ticks land two samples on the only registered coldkey. + SubtensorModule::tick_root_registered_ema(); + SubtensorModule::tick_root_registered_ema(); + assert_eq!(RootRegisteredEma::::get(coldkey).samples, 2); + + // Drop to zero hotkeys: the EMA slot is cleared. + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert!(!RootRegisteredEma::::contains_key(coldkey)); + + // Re-register: state starts fresh. + root_register_with_stake(&coldkey, &U256::from(12), alpha); + let state = RootRegisteredEma::::get(coldkey); + assert_eq!(state.ema, U64F64::from_num(0)); + assert_eq!(state.samples, 0); + }); +} + +#[test] +fn ema_tick_blends_completed_sample_with_fixed_alpha() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + + let _step = EmaValueProviderStepGuard::new(Some(|_, _| { + ( + SampleStep::Complete { + sample: U64F64::from_num(100), + }, + Weight::zero(), + ) + })); + + SubtensorModule::tick_root_registered_ema(); + + let state = RootRegisteredEma::::get(coldkey); + let expected = U64F64::from_num(2) + .saturating_div(U64F64::from_num(100)) + .saturating_mul(U64F64::from_num(100)); + assert_eq!(state.ema, expected); + assert_eq!(state.samples, 1); + }); +} + +#[test] +fn ema_tick_finalizes_samples_and_advances_cursor() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + let cold_a = U256::from(10); + let cold_b = U256::from(20); + root_register_with_stake(&cold_a, &U256::from(11), alpha); + root_register_with_stake(&cold_b, &U256::from(21), alpha); + let _ = take_ema_value_provider_log(); + + // Default mock progress is single-shot; provider returns 42 as + // the raw sample and the pallet blends it into the EMA. + let _step = EmaValueProviderStepGuard::new(Some(|_, _| { + ( + SampleStep::Complete { + sample: U64F64::from_num(42), + }, + Weight::zero(), + ) + })); + + // Two consecutive ticks: each finalizes a distinct member and + // the cursor advances by one per finalize. + assert_eq!(EmaSamplerState::::get().0, 0); + SubtensorModule::tick_root_registered_ema(); + SubtensorModule::tick_root_registered_ema(); + + let log = take_ema_value_provider_log(); + let touched: Vec = log.iter().map(|(k, _)| *k).collect(); + assert_eq!(touched.len(), 2); + assert!(touched.contains(&cold_a) && touched.contains(&cold_b)); + + let state_a = RootRegisteredEma::::get(cold_a); + assert!(state_a.ema > U64F64::from_num(0)); + assert_eq!(state_a.samples, 1); + let state_b = RootRegisteredEma::::get(cold_b); + assert!(state_b.ema > U64F64::from_num(0)); + assert_eq!(state_b.samples, 1); + + // The cursor wraps and rebuilds the snapshot, so a third tick + // revisits one of the members and bumps its counter to 2. + SubtensorModule::tick_root_registered_ema(); + let revisited_samples = RootRegisteredEma::::get(cold_a).samples + + RootRegisteredEma::::get(cold_b).samples; + assert_eq!(revisited_samples, 3); + }); +} + +#[test] +fn ema_tick_is_no_op_when_no_members() { + new_test_ext(1).execute_with(|| { + // No registrations: the rebuild produces an empty snapshot and + // the tick must not touch the cursor or the provider log. + let _ = take_ema_value_provider_log(); + let cursor_before = EmaSamplerState::::get().0; + SubtensorModule::tick_root_registered_ema(); + assert_eq!(EmaSamplerState::::get().0, cursor_before); + assert!(take_ema_value_provider_log().is_empty()); + }); +} + +#[test] +fn ema_tick_returns_weight_including_provider_contribution() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + root_register_with_stake(&U256::from(10), &U256::from(11), alpha); + + // Provider reports a non-zero per-step weight; the tick must + // surface it through its return value so `on_initialize` can + // bill the actual cost. + let _step_weight = EmaValueProviderStepWeightGuard::new(Weight::from_parts(12_345, 0)); + let on_tick = SubtensorModule::tick_root_registered_ema(); + assert!( + on_tick.ref_time() >= 12_345, + "tick weight must include provider contribution, got {on_tick:?}" + ); + }); +} + +#[test] +fn ema_tick_default_provider_advances_sample_count_without_changing_zero_ema() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + + // No guards: MockEmaValueProvider's default step is single-shot done + // with no contribution; finalize returns `previous.ema`. The EMA + // stays at the init value (0) but the sample counter advances. + let _ = take_ema_value_provider_log(); + SubtensorModule::tick_root_registered_ema(); + + let state = RootRegisteredEma::::get(coldkey); + assert_eq!(state.ema, U64F64::from_num(0)); + assert_eq!(state.samples, 1); + }); +} + +#[test] +fn ema_tick_persists_provider_progress_until_sample_completes() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + + // Step adds 100 per call and signals done only when offset + // reaches 3 (i.e. after three chunks). + let _step = EmaValueProviderStepGuard::new(Some(|_, mut progress| { + progress.offset = progress.offset.saturating_add(1); + progress.partial = progress.partial.saturating_add(100); + if progress.offset >= 3 { + ( + SampleStep::Complete { + sample: U64F64::from_num(progress.partial as u64), + }, + Weight::zero(), + ) + } else { + (SampleStep::Continue { progress }, Weight::zero()) + } + })); + + // First two ticks accumulate partial state without finalizing. + SubtensorModule::tick_root_registered_ema(); + let (cursor, progress) = EmaSamplerState::::get(); + assert_eq!(cursor, 0); + let in_flight = progress.expect("mid-sample progress must be Some"); + assert_eq!(in_flight.progress.offset, 1); + assert_eq!(in_flight.progress.partial, 100); + assert_eq!(RootRegisteredEma::::get(coldkey).samples, 0); + + SubtensorModule::tick_root_registered_ema(); + let (cursor, progress) = EmaSamplerState::::get(); + assert_eq!(cursor, 0); + let in_flight = progress.expect("mid-sample progress must be Some"); + assert_eq!(in_flight.progress.offset, 2); + assert_eq!(in_flight.progress.partial, 200); + assert_eq!(RootRegisteredEma::::get(coldkey).samples, 0); + + // Third tick finalizes: the accumulated 300 sample is blended + // into the EMA, sample counter increments, progress resets, and + // cursor advances. + SubtensorModule::tick_root_registered_ema(); + let ema = RootRegisteredEma::::get(coldkey); + assert!(ema.ema > U64F64::from_num(0)); + assert!(ema.ema < U64F64::from_num(300u64)); + assert_eq!(ema.samples, 1); + let (cursor, progress) = EmaSamplerState::::get(); + assert_eq!(cursor, 1); + assert!(progress.is_none()); + }); +} + +#[test] +fn ema_in_flight_progress_is_cleared_when_sampled_coldkey_leaves() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + + let _step = EmaValueProviderStepGuard::new(Some(|_, mut progress| { + progress.offset = progress.offset.saturating_add(1); + progress.partial = progress.partial.saturating_add(100); + (SampleStep::Continue { progress }, Weight::zero()) + })); + + SubtensorModule::tick_root_registered_ema(); + assert!(EmaSamplerState::::get().1.is_some()); + + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert!( + EmaSamplerState::::get().1.is_none(), + "leaving the root-registered set must clear stale in-flight EMA progress" + ); + + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + SubtensorModule::tick_root_registered_ema(); + let (_, progress) = EmaSamplerState::::get(); + let progress = progress.expect("fresh re-entry starts a new in-flight sample"); + assert_eq!(progress.progress.offset, 1); + assert_eq!(progress.progress.partial, 100); + }); +} + +#[test] +fn ema_in_flight_progress_survives_when_different_coldkey_leaves() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + let cold_a = U256::from(10); + let cold_b = U256::from(20); + root_register_with_stake(&cold_a, &U256::from(11), alpha); + root_register_with_stake(&cold_b, &U256::from(21), alpha); + + let _step = EmaValueProviderStepGuard::new(Some(|_, mut progress| { + progress.offset = progress.offset.saturating_add(1); + progress.partial = progress.partial.saturating_add(100); + (SampleStep::Continue { progress }, Weight::zero()) + })); + + SubtensorModule::tick_root_registered_ema(); + let (_, progress) = EmaSamplerState::::get(); + let in_flight = progress.expect("first tick must start an in-flight sample"); + let sampled = in_flight.coldkey; + let other = if sampled == cold_a { cold_b } else { cold_a }; + + SubtensorModule::decrement_root_registered_hotkey_count(&other); + + let (_, progress) = EmaSamplerState::::get(); + let progress = progress.expect("unrelated coldkey removal must not clear progress"); + assert_eq!(progress.coldkey, sampled); + assert_eq!(progress.progress.offset, 1); + assert_eq!(progress.progress.partial, 100); + }); +} + +#[test] +fn ema_tick_discards_stale_in_flight_progress_for_wrong_coldkey() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + let stale_coldkey = U256::from(20); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + + CurrentCycleMembers::::put( + BoundedVec::try_from(vec![coldkey]).expect("one member fits snapshot bound"), + ); + EmaSamplerState::::put(( + 0, + Some(InFlightEmaSample { + coldkey: stale_coldkey, + progress: MockEmaProgress { + offset: 99, + partial: 999, + }, + }), + )); + + let _step = EmaValueProviderStepGuard::new(Some(|_, progress| { + assert_eq!(progress, MockEmaProgress::default()); + (SampleStep::Continue { progress }, Weight::zero()) + })); + + SubtensorModule::tick_root_registered_ema(); + + let (_, progress) = EmaSamplerState::::get(); + let progress = progress.expect("continued sample must store fresh progress"); + assert_eq!(progress.coldkey, coldkey); + assert_eq!(progress.progress, MockEmaProgress::default()); + }); +} + +#[test] +fn ema_tick_ignores_joined_coldkey_until_cycle_snapshot_rebuilds() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + let cold_a = U256::from(10); + let cold_b = U256::from(20); + let cold_c = U256::from(30); + root_register_with_stake(&cold_a, &U256::from(11), alpha); + root_register_with_stake(&cold_b, &U256::from(21), alpha); + + SubtensorModule::tick_root_registered_ema(); + let first_snapshot = CurrentCycleMembers::::get(); + assert_eq!(first_snapshot.len(), 2); + + root_register_with_stake(&cold_c, &U256::from(31), alpha); + assert!(!first_snapshot.contains(&cold_c)); + assert!(!CurrentCycleMembers::::get().contains(&cold_c)); + + let _ = take_ema_value_provider_log(); + SubtensorModule::tick_root_registered_ema(); + let touched: Vec = take_ema_value_provider_log() + .iter() + .map(|(coldkey, _)| *coldkey) + .collect(); + assert!(!touched.contains(&cold_c)); + + SubtensorModule::tick_root_registered_ema(); + assert!(CurrentCycleMembers::::get().contains(&cold_c)); + }); +} + +#[test] +fn ema_tick_skips_removed_coldkey_from_existing_cycle_snapshot() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + let cold_a = U256::from(10); + let cold_b = U256::from(20); + root_register_with_stake(&cold_a, &U256::from(11), alpha); + root_register_with_stake(&cold_b, &U256::from(21), alpha); + let _ = take_ema_value_provider_log(); + + // Snapshot built on first tick; finalize bumps samples on + // whichever validator the cursor lands on. + SubtensorModule::tick_root_registered_ema(); + + // Identify the validator at the *next* cursor position and + // unregister it before the next tick reaches them. + let snapshot = CurrentCycleMembers::::get(); + let cursor = EmaSamplerState::::get().0; + let next = snapshot + .get(cursor as usize) + .copied() + .expect("cursor must point at a member after first tick"); + SubtensorModule::decrement_root_registered_hotkey_count(&next); + assert!(!RootRegisteredEma::::contains_key(next)); + + // The next tick lands on the unregistered coldkey, finds it + // missing from RootRegisteredEma, advances the cursor, and + // does not finalize. + let _ = take_ema_value_provider_log(); + SubtensorModule::tick_root_registered_ema(); + assert_eq!(EmaSamplerState::::get().0, cursor + 1); + assert!(take_ema_value_provider_log().is_empty()); + assert!(!RootRegisteredEma::::contains_key(next)); + }); +} diff --git a/pallets/subtensor/src/tests/swap_coldkey.rs b/pallets/subtensor/src/tests/swap_coldkey.rs index ca269fe3d1..fc55270277 100644 --- a/pallets/subtensor/src/tests/swap_coldkey.rs +++ b/pallets/subtensor/src/tests/swap_coldkey.rs @@ -2010,3 +2010,81 @@ fn dispute_coldkey_swap(who: U256) { RuntimeOrigin::signed(who), )); } + +fn ref_count(coldkey: &U256) -> u32 { + RootRegisteredHotkeyCount::::get(coldkey) +} + +#[test] +fn swap_coldkey_transfers_ref_count_for_root_registered_hotkeys() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let old_coldkey = U256::from(10); + let new_coldkey = U256::from(20); + let h1 = U256::from(11); + let h2 = U256::from(12); + let h_not_root = U256::from(13); + + // Two root-registered hotkeys plus one non-root-registered hotkey, + // all owned by old_coldkey. + root_register_with_stake(&old_coldkey, &h1, alpha); + root_register_with_stake(&old_coldkey, &h2, alpha); + register_ok_neuron(alpha, h_not_root, old_coldkey, 0); + + assert_eq!(ref_count(&old_coldkey), 2); + assert_eq!(ref_count(&new_coldkey), 0); + + assert_ok!(SubtensorModule::do_swap_coldkey(&old_coldkey, &new_coldkey)); + + assert_eq!(ref_count(&old_coldkey), 0); + assert_eq!(ref_count(&new_coldkey), 2); + assert!(!SubtensorModule::coldkey_has_root_hotkey(&old_coldkey)); + assert!(SubtensorModule::coldkey_has_root_hotkey(&new_coldkey)); + }); +} + +#[test] +fn swap_coldkey_with_no_root_hotkeys_is_noop_for_ref_count() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let old_coldkey = U256::from(10); + let new_coldkey = U256::from(20); + let hot = U256::from(11); + + // Hotkey registered on alpha only, not on root. + register_ok_neuron(alpha, hot, old_coldkey, 0); + assert_eq!(ref_count(&old_coldkey), 0); + + assert_ok!(SubtensorModule::do_swap_coldkey(&old_coldkey, &new_coldkey)); + + assert_eq!(ref_count(&old_coldkey), 0); + assert_eq!(ref_count(&new_coldkey), 0); + }); +} + +#[test] +fn swap_coldkey_fires_removed_for_source_and_added_for_target() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let from = U256::from(10); + let to = U256::from(99); + root_register_with_stake(&from, &U256::from(11), alpha); + root_register_with_stake(&from, &U256::from(12), alpha); + let _ = take_root_registration_log(); + + assert_ok!(SubtensorModule::do_swap_coldkey(&from, &to)); + + let log = take_root_registration_log(); + assert!(log.contains(&RootRegistrationChange::Removed(from))); + assert!(log.contains(&RootRegistrationChange::Added(to))); + }); +} diff --git a/pallets/subtensor/src/tests/swap_hotkey.rs b/pallets/subtensor/src/tests/swap_hotkey.rs index 29a3322638..3a8881a786 100644 --- a/pallets/subtensor/src/tests/swap_hotkey.rs +++ b/pallets/subtensor/src/tests/swap_hotkey.rs @@ -1832,7 +1832,7 @@ fn ghsa_2026_011_all_subnets_swap_covers_parent_key_subnets_not_child_side() { let interval: u64 = ::HotkeySwapOnSubnetInterval::get(); - add_network(member_netuid, 13, 0); + add_network(member_netuid, 13, 0; add_network(parent_netuid, 13, 0); add_network(child_netuid, 13, 0); register_ok_neuron(member_netuid, old_hotkey, coldkey, 0); @@ -1975,3 +1975,44 @@ fn ghsa_2026_014_childkey_take_not_migrated_on_hotkey_swap() { assert_eq!(ChildkeyTake::::get(old_hotkey, netuid), 0); }); } + +#[test] +fn test_swap_hotkey_preserves_root_registered_hotkey_count() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + let old_hotkey = U256::from(11); + let new_hotkey = U256::from(12); + + // Register `old_hotkey` on the root subnet under `coldkey`. + register_ok_neuron(alpha, old_hotkey, coldkey, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &old_hotkey, + &coldkey, + NetUid::ROOT, + AlphaBalance::from(1_000_000_000), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(coldkey), + old_hotkey, + )); + assert_eq!(RootRegisteredHotkeyCount::::get(coldkey), 1); + + let mut weight = Weight::zero(); + assert_ok!(SubtensorModule::perform_hotkey_swap_on_all_subnets( + &old_hotkey, + &new_hotkey, + &coldkey, + &mut weight, + false, + )); + + // The coldkey still controls one root-registered hotkey; only the + // identity changed. + assert_eq!(RootRegisteredHotkeyCount::::get(coldkey), 1); + assert!(SubtensorModule::coldkey_has_root_hotkey(&coldkey)); + }); +} \ No newline at end of file diff --git a/pallets/subtensor/src/weights.rs b/pallets/subtensor/src/weights.rs index e4beabe817..aa71d8e8f2 100644 --- a/pallets/subtensor/src/weights.rs +++ b/pallets/subtensor/src/weights.rs @@ -453,10 +453,16 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::ValidatorTrust` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::ValidatorPermit` (r:1 w:1) /// Proof: `SubtensorModule::ValidatorPermit` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::RootRegisteredHotkeyCount` (r:1 w:1) + /// Proof: `SubtensorModule::RootRegisteredHotkeyCount` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) /// Storage: `SubtensorModule::Delegates` (r:1 w:1) /// Proof: `SubtensorModule::Delegates` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::BlockAtRegistration` (r:0 w:1) /// Proof: `SubtensorModule::BlockAtRegistration` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::RootRegisteredEma` (r:0 w:1) + /// Proof: `SubtensorModule::RootRegisteredEma` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::Keys` (r:0 w:1) /// Proof: `SubtensorModule::Keys` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::IsNetworkMember` (r:0 w:1) @@ -2937,10 +2943,16 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::ValidatorTrust` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::ValidatorPermit` (r:1 w:1) /// Proof: `SubtensorModule::ValidatorPermit` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::RootRegisteredHotkeyCount` (r:1 w:1) + /// Proof: `SubtensorModule::RootRegisteredHotkeyCount` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) /// Storage: `SubtensorModule::Delegates` (r:1 w:1) /// Proof: `SubtensorModule::Delegates` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::BlockAtRegistration` (r:0 w:1) /// Proof: `SubtensorModule::BlockAtRegistration` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::RootRegisteredEma` (r:0 w:1) + /// Proof: `SubtensorModule::RootRegisteredEma` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::Keys` (r:0 w:1) /// Proof: `SubtensorModule::Keys` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::IsNetworkMember` (r:0 w:1) diff --git a/pallets/transaction-fee/src/tests/mock.rs b/pallets/transaction-fee/src/tests/mock.rs index 7d8508c5c0..3d60369704 100644 --- a/pallets/transaction-fee/src/tests/mock.rs +++ b/pallets/transaction-fee/src/tests/mock.rs @@ -317,6 +317,9 @@ impl pallet_subtensor::Config for Test { type CommitmentsInterface = CommitmentsI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; + type OnRootRegistrationChange = (); + type RootRegisteredInspector = (); + type EmaValueProvider = (); type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type InitialMaxEpochsPerBlock = MaxEpochsPerBlock; @@ -458,7 +461,6 @@ parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; pub const MaxScheduledPerBlock: u32 = 50; - pub const NoPreimagePostponement: Option = Some(10); } impl pallet_scheduler::Config for Test { diff --git a/precompiles/src/mock.rs b/precompiles/src/mock.rs index 2cd917b43c..2c449d58b4 100644 --- a/precompiles/src/mock.rs +++ b/precompiles/src/mock.rs @@ -489,6 +489,9 @@ impl pallet_subtensor::Config for Runtime { type CommitmentsInterface = CommitmentsI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; + type OnRootRegistrationChange = (); + type RootRegisteredInspector = (); + type EmaValueProvider = (); type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type InitialMaxEpochsPerBlock = MaxEpochsPerBlock; diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 946e797f21..5bb4a167de 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -158,6 +158,11 @@ stp-shield.workspace = true ethereum.workspace = true +# Governance +pallet-multi-collective.workspace = true +pallet-signed-voting.workspace = true +pallet-referenda.workspace = true + [dev-dependencies] frame-metadata.workspace = true sp-tracing.workspace = true @@ -203,6 +208,9 @@ std = [ "pallet-scheduler/std", "pallet-preimage/std", "pallet-commitments/std", + "pallet-multi-collective/std", + "pallet-signed-voting/std", + "pallet-referenda/std", "precompile-utils/std", "sp-api/std", "sp-block-builder/std", @@ -329,10 +337,13 @@ runtime-benchmarks = [ # Smart Tx fees pallet "subtensor-transaction-fee/runtime-benchmarks", "pallet-shield/runtime-benchmarks", - + "pallet-limit-orders/runtime-benchmarks", + "pallet-referenda/runtime-benchmarks", + "pallet-multi-collective/runtime-benchmarks", + "pallet-signed-voting/runtime-benchmarks", + "subtensor-runtime-common/runtime-benchmarks", "subtensor-chain-extensions/runtime-benchmarks", - "pallet-limit-orders/runtime-benchmarks", "subtensor-swap-interface/runtime-benchmarks", ] try-runtime = [ @@ -370,6 +381,9 @@ try-runtime = [ "pallet-fast-unstake/try-runtime", "pallet-nomination-pools/try-runtime", "pallet-offences/try-runtime", + "pallet-multi-collective/try-runtime", + "pallet-signed-voting/try-runtime", + "pallet-referenda/try-runtime", # EVM + Frontier "fp-self-contained/try-runtime", diff --git a/runtime/src/governance/README.md b/runtime/src/governance/README.md new file mode 100644 index 0000000000..8aceae8ec9 --- /dev/null +++ b/runtime/src/governance/README.md @@ -0,0 +1,158 @@ +# Runtime Governance + +This directory wires Subtensor's concrete governance configuration into the +generic governance pallets. + +The runtime uses: + +- `pallet_multi_collective` for named membership sets. +- `pallet_referenda` for the track state machine. +- `pallet_signed_voting` for per-account aye/nay voting. +- `pallet_subtensor` root-registration and subnet state to select rotating + collective members. + +## Tracks + +`tracks.rs` defines two static tracks. + +| Id | Name | Proposer set | Voter set | Strategy | +| -- | ---- | ---- | ---- | ---- | +| `0` | `triumvirate` | `MemberSet::Single(Proposers)` | `MemberSet::Single(Triumvirate)` | `PassOrFail`: 7 day decision period, 2/3 approve, 2/3 reject, approval hands off to track `1`. | +| `1` | `review` | `None` | `MemberSet::Union(Economic, Building)` | `Adjustable`: 24 hour initial delay, 2 day max delay, 75% fast-track threshold, 51% cancel threshold. | + +Track `1` must stay non-submittable (`proposer_set: None`). It is reached +only through `ApprovalAction::Review` after track `0` approval. This is the +runtime invariant that prevents direct submission of a root call into the +review delay. + +`EaseOutAdjustmentCurve` shapes review delay changes as `1 - (1 - p)^3`. +Early net collective signal has a visible effect on the dispatch delay, and +then tapers off as the vote approaches the hard fast-track or cancel +threshold. Net approval pulls the scheduled call toward the submission +block; net rejection pushes it toward `max_delay`. + +## Collectives + +`collectives.rs` defines the consensus-facing `CollectiveId` values: + +| Id | Codec index | Members | Term | +| -- | -- | -- | -- | +| `Proposers` | `0` | min `1`, max `20` | none | +| `Triumvirate` | `1` | exactly `3` | none | +| `Economic` | `2` | exactly `16` | 60 days | +| `Building` | `3` | exactly `16` | 60 days | +| `EconomicEligible` | `4` | max `64` | none | + +Codec indices are consensus-facing. Do not reorder or renumber them. + +The pallet-level `MaxMembers` is `64` because it is the storage bound shared +by all collectives. The per-collective `max_members` values above are the +logical limits. + +## Voting Sets + +`member_set.rs` adapts collectives into the `SetLike` interface +used by referenda tracks. + +- `Single(id)` reads exactly one collective. +- `Union(ids)` concatenates members from several collectives, sorts them, + and deduplicates them. + +The review track uses `Union(Economic, Building)`, so an account that is in +both collectives is counted once in the signed-voting snapshot and in the +threshold denominator. + +## Economic Rotation + +`EconomicEligible` is a staging set for Economic selection. It is maintained +by `EconomicEligibleSync`, which implements `OnRootRegistrationChange` for +`pallet-subtensor`. + +- A coldkey is added when its root-registered hotkey count moves from `0` + to `1`. +- A coldkey is removed when its count moves from `1` to `0`. +- `EconomicEligibleInspector` lets Subtensor try-state verify that the + collective matches the root-registered coldkey set. + +`term_management.rs` rotates `Economic` by calling +`TermManagement::top_validators(16)`. + +Selection steps: + +1. Read all `EconomicEligible` coldkeys. +2. Read `RootRegisteredEma` for each coldkey. +3. Ignore candidates with fewer than `ECONOMIC_ELIGIBILITY_THRESHOLD` + samples (`210`, roughly 30 days with the current sampler cadence). +4. Sort remaining candidates by descending EMA value. +5. Set `Economic` to the top 16. + +The EMA sample value is provided by `ema_provider.rs`. A sample is: + +```text +liquid TAO balance ++ TAO value of alpha held by owned hotkeys across all subnets +``` + +Sampling is incremental: 8 subnets per provider step and at most 256 owned +hotkeys valued per sample. Subtensor calls `tick_root_registered_ema()` from +its `on_initialize` hook, so the sampler advances once per block. The EMA +blend alpha is `0.02` and new root-registered coldkeys start from zero. + +## Building Rotation + +`term_management.rs` rotates `Building` by calling +`TermManagement::top_subnet_owners(16, MIN_SUBNET_AGE)`. + +Selection steps: + +1. Iterate all subnet netuids. +2. Ignore subnets younger than `MIN_SUBNET_AGE` (`180` days in production). +3. For each mature subnet, read its owner and moving price. +4. Keep only each owner's highest moving price across all mature subnets. +5. Sort owners by descending best price. +6. Set `Building` to the top 16. + +This gives one seat per owner coldkey, based on that owner's strongest +mature subnet. + +## Rotation Behavior + +`pallet_multi_collective` runs term hooks from `on_initialize` whenever +`block_number % term_duration == 0`. For this runtime only `Economic` and +`Building` have a term duration, so only those collectives rotate +automatically. + +Both rotating collectives require exactly 16 members. If selection returns +fewer than 16 accounts, `do_set_members` fails with `TooFewMembers`; the +runtime logs the failure and leaves the previous member list unchanged. + +Root can call `force_rotate` for a rotating collective to run the same hook +outside the normal cadence. + +## Referenda Runtime Constants + +`mod.rs` wires these constants: + +| Constant | Value | Meaning | +| ---- | ---- | ---- | +| `MaxQueued` | `20` | Maximum active referenda. | +| `MaxActivePerProposer` | `5` | Maximum active referenda per proposer. | +| `MaxVoterSetSize` | `64` | Bound for signed-voting snapshots. | +| `MaxPendingCleanup` | `40` | Cleanup queue capacity for completed polls. | +| `CleanupChunkSize` | `16` | Per-idle-block vote-record cleanup chunk. | + +Compile-time assertions keep these constants aligned with the collective +sizes. The widest voter set is currently `Economic + Building` (`32` +before deduplication). + +## Operational Notes + +- `referenda.submit` is signed and only works on tracks with + `proposer_set: Some(_)`. In this runtime, that means only track `0`. +- There is no proposer-only cancel or withdraw call. Emergency termination + is `referenda.kill`, gated by root. +- Voting is snapshot-based. Active polls are not affected by later + collective rotations. +- Dispatch is wrapped through `referenda.enact(index, call)`, which marks + the referendum `Enacted` in the same root call that dispatches the inner + proposal. diff --git a/runtime/src/governance/benchmarking.rs b/runtime/src/governance/benchmarking.rs new file mode 100644 index 0000000000..2bdcf35cf4 --- /dev/null +++ b/runtime/src/governance/benchmarking.rs @@ -0,0 +1,207 @@ +#![allow(clippy::arithmetic_side_effects, clippy::unwrap_used)] + +use core::marker::PhantomData; +use frame_benchmarking::{BenchmarkError, account, v2::*}; +use pallet_multi_collective::Pallet as MultiCollective; +use pallet_subtensor::{ + Pallet as Subtensor, + root_registered::{EmaValueProvider, SampleStep}, + *, +}; +use sp_std::vec::Vec; +use substrate_fixed::types::{I96F32, U64F64}; +use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; + +use super::{ + BUILDING_SIZE, CollectiveId, ECONOMIC_ELIGIBILITY_THRESHOLD, ECONOMIC_ELIGIBLE_SIZE, + ECONOMIC_SIZE, MIN_SUBNET_AGE, STAKE_CHUNK_SUBNETS, STAKE_VALUE_HOTKEYS, StakeValueProgress, + StakeValueProvider, TermManagement, +}; +use crate::{AccountId, Runtime}; + +pub trait Config: frame_system::Config {} + +pub struct Pallet(PhantomData); + +impl Config for Runtime {} + +const FIRST_BENCHMARK_NETUID: u16 = 1024; +const BUILDING_BENCHMARK_SUBNETS: u32 = 128; + +#[benchmarks] +mod benchmarks { + use super::*; + + #[benchmark] + fn stake_ema_provider_step() -> Result<(), BenchmarkError> { + let (coldkey, progress) = prepare_stake_value_state(); + let expected_offset = progress.subnet_offset.saturating_add(STAKE_CHUNK_SUBNETS); + let result; + + #[block] + { + result = StakeValueProvider::step(&coldkey, progress); + } + + assert!(matches!( + result.0, + SampleStep::Continue { progress } + if progress.subnet_offset == expected_offset && progress.accumulated_tao > 0 + )); + + Ok(()) + } + + #[benchmark] + fn rotate_economic() -> Result<(), BenchmarkError> { + let expected = expected_stored_members(prepare_economic_rotation_state()); + + #[block] + { + let _ = TermManagement::rotate_economic(); + } + + assert_eq!(members_of(CollectiveId::Economic), expected); + + Ok(()) + } + + #[benchmark] + fn rotate_building() -> Result<(), BenchmarkError> { + let expected = expected_stored_members(prepare_building_rotation_state()); + + #[block] + { + let _ = TermManagement::rotate_building(); + } + + assert_eq!(members_of(CollectiveId::Building), expected); + + Ok(()) + } + + fn seed_swap_reserves(netuid: NetUid) { + SubnetTAO::::insert(netuid, TaoBalance::from(150_000_000_000_u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(100_000_000_000_u64)); + } + + fn add_balance_to_coldkey_account(coldkey: &AccountId, tao: TaoBalance) { + let credit = Subtensor::::mint_tao(tao); + let _ = Subtensor::::spend_tao(coldkey, credit, tao).unwrap(); + } + + fn prepare_stake_value_state() -> (AccountId, StakeValueProgress) { + let coldkey: AccountId = account("StakeValueColdkey", 0, 0); + add_balance_to_coldkey_account(&coldkey, TaoBalance::from(1_000_000_000_u64)); + + let mut hotkeys: Vec = Vec::with_capacity(STAKE_VALUE_HOTKEYS as usize); + for hotkey_index in 0..STAKE_VALUE_HOTKEYS { + hotkeys.push(account("StakeValueHotkey", hotkey_index, 0)); + } + OwnedHotkeys::::insert(&coldkey, hotkeys.clone()); + + let mut first_netuid = None; + for subnet_index in 0..STAKE_CHUNK_SUBNETS { + let netuid = NetUid::from(FIRST_BENCHMARK_NETUID.saturating_add(subnet_index as u16)); + if first_netuid.is_none() { + first_netuid = Some(netuid); + } + + Subtensor::::init_new_network(netuid, 1); + SubtokenEnabled::::insert(netuid, true); + seed_swap_reserves(netuid); + + for hotkey in &hotkeys { + TotalHotkeyAlpha::::insert( + hotkey.clone(), + netuid, + AlphaBalance::from(1_000_000_000_u64), + ); + } + } + + let netuids = Subtensor::::get_all_subnet_netuids(); + let subnet_offset = netuids + .iter() + .position(|netuid| Some(*netuid) == first_netuid) + .unwrap_or_default() as u32; + + ( + coldkey, + StakeValueProgress { + subnet_offset, + accumulated_tao: 0, + }, + ) + } + + fn set_members(collective_id: CollectiveId, members: Vec) { + MultiCollective::::set_members( + frame_system::RawOrigin::Root.into(), + collective_id, + members, + ) + .unwrap(); + } + + fn members_of(collective_id: CollectiveId) -> Vec { + as pallet_multi_collective::CollectiveInspect< + AccountId, + CollectiveId, + >>::members_of(collective_id) + } + + fn expected_stored_members(mut members: Vec) -> Vec { + members.sort(); + members + } + + fn prepare_economic_rotation_state() -> Vec { + let eligible = (0..ECONOMIC_ELIGIBLE_SIZE) + .map(|index| { + let coldkey = account("EconomicEligibleColdkey", index, 0); + RootRegisteredEma::::insert( + &coldkey, + pallet_subtensor::root_registered::EmaState { + ema: U64F64::from_num(ECONOMIC_ELIGIBLE_SIZE - index), + samples: ECONOMIC_ELIGIBILITY_THRESHOLD, + }, + ); + coldkey + }) + .collect::>(); + set_members(CollectiveId::EconomicEligible, eligible); + + let old_members = (0..ECONOMIC_SIZE) + .map(|index| account("OldEconomicMember", index, 0)) + .collect::>(); + set_members(CollectiveId::Economic, old_members); + + TermManagement::top_validators(ECONOMIC_SIZE).0 + } + + fn prepare_building_rotation_state() -> Vec { + frame_system::Pallet::::set_block_number(MIN_SUBNET_AGE.saturating_add(1)); + + let old_members = (0..BUILDING_SIZE) + .map(|index| account("OldBuildingMember", index, 0)) + .collect::>(); + set_members(CollectiveId::Building, old_members); + + for subnet_index in 0..BUILDING_BENCHMARK_SUBNETS { + let netuid = NetUid::from(4_096_u16.saturating_add(subnet_index as u16)); + let owner_index = subnet_index % BUILDING_SIZE; + let owner: AccountId = account("BuildingOwner", owner_index, 0); + + Subtensor::::init_new_network(netuid, 1); + NetworkRegisteredAt::::insert(netuid, 0); + SubnetOwner::::insert(netuid, owner); + SubnetMovingPrice::::insert( + netuid, + I96F32::from_num(BUILDING_BENCHMARK_SUBNETS - subnet_index), + ); + } + + TermManagement::top_subnet_owners(BUILDING_SIZE, MIN_SUBNET_AGE).0 + } +} diff --git a/runtime/src/governance/collectives.rs b/runtime/src/governance/collectives.rs new file mode 100644 index 0000000000..c24ecc6291 --- /dev/null +++ b/runtime/src/governance/collectives.rs @@ -0,0 +1,194 @@ +use alloc::vec::Vec; + +use frame_support::pallet_prelude::*; +use pallet_multi_collective::{ + Collective, CollectiveInfo, CollectiveInspect, CollectivesInfo, + weights::WeightInfo as MultiCollectiveWeightInfo, +}; +use pallet_subtensor::root_registered::{OnRootRegistrationChange, RootRegisteredInspector}; +use runtime_common::prod_or_fast; +use subtensor_runtime_common::{pad_name, time::DAYS}; + +use crate::{AccountId, BlockNumber, Runtime}; + +/// Keeps fresh subnet launches out of the Building rotation. +pub const MIN_SUBNET_AGE: BlockNumber = prod_or_fast!(180 * DAYS, 100); + +/// Voting seats rotated into the Economic collective. +pub const ECONOMIC_SIZE: u32 = 16; + +/// Voting seats rotated into the Building collective. +pub const BUILDING_SIZE: u32 = 16; + +/// Cap on the EconomicEligible collective. Equal to the root subnet's +/// maximum UID count: membership mirrors the set of coldkeys with at +/// least one root-registered hotkey, so the worst case is one distinct +/// coldkey per root UID. +pub const ECONOMIC_ELIGIBLE_SIZE: u32 = 64; + +/// Rotation cadence for ranked collectives. +const TERM_DURATION: BlockNumber = prod_or_fast!(60 * DAYS, 100); + +/// Stable collective ids. Codec indices are consensus-facing. +#[derive( + Copy, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, +)] +pub enum CollectiveId { + /// Accounts authorized to submit proposals on the triumvirate track. + #[codec(index = 0)] + Proposers, + /// Three-member approval body for track 0. + #[codec(index = 1)] + Triumvirate, + /// Top validators: one half of the collective oversight voter set. + #[codec(index = 2)] + Economic, + /// Top subnet owners: one half of the collective oversight voter set. + #[codec(index = 3)] + Building, + /// Staging set for the Economic collective. Membership is driven by + /// `do_root_register` in `pallet-subtensor`; each rotation projects + /// the top-`ECONOMIC_SIZE` from here into `Economic`. + #[codec(index = 4)] + EconomicEligible, +} + +pub struct Collectives; +impl CollectivesInfo for Collectives { + type Id = CollectiveId; + + fn collectives() -> impl Iterator> { + [ + Collective { + id: CollectiveId::Proposers, + info: CollectiveInfo { + name: pad_name(b"proposers"), + min_members: 1, + max_members: Some(20), + term_duration: None, + }, + }, + Collective { + id: CollectiveId::Triumvirate, + info: CollectiveInfo { + name: pad_name(b"triumvirate"), + min_members: 3, + max_members: Some(3), + term_duration: None, + }, + }, + Collective { + id: CollectiveId::Economic, + info: CollectiveInfo { + name: pad_name(b"economic"), + min_members: ECONOMIC_SIZE, + max_members: Some(ECONOMIC_SIZE), + term_duration: Some(TERM_DURATION), + }, + }, + Collective { + id: CollectiveId::Building, + info: CollectiveInfo { + name: pad_name(b"building"), + min_members: BUILDING_SIZE, + max_members: Some(BUILDING_SIZE), + term_duration: Some(TERM_DURATION), + }, + }, + Collective { + id: CollectiveId::EconomicEligible, + info: CollectiveInfo { + name: pad_name(b"economic_eligible"), + min_members: 0, + max_members: Some(ECONOMIC_ELIGIBLE_SIZE), + term_duration: None, + }, + }, + ] + .into_iter() + } +} + +/// Keeps the Economic eligibility pool aligned with root registration. +/// +/// Failures are logged instead of blocking root-register or hotkey-swap +/// calls; `try_state` checks the invariant afterwards. +pub struct EconomicEligibleSync; + +impl OnRootRegistrationChange for EconomicEligibleSync { + fn on_added(coldkey: &AccountId) { + if let Err(err) = pallet_multi_collective::Pallet::::do_add_member( + CollectiveId::EconomicEligible, + coldkey.clone(), + ) { + log::error!( + target: "runtime::economic-eligible-sync", + "do_add_member failed for {:?}: {:?}", + coldkey, + err, + ); + } + } + + fn on_removed(coldkey: &AccountId) { + if let Err(err) = pallet_multi_collective::Pallet::::do_remove_member( + CollectiveId::EconomicEligible, + coldkey.clone(), + ) { + log::error!( + target: "runtime::economic-eligible-sync", + "do_remove_member failed for {:?}: {:?}", + coldkey, + err, + ); + } + } + + fn on_added_weight() -> Weight { + ::WeightInfo::do_add_member() + } + + fn on_removed_weight() -> Weight { + ::WeightInfo::do_remove_member() + } +} + +/// Lets `pallet-subtensor` verify its root-registration invariant. +pub struct EconomicEligibleInspector; + +impl RootRegisteredInspector for EconomicEligibleInspector { + fn members() -> Option> { + Some( + as CollectiveInspect< + AccountId, + CollectiveId, + >>::members_of(CollectiveId::EconomicEligible), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codec::Encode; + + #[test] + fn collective_id_codec_indices_are_pinned() { + assert_eq!(CollectiveId::Proposers.encode(), vec![0]); + assert_eq!(CollectiveId::Triumvirate.encode(), vec![1]); + assert_eq!(CollectiveId::Economic.encode(), vec![2]); + assert_eq!(CollectiveId::Building.encode(), vec![3]); + assert_eq!(CollectiveId::EconomicEligible.encode(), vec![4]); + } +} diff --git a/runtime/src/governance/ema_provider.rs b/runtime/src/governance/ema_provider.rs new file mode 100644 index 0000000000..8bc7ead29b --- /dev/null +++ b/runtime/src/governance/ema_provider.rs @@ -0,0 +1,415 @@ +use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use frame_support::{traits::fungible::Inspect, weights::Weight}; +use pallet_subtensor::{ + Pallet as Subtensor, + root_registered::{EmaValueProvider, SampleStep}, + *, +}; +use scale_info::TypeInfo; +use sp_runtime::traits::UniqueSaturatedInto; +use substrate_fixed::types::U64F64; +use subtensor_runtime_common::NetUid; +use subtensor_swap_interface::{Order, SwapHandler}; + +use super::weights::WeightInfo; +use crate::{AccountId, Runtime}; + +/// Number of subnets folded into the stake-value accumulator per tick. +pub(crate) const STAKE_CHUNK_SUBNETS: u32 = 8; + +/// Maximum owned hotkeys valued for one governance stake EMA sample. +pub(crate) const STAKE_VALUE_HOTKEYS: u32 = 256; + +/// Provider-owned progress for the governance stake-value EMA. +#[subtensor_macros::freeze_struct("1a8d9e6e7d73e9d3")] +#[derive( + Clone, + Copy, + Default, + PartialEq, + Eq, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, +)] +pub struct StakeValueProgress { + /// Subnet offset processed so far. + pub subnet_offset: u32, + /// Running TAO accumulator for processed subnet chunks. + pub accumulated_tao: u128, +} + +/// Governance stake-value provider: each root-registered coldkey's sample +/// is the TAO value of its liquid balance plus the alpha held across all +/// owned hotkeys on every subnet. +pub struct StakeValueProvider; + +impl StakeValueProvider { + fn subnet_chunk(netuids: &[NetUid], offset: u32) -> &[NetUid] { + let start: usize = offset.unique_saturated_into(); + let start = start.min(netuids.len()); + let netuids_len: u32 = netuids.len().unique_saturated_into(); + let end: usize = offset + .saturating_add(STAKE_CHUNK_SUBNETS) + .min(netuids_len) + .unique_saturated_into(); + netuids.get(start..end).unwrap_or_default() + } + + fn accumulate_subnet_values( + hotkeys: &[AccountId], + netuids: &[NetUid], + accumulated_tao: u128, + ) -> u128 { + netuids.iter().fold(accumulated_tao, |total, netuid| { + total.saturating_add(Self::tao_for_subnet_hotkeys(hotkeys, *netuid)) + }) + } + + fn tao_for_subnet_hotkeys(hotkeys: &[AccountId], netuid: NetUid) -> u128 { + let hotkey_limit: usize = STAKE_VALUE_HOTKEYS.unique_saturated_into(); + let total_alpha = hotkeys + .iter() + .take(hotkey_limit) + .fold(0_u128, |total, hotkey| { + let alpha = Subtensor::::get_stake_for_hotkey_on_subnet(hotkey, netuid); + total.saturating_add(u128::from(u64::from(alpha))) + }); + + if total_alpha == 0 { + return 0; + } + + let aggregated: u64 = total_alpha + .min(u128::from(u64::MAX)) + .unique_saturated_into(); + let order = GetTaoForAlpha::::with_amount(aggregated); + ::SwapInterface::sim_swap(netuid.into(), order) + .map(|r| u128::from(u64::from(r.amount_paid_out))) + .unwrap_or_default() + } +} + +impl EmaValueProvider for StakeValueProvider { + type Progress = StakeValueProgress; + + /// Advances one chunk of subnet valuation for `coldkey`, carrying the + /// accumulated TAO value in `Progress` until all subnets are sampled. + fn step(coldkey: &AccountId, progress: Self::Progress) -> (SampleStep, Weight) { + let netuids = Subtensor::::get_all_subnet_netuids(); + let total: u32 = netuids.len().unique_saturated_into(); + let hotkeys = OwnedHotkeys::::get(coldkey); + + let mut next = progress; + if next.subnet_offset < total { + let chunk = Self::subnet_chunk(&netuids, next.subnet_offset); + next.accumulated_tao = + Self::accumulate_subnet_values(&hotkeys, chunk, next.accumulated_tao); + let chunk_len: u32 = chunk.len().unique_saturated_into(); + next.subnet_offset = next.subnet_offset.saturating_add(chunk_len).min(total); + } + + let step = if next.subnet_offset >= total { + let liquid = u128::from(u64::from(::Currency::balance(coldkey))); + let sample = U64F64::saturating_from_num(next.accumulated_tao.saturating_add(liquid)); + SampleStep::Complete { sample } + } else { + SampleStep::Continue { progress: next } + }; + + (step, Self::step_weight()) + } + + fn step_weight() -> Weight { + super::weights::SubstrateWeight::::stake_ema_provider_step() + } +} + +#[cfg(test)] +#[allow(clippy::indexing_slicing)] +mod tests { + use super::*; + + use frame_support::traits::fungible::Mutate; + use sp_runtime::BuildStorage; + use subtensor_runtime_common::{AlphaBalance, TaoBalance}; + + fn new_test_ext() -> sp_io::TestExternalities { + let storage = match (crate::RuntimeGenesisConfig { + sudo: pallet_sudo::GenesisConfig { key: None }, + ..Default::default() + }) + .build_storage() + { + Ok(storage) => storage, + Err(err) => panic!("failed to build test storage: {err:?}"), + }; + let mut ext: sp_io::TestExternalities = storage.into(); + ext.execute_with(|| crate::System::set_block_number(1)); + ext + } + + fn account(seed: u8) -> AccountId { + AccountId::from([seed; 32]) + } + + fn indexed_account(index: u32) -> AccountId { + let mut bytes = [0; 32]; + bytes[..4].copy_from_slice(&index.to_le_bytes()); + AccountId::from(bytes) + } + + fn add_balance(coldkey: &AccountId, amount: u64) { + assert!( + ::Currency::mint_into(coldkey, TaoBalance::from(amount)).is_ok() + ); + } + + fn seed_subnet(netuid: NetUid) { + Subtensor::::init_new_network(netuid, 1); + SubtokenEnabled::::insert(netuid, true); + SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_u64)); + } + + fn progress_at(netuid: NetUid, accumulated_tao: u128) -> StakeValueProgress { + let netuids = Subtensor::::get_all_subnet_netuids(); + let Some(offset) = netuids.iter().position(|candidate| *candidate == netuid) else { + panic!("seeded subnet {netuid:?} is not in the subnet list"); + }; + StakeValueProgress { + subnet_offset: offset as u32, + accumulated_tao, + } + } + + fn complete_sample(step: SampleStep) -> U64F64 { + match step { + SampleStep::Complete { sample } => sample, + SampleStep::Continue { progress } => { + panic!("expected complete sample, got progress {progress:?}") + } + } + } + + fn continued_progress(step: SampleStep) -> StakeValueProgress { + match step { + SampleStep::Continue { progress } => progress, + SampleStep::Complete { sample } => { + panic!("expected continued sample, got complete sample {sample:?}") + } + } + } + + #[test] + fn step_completes_with_liquid_balance_when_there_are_no_subnets() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + add_balance(&coldkey, 1_000); + + let (step, weight) = StakeValueProvider::step(&coldkey, StakeValueProgress::default()); + + assert_eq!(complete_sample(step), U64F64::from_num(1_000)); + assert_eq!(weight, StakeValueProvider::step_weight()); + }); + } + + #[test] + fn step_continues_after_one_subnet_chunk_when_more_subnets_remain() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + for index in 0..=STAKE_CHUNK_SUBNETS { + seed_subnet(NetUid::from(1_000_u16 + index as u16)); + } + + let (step, weight) = StakeValueProvider::step(&coldkey, StakeValueProgress::default()); + let progress = continued_progress(step); + + assert_eq!(progress.subnet_offset, STAKE_CHUNK_SUBNETS); + assert_eq!(progress.accumulated_tao, 0); + assert_eq!(weight, StakeValueProvider::step_weight()); + }); + } + + #[test] + fn step_accumulates_multiple_chunks_with_many_hotkeys_until_complete() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + let hotkeys = vec![account(2), account(3), account(4), account(5)]; + let unowned_hotkey = account(6); + let liquid = 1_000_u128; + add_balance(&coldkey, liquid as u64); + OwnedHotkeys::::insert(&coldkey, hotkeys.clone()); + + let subnet_count = STAKE_CHUNK_SUBNETS * 2 + 1; + for index in 0..subnet_count { + seed_subnet(NetUid::from(1_000_u16 + index as u16)); + } + + let netuids = Subtensor::::get_all_subnet_netuids(); + assert!(netuids.len() > (STAKE_CHUNK_SUBNETS * 2) as usize); + assert!(netuids.len() <= (STAKE_CHUNK_SUBNETS * 3) as usize); + + let expected_by_subnet = netuids + .iter() + .enumerate() + .map(|(subnet_index, netuid)| { + let total_owned_alpha = + hotkeys + .iter() + .enumerate() + .fold(0_u64, |total, (hotkey_index, hotkey)| { + let alpha = + ((subnet_index as u64) + 1) * ((hotkey_index as u64) + 1) * 10; + TotalHotkeyAlpha::::insert( + hotkey.clone(), + *netuid, + AlphaBalance::from(alpha), + ); + total + alpha + }); + TotalHotkeyAlpha::::insert( + unowned_hotkey.clone(), + *netuid, + AlphaBalance::from(1_000_000_u64), + ); + assert!(total_owned_alpha > 0); + StakeValueProvider::tao_for_subnet_hotkeys(&hotkeys, *netuid) + }) + .collect::>(); + + let first_chunk_end = STAKE_CHUNK_SUBNETS as usize; + let second_chunk_end = (STAKE_CHUNK_SUBNETS * 2) as usize; + let expected_first_chunk = expected_by_subnet[..first_chunk_end] + .iter() + .copied() + .sum::(); + let expected_second_chunk = expected_by_subnet[first_chunk_end..second_chunk_end] + .iter() + .copied() + .sum::(); + let expected_final_chunk = expected_by_subnet[second_chunk_end..] + .iter() + .copied() + .sum::(); + + let (step, weight) = StakeValueProvider::step(&coldkey, StakeValueProgress::default()); + let progress = continued_progress(step); + assert_eq!(weight, StakeValueProvider::step_weight()); + assert_eq!(progress.subnet_offset, STAKE_CHUNK_SUBNETS); + assert_eq!(progress.accumulated_tao, expected_first_chunk); + + let (step, weight) = StakeValueProvider::step(&coldkey, progress); + let progress = continued_progress(step); + assert_eq!(weight, StakeValueProvider::step_weight()); + assert_eq!(progress.subnet_offset, STAKE_CHUNK_SUBNETS * 2); + assert_eq!( + progress.accumulated_tao, + expected_first_chunk + expected_second_chunk + ); + + let (step, weight) = StakeValueProvider::step(&coldkey, progress); + assert_eq!(weight, StakeValueProvider::step_weight()); + assert_eq!( + complete_sample(step), + U64F64::from_num( + expected_first_chunk + expected_second_chunk + expected_final_chunk + liquid, + ) + ); + }); + } + + #[test] + fn step_completes_from_resumed_progress_and_adds_liquid_balance() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + add_balance(&coldkey, 1_000); + + let progress = StakeValueProgress { + subnet_offset: u32::MAX, + accumulated_tao: 12, + }; + let (step, _) = StakeValueProvider::step(&coldkey, progress); + + assert_eq!(complete_sample(step), U64F64::from_num(1_012)); + }); + } + + #[test] + fn step_aggregates_owned_hotkey_alpha_for_the_current_subnet() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + let hotkey_a = account(2); + let hotkey_b = account(3); + let hotkeys = vec![hotkey_a.clone(), hotkey_b.clone()]; + let unowned_hotkey = account(4); + let netuid = NetUid::from(1_000); + seed_subnet(netuid); + + OwnedHotkeys::::insert(&coldkey, hotkeys.clone()); + TotalHotkeyAlpha::::insert(hotkey_a, netuid, AlphaBalance::from(100_u64)); + TotalHotkeyAlpha::::insert(hotkey_b, netuid, AlphaBalance::from(200_u64)); + TotalHotkeyAlpha::::insert( + unowned_hotkey, + netuid, + AlphaBalance::from(900_u64), + ); + + let expected = StakeValueProvider::tao_for_subnet_hotkeys(&hotkeys, netuid); + let (step, _) = StakeValueProvider::step(&coldkey, progress_at(netuid, 0)); + + assert_eq!(complete_sample(step), U64F64::from_num(expected)); + }); + } + + #[test] + fn step_values_only_the_governance_hotkey_limit() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + let netuid = NetUid::from(1_000); + seed_subnet(netuid); + + let hotkeys = (0..=STAKE_VALUE_HOTKEYS) + .map(|index| indexed_account(index + 10)) + .collect::>(); + OwnedHotkeys::::insert(&coldkey, hotkeys.clone()); + + for (index, hotkey) in hotkeys.iter().enumerate() { + let alpha = if index < STAKE_VALUE_HOTKEYS as usize { + 1_u64 + } else { + 1_000_000_000_u64 + }; + TotalHotkeyAlpha::::insert( + hotkey.clone(), + netuid, + AlphaBalance::from(alpha), + ); + } + + let expected = StakeValueProvider::tao_for_subnet_hotkeys( + &hotkeys[..STAKE_VALUE_HOTKEYS as usize], + netuid, + ); + let (step, _) = StakeValueProvider::step(&coldkey, progress_at(netuid, 0)); + + assert_eq!(complete_sample(step), U64F64::from_num(expected)); + }); + } + + #[test] + fn step_carries_existing_accumulator_through_zero_alpha_subnets() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + let netuid = NetUid::from(1_000); + seed_subnet(netuid); + + let (step, _) = StakeValueProvider::step(&coldkey, progress_at(netuid, 77)); + + assert_eq!(complete_sample(step), U64F64::from_num(77)); + }); + } +} diff --git a/runtime/src/governance/member_set.rs b/runtime/src/governance/member_set.rs new file mode 100644 index 0000000000..4e06f2dff0 --- /dev/null +++ b/runtime/src/governance/member_set.rs @@ -0,0 +1,147 @@ +use alloc::vec::Vec; + +use pallet_multi_collective::CollectiveInspect; +use sp_runtime::traits::UniqueSaturatedInto; +use subtensor_runtime_common::SetLike; + +use crate::{AccountId, MultiCollective}; + +use super::collectives::CollectiveId; + +/// A voter or proposer set composed of one or more collectives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MemberSet { + Single(CollectiveId), + Union(Vec), +} + +impl MemberSet { + fn contains_with(&self, who: &A, lookup: F) -> bool + where + F: Fn(CollectiveId, &A) -> bool, + { + match self { + Self::Single(id) => lookup(*id, who), + Self::Union(ids) => ids.iter().any(|id| lookup(*id, who)), + } + } + + // Union members can overlap across collectives; dedup so the count + // signed-voting captures as `total` reflects true cardinality and + // does not bias thresholds upward. + fn to_vec_with(&self, lookup: F) -> Vec + where + A: Ord, + F: Fn(CollectiveId) -> Vec, + { + match self { + Self::Single(id) => lookup(*id), + Self::Union(ids) => { + let mut accounts: Vec = Vec::new(); + for id in ids { + accounts.extend(lookup(*id)); + } + accounts.sort(); + accounts.dedup(); + accounts + } + } + } + + fn is_initialized_with(&self, lookup: F) -> bool + where + F: Fn(CollectiveId) -> bool, + { + match self { + Self::Single(id) => lookup(*id), + Self::Union(ids) if ids.is_empty() => true, + Self::Union(ids) => ids.iter().any(|id| lookup(*id)), + } + } +} + +impl SetLike for MemberSet { + fn contains(&self, who: &AccountId) -> bool { + use CollectiveInspect as CI; + use MultiCollective as MC; + + self.contains_with(who, |id, who| { + >::is_member(id, who) + }) + } + + fn len(&self) -> u32 { + self.to_vec().len().unique_saturated_into() + } + + fn is_initialized(&self) -> bool { + use CollectiveInspect as CI; + use MultiCollective as MC; + + self.is_initialized_with(>::is_initialized) + } + + fn to_vec(&self) -> Vec { + use CollectiveInspect as CI; + use MultiCollective as MC; + + self.to_vec_with(>::members_of) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make(ids: &[u32]) -> Vec { + ids.to_vec() + } + + #[test] + fn single_delegates_to_lookup() { + let set = MemberSet::Single(CollectiveId::Triumvirate); + let out = set.to_vec_with::(|id| match id { + CollectiveId::Triumvirate => make(&[1, 2, 3]), + _ => make(&[]), + }); + assert_eq!(out, vec![1, 2, 3]); + } + + #[test] + fn union_concatenates_and_dedups() { + let set = MemberSet::Union(alloc::vec![CollectiveId::Economic, CollectiveId::Building,]); + let out = set.to_vec_with::(|id| match id { + CollectiveId::Economic => make(&[1, 2, 3]), + CollectiveId::Building => make(&[3, 4, 5]), + _ => make(&[]), + }); + assert_eq!(out, vec![1, 2, 3, 4, 5]); + } + + #[test] + fn union_with_no_ids_is_empty() { + let set = MemberSet::Union(alloc::vec![]); + let out = set.to_vec_with::(|_| make(&[1, 2])); + assert!(out.is_empty()); + } + + #[test] + fn single_contains_uses_only_named_collective() { + let set = MemberSet::Single(CollectiveId::Proposers); + let lookup = |id: CollectiveId, who: &u32| -> bool { + matches!(id, CollectiveId::Proposers) && *who == 7 + }; + assert!(set.contains_with(&7, lookup)); + assert!(!set.contains_with(&8, lookup)); + } + + #[test] + fn union_contains_short_circuits_on_first_match() { + let set = MemberSet::Union(alloc::vec![CollectiveId::Economic, CollectiveId::Building,]); + let lookup = |id: CollectiveId, who: &u32| -> bool { + matches!(id, CollectiveId::Building) && *who == 42 + }; + assert!(set.contains_with(&42, lookup)); + assert!(!set.contains_with(&1, lookup)); + } +} diff --git a/runtime/src/governance/mod.rs b/runtime/src/governance/mod.rs new file mode 100644 index 0000000000..99b02316d7 --- /dev/null +++ b/runtime/src/governance/mod.rs @@ -0,0 +1,301 @@ +//! Runtime governance wiring. +//! +//! This module connects Subtensor's concrete governance model to three +//! generic pallets: +//! +//! - `pallet_multi_collective`: stores named membership sets. +//! - `pallet_referenda`: owns proposal lifecycle, scheduling, and root dispatch. +//! - `pallet_signed_voting`: records per-account aye/nay votes over referendum +//! voter-set snapshots. +//! +//! The runtime governance path is intentionally two-stage: +//! +//! 1. Track 0 (`triumvirate`) is the only directly-submittable track. Members +//! of the `Proposers` collective may submit root calls, and the +//! `Triumvirate` collective decides by 2-of-3 signed vote. +//! 2. Approval on track 0 delegates the call to track 1 (`review`). Track 1 has +//! `proposer_set: None`, so it cannot be submitted to directly. Its voters +//! are the deduplicated union of the `Economic` and `Building` collectives. +//! +//! Collective selection is split by stakeholder role: +//! +//! - `Economic` rotates to the top root-registered coldkeys by governance +//! stake-value EMA. +//! - `Building` rotates to the top subnet-owner coldkeys by each owner's best +//! mature subnet moving price. +//! - `EconomicEligible` is a non-voting staging set synchronized from root +//! registration and used as the candidate pool for `Economic`. +//! +//! Keep the safety invariants close to the code: +//! +//! - `CollectiveId` codec indices are consensus-facing. +//! - Track 1 must remain non-submittable; otherwise proposers could bypass +//! Triumvirate approval and schedule root calls straight into review. +//! - Signed-voting snapshots voter sets at poll creation, so rotations do not +//! change eligibility for already-open referenda. +//! +//! See `runtime/src/governance/README.md` for the full operator-facing +//! explanation and selection details. + +mod collectives; +mod ema_provider; +mod member_set; +mod term_management; +mod tracks; +mod weights; + +#[cfg(feature = "runtime-benchmarks")] +pub mod benchmarking; + +pub use self::collectives::*; +pub use self::ema_provider::*; +pub use self::member_set::*; +pub use self::term_management::*; +pub use self::tracks::*; + +use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use frame_support::parameter_types; +use frame_support::traits::AsEnsureOriginWithArg; +use frame_system::EnsureRoot; +use scale_info::TypeInfo; + +use crate::{ + AccountId, Preimage, Referenda, Runtime, RuntimeCall, Scheduler, SignedVoting, System, +}; + +parameter_types! { + /// Storage cap shared by all collectives; sized for the widest one + /// (`EconomicEligible`). Per-collective `info.max_members` are the + /// logical caps; this is just the `BoundedVec` capacity. + pub const MaxMembers: u32 = collectives::ECONOMIC_ELIGIBLE_SIZE; +} + +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; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = MultiCollectiveBenchmarkHelper; +} + +#[cfg(feature = "runtime-benchmarks")] +pub struct MultiCollectiveBenchmarkHelper; + +#[cfg(feature = "runtime-benchmarks")] +impl pallet_multi_collective::BenchmarkHelper for MultiCollectiveBenchmarkHelper { + fn collective() -> CollectiveId { + CollectiveId::EconomicEligible + } + + fn rotatable_collective() -> CollectiveId { + CollectiveId::Economic + } +} + +/// Voting scheme for each referenda track. +#[derive( + Copy, + Clone, + PartialEq, + Eq, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, +)] +pub enum VotingScheme { + Signed, +} + +parameter_types! { + pub const Scheme: VotingScheme = VotingScheme::Signed; + /// Headroom over the widest track's voter set (see guard below). + pub const MaxVoterSetSize: u32 = 64; + /// 2x `MaxQueued` for headroom; queue overflow leaks `VotingFor` storage. + pub const MaxPendingCleanup: u32 = 40; + /// `VotingFor` entries drained per `on_idle` step. A full poll drains + /// in `MaxVoterSetSize / CleanupChunkSize` idle blocks. + pub const CleanupChunkSize: u32 = 16; + /// Resume cursor for chunked cleanup; 128 bytes covers any FRAME + /// double-map partial trie key. + pub const CleanupCursorMaxLen: u32 = 128; +} + +impl pallet_signed_voting::Config for Runtime { + type Scheme = Scheme; + type Polls = Referenda; + type MaxVoterSetSize = MaxVoterSetSize; + type MaxPendingCleanup = MaxPendingCleanup; + type CleanupChunkSize = CleanupChunkSize; + type CleanupCursorMaxLen = CleanupCursorMaxLen; + type WeightInfo = pallet_signed_voting::weights::SubstrateWeight; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = SignedVotingBenchmarkHelper; +} + +#[cfg(feature = "runtime-benchmarks")] +pub struct SignedVotingBenchmarkHelper; + +#[cfg(feature = "runtime-benchmarks")] +impl pallet_signed_voting::benchmarking::BenchmarkHelper for SignedVotingBenchmarkHelper { + #[allow(clippy::expect_used)] + fn ongoing_poll() -> u32 { + use self::ReferendaBenchmarkHelper as RBH; + use pallet_referenda::{ + BenchmarkHelper as BH, ReferendumCount, ReferendumStatus, ReferendumStatusFor, + }; + use sp_runtime::Perbill; + use subtensor_runtime_common::VoteTally; + + let proposer = >::proposer(); + >::seed_collective_members(); + let track = >::track_passorfail(); + let call = >::call(); + let parent = ReferendumCount::::get(); + + Referenda::submit( + frame_system::RawOrigin::Signed(proposer).into(), + track, + sp_std::boxed::Box::new(call), + ) + .expect("submit must succeed in benchmark setup"); + + let child = ReferendumCount::::get(); + let mut info = match ReferendumStatusFor::::get(parent) { + Some(ReferendumStatus::Ongoing(info)) => info, + _ => panic!("expected ongoing referendum"), + }; + info.tally = VoteTally { + approval: Perbill::one(), + rejection: Perbill::zero(), + abstention: Perbill::zero(), + }; + ReferendumStatusFor::::insert(parent, ReferendumStatus::Ongoing(info)); + + Referenda::advance_referendum(frame_system::RawOrigin::Root.into(), parent) + .expect("advance must create review poll in benchmark setup"); + assert!(matches!( + ReferendumStatusFor::::get(child), + Some(ReferendumStatus::Ongoing(_)) + )); + child + } +} + +parameter_types! { + pub const MaxQueued: u32 = 20; + pub const MaxActivePerProposer: u32 = 5; +} + +impl pallet_referenda::Config for Runtime { + type RuntimeCall = RuntimeCall; + type Scheduler = Scheduler; + type Preimages = Preimage; + type MaxQueued = MaxQueued; + type MaxActivePerProposer = MaxActivePerProposer; + type KillOrigin = EnsureRoot; + type Tracks = tracks::Tracks; + type AdjustmentCurve = tracks::EaseOutAdjustmentCurve; + type BlockNumberProvider = System; + type OnPollCreated = SignedVoting; + type OnPollCompleted = SignedVoting; + type WeightInfo = pallet_referenda::weights::SubstrateWeight; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = ReferendaBenchmarkHelper; +} + +#[cfg(feature = "runtime-benchmarks")] +pub struct ReferendaBenchmarkHelper; + +#[cfg(feature = "runtime-benchmarks")] +#[allow(clippy::expect_used)] +impl pallet_referenda::BenchmarkHelper for ReferendaBenchmarkHelper { + fn track_passorfail() -> u8 { + 0 + } + + fn track_adjustable() -> u8 { + 1 + } + + fn proposer() -> AccountId { + use frame_system::RawOrigin; + use pallet_multi_collective::Pallet as MultiCollective; + use sp_core::crypto::AccountId32; + + let proposer: AccountId = AccountId32::new([1u8; 32]).into(); + MultiCollective::::add_member( + RawOrigin::Root.into(), + CollectiveId::Proposers, + proposer.clone(), + ) + .expect("add proposer must succeed in benchmark setup"); + + proposer + } + + fn seed_collective_members() { + use frame_system::RawOrigin; + use pallet_multi_collective::Pallet as MultiCollective; + use sp_core::crypto::AccountId32; + + MultiCollective::::add_member( + RawOrigin::Root.into(), + CollectiveId::Triumvirate, + AccountId32::new([2u8; 32]).into(), + ) + .expect("add triumvirate member must succeed in benchmark setup"); + MultiCollective::::add_member( + RawOrigin::Root.into(), + CollectiveId::Economic, + AccountId32::new([3u8; 32]).into(), + ) + .expect("add economic member must succeed in benchmark setup"); + MultiCollective::::add_member( + RawOrigin::Root.into(), + CollectiveId::Building, + AccountId32::new([4u8; 32]).into(), + ) + .expect("add building member must succeed in benchmark setup"); + } + + fn call() -> RuntimeCall { + RuntimeCall::System(frame_system::Call::remark { + remark: alloc::vec![], + }) + } +} + +// Compile-time guards on the relationships between the constants above. +// A misconfiguration here would degrade signed-voting silently (oversized +// voter set collapses to an empty snapshot, queue overflow leaks state), +// so catch the obvious foot-guns at build time. +const _: () = { + // The widest track today is `Union(Economic, Building)`. Union members + // can overlap (a coldkey may sit in both), so this sum is an upper + // bound on the voter set's true cardinality before `MemberSet::Union`'s + // dedup runs. + let widest_union = (collectives::ECONOMIC_SIZE as u64) + (collectives::BUILDING_SIZE as u64); + assert!( + MaxVoterSetSize::get() as u64 >= widest_union, + "MaxVoterSetSize must fit the widest track's voter set", + ); + assert!( + MaxVoterSetSize::get() >= MaxMembers::get(), + "MaxVoterSetSize must fit any single-collective track", + ); + assert!( + MaxPendingCleanup::get() >= MaxQueued::get(), + "MaxPendingCleanup must absorb at least one full simultaneous-completion event from `pallet-referenda`", + ); +}; diff --git a/runtime/src/governance/term_management.rs b/runtime/src/governance/term_management.rs new file mode 100644 index 0000000000..fc1437c4b8 --- /dev/null +++ b/runtime/src/governance/term_management.rs @@ -0,0 +1,430 @@ +use alloc::vec::Vec; + +use frame_support::pallet_prelude::*; +use pallet_multi_collective::{ + CollectiveInspect, OnNewTerm, Pallet as MultiCollective, + weights::WeightInfo as MultiCollectiveWeightInfo, +}; +use pallet_subtensor::{Pallet as Subtensor, *}; +use sp_runtime::traits::UniqueSaturatedInto; +use substrate_fixed::types::{I96F32, U64F64}; + +use crate::{AccountId, BlockNumber, Runtime}; + +use super::collectives::{BUILDING_SIZE, CollectiveId, ECONOMIC_SIZE, MIN_SUBNET_AGE}; +use super::weights::{SubstrateWeight as GovernanceWeight, WeightInfo as GovernanceWeightInfo}; + +/// Minimum root-registered EMA samples before Economic eligibility. +/// With the current sampler cadence, 210 is roughly 30 days. +pub const ECONOMIC_ELIGIBILITY_THRESHOLD: u32 = 210; + +/// Runtime rotation policy for rotating collectives. +pub struct TermManagement; + +impl OnNewTerm for TermManagement { + fn weight() -> Weight { + [ + GovernanceWeight::::rotate_economic(), + GovernanceWeight::::rotate_building(), + ] + .into_iter() + .max_by_key(Weight::ref_time) + .unwrap_or_default() + } + + fn on_new_term(collective_id: CollectiveId) -> Weight { + // Curated collectives are managed outside this rotation policy. + match collective_id { + CollectiveId::Economic => Self::rotate_economic(), + CollectiveId::Building => Self::rotate_building(), + _ => Weight::zero(), + } + } +} + +impl TermManagement { + pub(crate) fn rotate_economic() -> Weight { + let (members, query_weight) = Self::top_validators(ECONOMIC_SIZE); + Self::apply_rotation(CollectiveId::Economic, members, query_weight) + } + + pub(crate) fn rotate_building() -> Weight { + let (members, query_weight) = Self::top_subnet_owners(BUILDING_SIZE, MIN_SUBNET_AGE); + Self::apply_rotation(CollectiveId::Building, members, query_weight) + } + + /// Top validator coldkeys by smoothed root-registered value. + pub fn top_validators(n: u32) -> (Vec, Weight) { + let db = ::DbWeight::get(); + let eligible = + as CollectiveInspect>::members_of( + CollectiveId::EconomicEligible, + ); + let mut weight = db.reads(1); + + let entries: Vec<(AccountId, U64F64)> = eligible + .into_iter() + .filter_map(|coldkey| { + weight.saturating_accrue(db.reads(1)); + let state = RootRegisteredEma::::get(&coldkey); + (state.samples >= ECONOMIC_ELIGIBILITY_THRESHOLD).then_some((coldkey, state.ema)) + }) + .collect(); + + (rank_top_n(entries, n), weight) + } + + /// Top subnet-owner coldkeys by their best mature subnet price. + pub fn top_subnet_owners(n: u32, min_age: BlockNumber) -> (Vec, Weight) { + let mut weight = Weight::zero(); + let now: u64 = >::block_number().into(); + let min_age_u64: u64 = min_age.into(); + + let mut entries: Vec<(AccountId, I96F32)> = Vec::new(); + for netuid in Subtensor::::get_all_subnet_netuids() { + weight.saturating_accrue(::DbWeight::get().reads(3)); + let registered_at: u64 = NetworkRegisteredAt::::get(netuid); + if now.saturating_sub(registered_at) < min_age_u64 { + continue; + } + let price = SubnetMovingPrice::::get(netuid); + let owner = SubnetOwner::::get(netuid); + merge_owner_by_highest_price(&mut entries, owner, price); + } + + (rank_top_n(entries, n), weight) + } + + /// Apply a rotated membership through the collective pallet. + fn apply_rotation( + collective_id: CollectiveId, + members: Vec, + query_weight: Weight, + ) -> Weight { + let result = MultiCollective::::do_set_members(collective_id, members); + + if let Err(err) = result { + log::error!( + target: "runtime::collective-management", + "rotation failed for {:?}: {:?}", + collective_id, + err, + ); + } + + query_weight + .saturating_add(::WeightInfo::set_members()) + } +} + +/// Sort by descending score and return the first `n` keys. +fn rank_top_n(mut entries: Vec<(K, S)>, n: u32) -> Vec { + entries.sort_by(|a, b| b.1.cmp(&a.1)); + entries.truncate(n.unique_saturated_into()); + entries.into_iter().map(|(k, _)| k).collect() +} + +/// Keep only an owner's highest observed subnet price. +fn merge_owner_by_highest_price( + entries: &mut Vec<(A, I96F32)>, + owner: A, + price: I96F32, +) { + if let Some(existing) = entries.iter_mut().find(|(o, _)| *o == owner) { + if price > existing.1 { + existing.1 = price; + } + } else { + entries.push((owner, price)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use pallet_subtensor::root_registered::EmaState; + use sp_runtime::BuildStorage; + use subtensor_runtime_common::NetUid; + + fn new_test_ext() -> sp_io::TestExternalities { + let storage = match (crate::RuntimeGenesisConfig { + sudo: pallet_sudo::GenesisConfig { key: None }, + ..Default::default() + }) + .build_storage() + { + Ok(storage) => storage, + Err(err) => panic!("failed to build test storage: {err:?}"), + }; + let mut ext: sp_io::TestExternalities = storage.into(); + ext.execute_with(|| crate::System::set_block_number(1)); + ext + } + + fn account(seed: u8) -> AccountId { + AccountId::from([seed; 32]) + } + + fn accounts(start: u8, count: u32) -> Vec { + (0..count) + .map(|offset| account(start + offset as u8)) + .collect() + } + + fn rank_entry(key: u32, score: u64) -> (u32, U64F64) { + (key, U64F64::from_num(score)) + } + + fn price(value: i64) -> I96F32 { + I96F32::from_num(value) + } + + fn set_members(collective_id: CollectiveId, members: Vec) { + assert!( + MultiCollective::::set_members( + frame_system::RawOrigin::Root.into(), + collective_id, + members, + ) + .is_ok() + ); + } + + fn members_of(collective_id: CollectiveId) -> Vec { + as CollectiveInspect>::members_of( + collective_id, + ) + } + + fn set_ema(coldkey: &AccountId, ema: u64, samples: u32) { + RootRegisteredEma::::insert( + coldkey, + EmaState { + ema: U64F64::from_num(ema), + samples, + }, + ); + } + + fn seed_subnet(netuid: NetUid, owner: AccountId, price: i64, registered_at: u64) { + Subtensor::::init_new_network(netuid, 1); + NetworkRegisteredAt::::insert(netuid, registered_at); + SubnetMovingPrice::::insert(netuid, I96F32::from_num(price)); + SubnetOwner::::insert(netuid, owner); + } + + #[test] + fn rank_top_n_truncates_to_n() { + let result = rank_top_n( + vec![ + rank_entry(1, 10), + rank_entry(2, 30), + rank_entry(3, 20), + rank_entry(4, 40), + ], + 2, + ); + assert_eq!(result, vec![4, 2]); + } + + #[test] + fn rank_top_n_zero_returns_empty() { + let result = rank_top_n(vec![rank_entry(1, 10), rank_entry(2, 30)], 0); + assert!(result.is_empty()); + } + + #[test] + fn rank_top_n_larger_than_input_returns_all_sorted() { + let result = rank_top_n(vec![rank_entry(1, 10), rank_entry(2, 30)], 100); + assert_eq!(result, vec![2, 1]); + } + + #[test] + fn rank_top_n_empty_input_returns_empty() { + let result = rank_top_n::(vec![], 5); + assert!(result.is_empty()); + } + + #[test] + fn rank_top_n_ties_preserve_insertion_order() { + let result = rank_top_n( + vec![rank_entry(1, 10), rank_entry(2, 10), rank_entry(3, 10)], + 2, + ); + assert_eq!(result, vec![1, 2]); + } + + #[test] + fn merge_inserts_first_observation() { + let mut entries: Vec<(u32, I96F32)> = Vec::new(); + merge_owner_by_highest_price(&mut entries, 7, price(100)); + assert_eq!(entries, vec![(7, price(100))]); + } + + #[test] + fn merge_upgrades_to_higher_price_for_same_owner() { + let mut entries = vec![(7, price(100))]; + merge_owner_by_highest_price(&mut entries, 7, price(250)); + assert_eq!(entries, vec![(7, price(250))]); + } + + #[test] + fn merge_keeps_existing_when_new_price_lower() { + let mut entries = vec![(7, price(250))]; + merge_owner_by_highest_price(&mut entries, 7, price(100)); + assert_eq!(entries, vec![(7, price(250))]); + } + + #[test] + fn merge_keeps_one_entry_with_highest_price_for_owner_with_multiple_subnets() { + let mut entries: Vec<(u32, I96F32)> = Vec::new(); + merge_owner_by_highest_price(&mut entries, 7, price(100)); + merge_owner_by_highest_price(&mut entries, 8, price(200)); + merge_owner_by_highest_price(&mut entries, 7, price(300)); + assert_eq!(entries, vec![(7, price(300)), (8, price(200))]); + } + + #[test] + fn top_validators_rank_by_ema_after_sample_threshold() { + new_test_ext().execute_with(|| { + let exact_threshold = account(1); + let above_threshold = account(2); + let below_threshold = account(3); + set_members( + CollectiveId::EconomicEligible, + vec![ + exact_threshold.clone(), + above_threshold.clone(), + below_threshold.clone(), + ], + ); + set_ema(&exact_threshold, 100, ECONOMIC_ELIGIBILITY_THRESHOLD); + set_ema( + &above_threshold, + 50, + ECONOMIC_ELIGIBILITY_THRESHOLD.saturating_add(1), + ); + set_ema( + &below_threshold, + 1_000, + ECONOMIC_ELIGIBILITY_THRESHOLD.saturating_sub(1), + ); + + let (members, weight) = TermManagement::top_validators(2); + + assert_eq!(members, vec![exact_threshold, above_threshold]); + assert!(weight.ref_time() > 0); + }); + } + + #[test] + fn top_validators_returns_empty_when_no_candidate_has_enough_samples() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + set_members(CollectiveId::EconomicEligible, vec![coldkey.clone()]); + set_ema( + &coldkey, + 1_000, + ECONOMIC_ELIGIBILITY_THRESHOLD.saturating_sub(1), + ); + + let (members, _) = TermManagement::top_validators(ECONOMIC_SIZE); + + assert!(members.is_empty()); + }); + } + + #[test] + fn top_validators_zero_limit_returns_empty() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + set_members(CollectiveId::EconomicEligible, vec![coldkey.clone()]); + set_ema(&coldkey, 1_000, ECONOMIC_ELIGIBILITY_THRESHOLD); + + let (members, _) = TermManagement::top_validators(0); + + assert!(members.is_empty()); + }); + } + + #[test] + fn rotate_economic_keeps_old_members_when_validator_set_is_underfilled() { + new_test_ext().execute_with(|| { + let old_members = accounts(10, ECONOMIC_SIZE); + let candidate = account(1); + set_members(CollectiveId::Economic, old_members.clone()); + set_members(CollectiveId::EconomicEligible, vec![candidate.clone()]); + set_ema(&candidate, 1_000, ECONOMIC_ELIGIBILITY_THRESHOLD); + + let weight = TermManagement::rotate_economic(); + + assert!(weight.ref_time() > 0); + assert_eq!(members_of(CollectiveId::Economic), old_members); + }); + } + + #[test] + fn top_subnet_owners_ranks_best_mature_subnet_per_owner() { + new_test_ext().execute_with(|| { + crate::System::set_block_number(1_000); + let owner_a = account(1); + let owner_b = account(2); + let immature_owner = account(3); + + seed_subnet(NetUid::from(1_000), owner_a.clone(), 10, 700); + seed_subnet(NetUid::from(1_001), owner_a.clone(), 30, 800); + seed_subnet(NetUid::from(1_002), owner_b.clone(), 20, 750); + seed_subnet(NetUid::from(1_003), immature_owner, 100, 950); + + let (members, weight) = TermManagement::top_subnet_owners(2, 100); + + assert_eq!(members, vec![owner_a, owner_b]); + assert!(weight.ref_time() > 0); + }); + } + + #[test] + fn rotate_building_keeps_old_members_when_owner_set_is_underfilled() { + new_test_ext().execute_with(|| { + crate::System::set_block_number(1_000); + let old_members = accounts(20, BUILDING_SIZE); + let candidate = account(1); + set_members(CollectiveId::Building, old_members.clone()); + seed_subnet(NetUid::from(1_000), candidate, 10, 0); + + let weight = TermManagement::rotate_building(); + + assert!(weight.ref_time() > 0); + assert_eq!(members_of(CollectiveId::Building), old_members); + }); + } + + #[test] + fn top_subnet_owners_includes_exact_min_age_boundary() { + new_test_ext().execute_with(|| { + crate::System::set_block_number(1_000); + let exact_age_owner = account(1); + let too_young_owner = account(2); + + seed_subnet(NetUid::from(1_000), exact_age_owner.clone(), 10, 900); + seed_subnet(NetUid::from(1_001), too_young_owner, 100, 901); + + let (members, _) = TermManagement::top_subnet_owners(1, 100); + + assert_eq!(members, vec![exact_age_owner]); + }); + } + + #[test] + fn top_subnet_owners_zero_limit_returns_empty() { + new_test_ext().execute_with(|| { + crate::System::set_block_number(1_000); + seed_subnet(NetUid::from(1_000), account(1), 10, 0); + + let (members, _) = TermManagement::top_subnet_owners(0, 100); + + assert!(members.is_empty()); + }); + } +} diff --git a/runtime/src/governance/tracks.rs b/runtime/src/governance/tracks.rs new file mode 100644 index 0000000000..0556013f7e --- /dev/null +++ b/runtime/src/governance/tracks.rs @@ -0,0 +1,179 @@ +//! Static governance tracks: Triumvirate approval, then collective review. + +use pallet_referenda::{ + AdjustmentCurve, ApprovalAction, DecisionStrategy, MAX_TRACK_NAME_LEN, Track as RefTrack, + TrackInfo as RefTrackInfo, TracksInfo as RefTracksInfo, +}; +use runtime_common::prod_or_fast; +use safe_math::SafeDiv; +use sp_runtime::{Perbill, traits::UniqueSaturatedInto}; +use subtensor_runtime_common::{ + pad_name, + time::{DAYS, HOURS}, +}; + +use super::collectives::CollectiveId; +use super::{MemberSet, VotingScheme}; +use crate::{AccountId, BlockNumber, RuntimeCall}; + +const TRIUMVIRATE_DECISION_PERIOD: BlockNumber = prod_or_fast!(7 * DAYS, 50); + +const REVIEW_INITIAL_DELAY: BlockNumber = prod_or_fast!(24 * HOURS, 30); + +const TRIUMVIRATE_TRACK_ID: u8 = 0; +const REVIEW_TRACK_ID: u8 = 1; + +/// Upper bound on the Review dispatch delay, reached as net rejection +/// approaches `cancel_threshold`. +const REVIEW_MAX_DELAY: BlockNumber = prod_or_fast!(2 * DAYS, 60); + +/// Ease-out curve for review delay adjustment: `1 - (1 - p)^3`. +/// +/// Early collective signal has a visible effect on the dispatch time, while +/// additional votes near the threshold taper off before the hard fast-track +/// or cancel threshold concludes the referendum. +pub struct EaseOutAdjustmentCurve; +impl AdjustmentCurve for EaseOutAdjustmentCurve { + fn apply(progress: Perbill) -> Perbill { + let scale = u128::from(Perbill::from_percent(100).deconstruct()); + let remaining = scale.saturating_sub(u128::from(progress.deconstruct())); + let remaining_cubed = remaining + .saturating_mul(remaining) + .saturating_mul(remaining) + .safe_div(scale) + .safe_div(scale); + let curved = scale.saturating_sub(remaining_cubed); + + Perbill::from_parts(curved.min(scale).unique_saturated_into()) + } +} + +pub struct Tracks; +impl RefTracksInfo<[u8; MAX_TRACK_NAME_LEN], AccountId, RuntimeCall, BlockNumber> for Tracks { + type Id = u8; + type ProposerSet = MemberSet; + type VotingScheme = VotingScheme; + type VoterSet = MemberSet; + + fn tracks() -> impl Iterator< + Item = RefTrack< + Self::Id, + [u8; MAX_TRACK_NAME_LEN], + BlockNumber, + Self::ProposerSet, + Self::VoterSet, + Self::VotingScheme, + >, + > { + [ + RefTrack { + id: TRIUMVIRATE_TRACK_ID, + info: RefTrackInfo { + name: pad_name(b"triumvirate"), + proposer_set: Some(MemberSet::Single(CollectiveId::Proposers)), + voter_set: MemberSet::Single(CollectiveId::Triumvirate), + voting_scheme: VotingScheme::Signed, + decision_strategy: DecisionStrategy::PassOrFail { + decision_period: TRIUMVIRATE_DECISION_PERIOD, + approve_threshold: Perbill::from_rational(2u32, 3u32), + reject_threshold: Perbill::from_rational(2u32, 3u32), + // Triumvirate approval still gets a wider review + // window before enactment. + on_approval: ApprovalAction::Review { + track: REVIEW_TRACK_ID, + }, + }, + }, + }, + // `proposer_set: None` is load-bearing: it makes track 1 reachable + // only via Track 0's `ApprovalAction::Review` handoff. Setting it + // to `Some(_)` would let a proposer schedule a root call for + // auto-dispatch at `now + initial_delay`, bypassing Triumvirate + // approval. + RefTrack { + id: REVIEW_TRACK_ID, + info: RefTrackInfo { + name: pad_name(b"review"), + proposer_set: None, + voter_set: MemberSet::Union(alloc::vec![ + CollectiveId::Economic, + CollectiveId::Building, + ]), + voting_scheme: VotingScheme::Signed, + decision_strategy: DecisionStrategy::Adjustable { + initial_delay: REVIEW_INITIAL_DELAY, + max_delay: REVIEW_MAX_DELAY, + fast_track_threshold: Perbill::from_percent(75), + cancel_threshold: Perbill::from_percent(51), + }, + }, + }, + ] + .into_iter() + } +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + use pallet_referenda::TracksInfo; + + fn track( + id: u8, + ) -> RefTrack + { + Tracks::tracks() + .find(|track| track.id == id) + .expect("track must exist") + } + + #[test] + fn track_0_triumvirate_is_directly_submittable() { + let track_0 = track(TRIUMVIRATE_TRACK_ID); + + assert!( + track_0.info.proposer_set.is_some(), + "track 0 must have a proposer_set; without it there is no \ + on-chain entry point into governance." + ); + + match track_0.info.decision_strategy { + DecisionStrategy::PassOrFail { + on_approval: ApprovalAction::Review { track }, + .. + } => assert_eq!( + track, REVIEW_TRACK_ID, + "track 0 approval must hand off to the review track" + ), + other => panic!("track 0 must stay PassOrFail with review handoff, got {other:?}"), + } + } + + #[test] + fn track_1_review_is_not_directly_submittable() { + let track_1 = track(REVIEW_TRACK_ID); + + assert!( + track_1.info.proposer_set.is_none(), + "track 1 must have proposer_set: None; Some(_) would let a \ + proposer schedule a root call without Triumvirate approval." + ); + } + + #[test] + fn ease_out_curve_uses_cubic_complement() { + assert_eq!( + EaseOutAdjustmentCurve::apply(Perbill::from_percent(0)), + Perbill::from_percent(0), + ); + assert_eq!( + EaseOutAdjustmentCurve::apply(Perbill::from_percent(50)), + Perbill::from_rational(7u32, 8u32), + ); + assert_eq!( + EaseOutAdjustmentCurve::apply(Perbill::from_percent(100)), + Perbill::from_percent(100), + ); + } +} diff --git a/runtime/src/governance/weights.rs b/runtime/src/governance/weights.rs new file mode 100644 index 0000000000..666bcc0dc7 --- /dev/null +++ b/runtime/src/governance/weights.rs @@ -0,0 +1,147 @@ + +//! Autogenerated weights for `governance` +//! +//! 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=governance +// --extrinsic=* +// --steps=50 +// --repeat=20 +// --no-storage-info +// --no-min-squares +// --no-median-slopes +// --output=/tmp/tmp.JF1LCW5Q9K +// --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::RocksDbWeight}}; +use core::marker::PhantomData; + +/// Weight functions needed for `governance`. +pub trait WeightInfo { + fn stake_ema_provider_step() -> Weight; + fn rotate_economic() -> Weight; + fn rotate_building() -> Weight; +} + +/// Weights for `governance` using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + /// Storage: `SubtensorModule::NetworksAdded` (r:11 w:0) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::OwnedHotkeys` (r:1 w:0) + /// Proof: `SubtensorModule::OwnedHotkeys` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeyAlpha` (r:2048 w:0) + /// Proof: `SubtensorModule::TotalHotkeyAlpha` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMechanism` (r:7 w:0) + /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn stake_ema_provider_step() -> Weight { + // Proof Size summary in bytes: + // Measured: `48134` + // Estimated: `5117924` + // Minimum execution time: 6_809_228_000 picoseconds. + Weight::from_parts(6_912_642_000, 5117924) + .saturating_add(T::DbWeight::get().reads(2067_u64)) + } + /// Storage: `MultiCollective::Members` (r:2 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::RootRegisteredEma` (r:64 w:0) + /// Proof: `SubtensorModule::RootRegisteredEma` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn rotate_economic() -> Weight { + // Proof Size summary in bytes: + // Measured: `7996` + // Estimated: `167386` + // Minimum execution time: 280_504_000 picoseconds. + Weight::from_parts(284_522_000, 167386) + .saturating_add(T::DbWeight::get().reads(66_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `SubtensorModule::NetworksAdded` (r:131 w:0) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegisteredAt` (r:130 w:0) + /// Proof: `SubtensorModule::NetworkRegisteredAt` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMovingPrice` (r:130 w:0) + /// Proof: `SubtensorModule::SubnetMovingPrice` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetOwner` (r:130 w:0) + /// Proof: `SubtensorModule::SubnetOwner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn rotate_building() -> Weight { + // Proof Size summary in bytes: + // Measured: `11112` + // Estimated: `336327` + // Minimum execution time: 1_106_639_000 picoseconds. + Weight::from_parts(1_118_871_000, 336327) + .saturating_add(T::DbWeight::get().reads(522_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } +} + +// For backwards compatibility and tests. +impl WeightInfo for () { + /// Storage: `SubtensorModule::NetworksAdded` (r:11 w:0) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::OwnedHotkeys` (r:1 w:0) + /// Proof: `SubtensorModule::OwnedHotkeys` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeyAlpha` (r:2048 w:0) + /// Proof: `SubtensorModule::TotalHotkeyAlpha` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMechanism` (r:7 w:0) + /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn stake_ema_provider_step() -> Weight { + // Proof Size summary in bytes: + // Measured: `48134` + // Estimated: `5117924` + // Minimum execution time: 6_809_228_000 picoseconds. + Weight::from_parts(6_912_642_000, 5117924) + .saturating_add(RocksDbWeight::get().reads(2067_u64)) + } + /// Storage: `MultiCollective::Members` (r:2 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::RootRegisteredEma` (r:64 w:0) + /// Proof: `SubtensorModule::RootRegisteredEma` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn rotate_economic() -> Weight { + // Proof Size summary in bytes: + // Measured: `7996` + // Estimated: `167386` + // Minimum execution time: 280_504_000 picoseconds. + Weight::from_parts(284_522_000, 167386) + .saturating_add(RocksDbWeight::get().reads(66_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `SubtensorModule::NetworksAdded` (r:131 w:0) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegisteredAt` (r:130 w:0) + /// Proof: `SubtensorModule::NetworkRegisteredAt` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMovingPrice` (r:130 w:0) + /// Proof: `SubtensorModule::SubnetMovingPrice` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetOwner` (r:130 w:0) + /// Proof: `SubtensorModule::SubnetOwner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn rotate_building() -> Weight { + // Proof Size summary in bytes: + // Measured: `11112` + // Estimated: `336327` + // Minimum execution time: 1_106_639_000 picoseconds. + Weight::from_parts(1_118_871_000, 336327) + .saturating_add(RocksDbWeight::get().reads(522_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } +} diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 2c4fa3d686..5d7e853b07 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -12,6 +12,7 @@ use core::num::NonZeroU64; pub mod check_mortality; pub mod check_nonce; +pub mod governance; pub mod sudo_wrapper; pub mod transaction_payment_wrapper; @@ -821,7 +822,6 @@ parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; pub const MaxScheduledPerBlock: u32 = 50; - pub const NoPreimagePostponement: Option = Some(10); } /// Used the compare the privilege of an origin inside the scheduler. @@ -1028,7 +1028,6 @@ parameter_types! { pub const InitialDissolveNetworkScheduleDuration: BlockNumber = 5 * 24 * 60 * 60 / 12; // 5 days pub const SubtensorInitialTaoWeight: u64 = 971_718_665_099_567_868; // 0.05267697438728329% tao weight. pub const InitialEmaPriceHalvingPeriod: u64 = 201_600_u64; // 4 weeks - // 0 days pub const InitialStartCallDelay: u64 = 0; pub const SubtensorInitialKeySwapOnSubnetCost: TaoBalance = TaoBalance::new(1_000_000); // 0.001 TAO pub const HotkeySwapOnSubnetInterval : BlockNumber = prod_or_fast!(24 * 60 * 60 / 12, 1); // 1 day @@ -1119,6 +1118,9 @@ impl pallet_subtensor::Config for Runtime { type AlphaAssets = AlphaAssets; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = BlockAuthorFromAura; + type OnRootRegistrationChange = governance::EconomicEligibleSync; + type RootRegisteredInspector = governance::EconomicEligibleInspector; + type EmaValueProvider = governance::StakeValueProvider; type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type InitialMaxEpochsPerBlock = SubtensorMaxEpochsPerBlock; @@ -1635,6 +1637,11 @@ construct_runtime!( MevShield: pallet_shield = 30, AlphaAssets: pallet_alpha_assets = 31, LimitOrders: pallet_limit_orders = 32, + + // Governance + MultiCollective: pallet_multi_collective = 33, + SignedVoting: pallet_signed_voting = 34, + Referenda: pallet_referenda = 35, } ); @@ -1720,6 +1727,10 @@ mod benches { [pallet_subtensor_proxy, Proxy] [pallet_subtensor_utility, Utility] [pallet_limit_orders, LimitOrders] + [pallet_referenda, Referenda] + [pallet_signed_voting, SignedVoting] + [pallet_multi_collective, MultiCollective] + [governance, GovernanceBench::] ); } @@ -2361,6 +2372,7 @@ impl_runtime_apis! { use frame_support::traits::StorageInfoTrait; use frame_system_benchmarking::Pallet as SystemBench; use baseline::Pallet as BaselineBench; + use governance::benchmarking::Pallet as GovernanceBench; let mut list = Vec::::new(); list_benchmarks!(list, extra); @@ -2378,6 +2390,7 @@ impl_runtime_apis! { use frame_system_benchmarking::Pallet as SystemBench; use baseline::Pallet as BaselineBench; + use governance::benchmarking::Pallet as GovernanceBench; #[allow(non_local_definitions)] impl frame_system_benchmarking::Config for Runtime {} diff --git a/scripts/benchmark_action.sh b/scripts/benchmark_action.sh index 2497956a84..578672821d 100755 --- a/scripts/benchmark_action.sh +++ b/scripts/benchmark_action.sh @@ -19,13 +19,13 @@ REPEAT="${REPEAT:-20}" die() { echo "ERROR: $1" >&2; exit 1; } -# ── Auto-discover pallets ──────────────────────────────────────────────────── +# ── Auto-discover benchmark targets ────────────────────────────────────────── declare -A OUTPUTS while read -r name path; do OUTPUTS[$name]="$path" done < <("$SCRIPT_DIR/discover_pallets.sh") -(( ${#OUTPUTS[@]} > 0 )) || die "no benchmarked pallets found" +(( ${#OUTPUTS[@]} > 0 )) || die "no benchmark targets found" mkdir -p "$PATCH_DIR" diff --git a/scripts/benchmark_all.sh b/scripts/benchmark_all.sh index d73af7cdc4..a9cd81562f 100755 --- a/scripts/benchmark_all.sh +++ b/scripts/benchmark_all.sh @@ -1,21 +1,25 @@ #!/usr/bin/env zsh set -euo pipefail -# Generate weights.rs files for all (or a single) pallet using the standard +# Generate weights.rs files for all (or a single) benchmark target using the standard # frame-benchmarking-cli --output / --template approach. # -# Pallets are auto-discovered: any pallet with both benchmarking.rs and -# weights.rs is included. If a pallet is missing from define_benchmarks! +# Targets are auto-discovered: pallets with both benchmarking.rs and +# weights.rs are included, plus runtime-owned targets listed by +# scripts/discover_pallets.sh. If a target is missing from define_benchmarks! # in runtime/src/lib.rs, the benchmark CLI will error — no silent failures. # # Usage: # ./scripts/benchmark_all.sh # build + generate all -# ./scripts/benchmark_all.sh pallet_subtensor # build + generate one pallet +# ./scripts/benchmark_all.sh pallet_subtensor # build + generate one target +# ./scripts/benchmark_all.sh governance # build + generate governance weights # SKIP_BUILD=1 ./scripts/benchmark_all.sh # skip cargo build SCRIPT_DIR="$(cd "$(dirname "${0}")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +export PATH="$HOME/.cargo/bin:$PATH" + RUNTIME_WASM="$ROOT_DIR/target/production/wbuild/node-subtensor-runtime/node_subtensor_runtime.compact.compressed.wasm" NODE_BIN="$ROOT_DIR/target/production/node-subtensor" TEMPLATE="$ROOT_DIR/.maintain/frame-weight-template.hbs" @@ -25,13 +29,13 @@ REPEAT="${REPEAT:-20}" die() { echo "ERROR: $1" >&2; exit 1; } -# ── Auto-discover pallets ──────────────────────────────────────────────────── +# ── Auto-discover benchmark targets ────────────────────────────────────────── typeset -A PALLET_OUTPUTS while read -r name output_path; do PALLET_OUTPUTS[$name]="$output_path" done < <("$SCRIPT_DIR/discover_pallets.sh") -(( ${#PALLET_OUTPUTS} > 0 )) || die "no benchmarked pallets found" +(( ${#PALLET_OUTPUTS} > 0 )) || die "no benchmark targets found" # ── Build ──────────────────────────────────────────────────────────────────── if [[ "${SKIP_BUILD:-0}" != "1" ]]; then @@ -43,11 +47,11 @@ fi [[ -f "$RUNTIME_WASM" ]] || die "runtime WASM not found at $RUNTIME_WASM" [[ -f "$TEMPLATE" ]] || die "weight template not found at $TEMPLATE" -# ── Determine which pallets to benchmark ───────────────────────────────────── +# ── Determine which targets to benchmark ───────────────────────────────────── if [[ $# -gt 0 ]]; then PALLETS=("$@") for p in "${PALLETS[@]}"; do - [[ -n "${PALLET_OUTPUTS[$p]:-}" ]] || die "unknown pallet: $p (available: ${(k)PALLET_OUTPUTS})" + [[ -n "${PALLET_OUTPUTS[$p]:-}" ]] || die "unknown benchmark target: $p (available: ${(k)PALLET_OUTPUTS})" done else PALLETS=("${(k)PALLET_OUTPUTS[@]}") diff --git a/scripts/discover_pallets.sh b/scripts/discover_pallets.sh index e42e6f7825..7685d15e36 100755 --- a/scripts/discover_pallets.sh +++ b/scripts/discover_pallets.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Auto-discover benchmarked pallets. +# Auto-discover benchmarked runtime benchmark targets. # # Finds all pallets under pallets/ that have both: # - src/benchmarking.rs (or src/benchmarks.rs) @@ -14,7 +14,10 @@ set -euo pipefail # # The pallet must also be present in the runtime benchmark registry. # -# Outputs one line per pallet: "pallet_name pallets//src/weights.rs" +# Also includes runtime-owned benchmark targets that are registered in +# runtime/src/lib.rs via define_benchmarks!. +# +# Outputs one line per target: "benchmark_name path/to/weights.rs" # The pallet name is derived from the Cargo.toml `name` field with dashes -> underscores. ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" @@ -50,4 +53,10 @@ for dir in "$ROOT_DIR"/pallets/*/; do relpath="pallets/$(basename "$dir")/src/weights.rs" echo "$name $relpath" -done \ No newline at end of file +done + +if printf '%s\n' "$RUNTIME_BENCHMARKS" | grep -qxF "governance" && \ + [ -f "$ROOT_DIR/runtime/src/governance/benchmarking.rs" ] && \ + [ -f "$ROOT_DIR/runtime/src/governance/weights.rs" ]; then + echo "governance runtime/src/governance/weights.rs" +fi diff --git a/support/procedural-fork/Cargo.toml b/support/procedural-fork/Cargo.toml index fdc280ec14..cc03c78242 100644 --- a/support/procedural-fork/Cargo.toml +++ b/support/procedural-fork/Cargo.toml @@ -10,7 +10,7 @@ all = "allow" derive-syn-parse.workspace = true Inflector.workspace = true cfg-expr.workspace = true -itertools.workspace = true +itertools = { workspace = true, features = ["use_alloc"] } proc-macro2.workspace = true quote.workspace = true syn = { workspace = true, features = [ diff --git a/ts-tests/moonwall.config.json b/ts-tests/moonwall.config.json index 710ea06d02..afe3e7cc3c 100644 --- a/ts-tests/moonwall.config.json +++ b/ts-tests/moonwall.config.json @@ -12,7 +12,8 @@ "suites/dev" ], "runScripts": [ - "generate-types.sh" + "generate-types.sh", + "build-upgrade-runtime.sh" ], "multiThreads": true, "reporters": ["basic"], @@ -41,6 +42,41 @@ ] } }, + { + "name": "dev_fast", + "timeout": 120000, + "envVars": ["DEBUG_COLORS=1"], + "testFileDir": [ + "suites/dev_fast" + ], + "runScripts": [ + "build-fast-runtime.sh" + ], + "multiThreads": true, + "reporters": ["basic"], + "foundation": { + "type": "dev", + "launchSpec": [ + { + "name": "subtensor", + "binPath": "../target/release-fast/node-subtensor", + "options": [ + "--one", + "--dev", + "--force-authoring", + "--rpc-cors=all", + "--no-prometheus", + "--no-telemetry", + "--reserved-only", + "--tmp", + "--sealing=manual" + ], + "disableDefaultEthProviders": true, + "newRpcBehaviour": true + } + ] + } + }, { "name": "zombienet_staking", "timeout": 600000, diff --git a/ts-tests/scripts/build-fast-runtime.sh b/ts-tests/scripts/build-fast-runtime.sh new file mode 100755 index 0000000000..fa5a2cc6e4 --- /dev/null +++ b/ts-tests/scripts/build-fast-runtime.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# +# Builds node-subtensor with --features fast-runtime, staging the binary at +# target/release-fast/node-subtensor so the prod build at target/release/ +# stays untouched (and the upgrade test keeps working against it). +# +# The fast-runtime build uses a dedicated CARGO_TARGET_DIR to avoid +# invalidating the prod build's incremental cache. +# +set -euo pipefail + +cd "$(dirname "$0")/.." +TS_TESTS_DIR="$(pwd)" +REPO_ROOT="$(cd .. && pwd)" + +OUTPUT_BIN="$REPO_ROOT/target/release-fast/node-subtensor" +FAST_TARGET_DIR="$TS_TESTS_DIR/tmp/cargo-target-fast" +BUILT_BIN="$FAST_TARGET_DIR/release/node-subtensor" + +# Skip if the staged binary is newer than every source file we care about. +# The set of paths mirrors what `cargo build -p node-subtensor` actually +# depends on; widen it if a future change moves source under a new prefix. +if [ -x "$OUTPUT_BIN" ]; then + newer=$(find \ + "$REPO_ROOT/runtime" \ + "$REPO_ROOT/common" \ + "$REPO_ROOT/pallets" \ + "$REPO_ROOT/node" \ + "$REPO_ROOT/primitives" \ + -name '*.rs' -newer "$OUTPUT_BIN" -print -quit 2>/dev/null || true) + if [ -z "$newer" ]; then + echo "==> $OUTPUT_BIN up-to-date, skipping fast-runtime build." + exit 0 + fi +fi + +echo "==> Building node-subtensor with --features fast-runtime" +echo " (CARGO_TARGET_DIR=$FAST_TARGET_DIR; first build is slow)" +( + cd "$REPO_ROOT" + CARGO_TARGET_DIR="$FAST_TARGET_DIR" \ + cargo build --release --features fast-runtime -p node-subtensor +) + +if [ ! -x "$BUILT_BIN" ]; then + echo "ERROR: expected binary not found at $BUILT_BIN" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$OUTPUT_BIN")" +cp "$BUILT_BIN" "$OUTPUT_BIN" +echo "==> Wrote $OUTPUT_BIN (fast-runtime)" diff --git a/ts-tests/scripts/build-upgrade-runtime.sh b/ts-tests/scripts/build-upgrade-runtime.sh new file mode 100755 index 0000000000..3dc576bc0e --- /dev/null +++ b/ts-tests/scripts/build-upgrade-runtime.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# +# Builds a runtime WASM with spec_version bumped by +1 +# +set -euo pipefail + +cd "$(dirname "$0")/.." +TS_TESTS_DIR="$(pwd)" +REPO_ROOT="$(cd .. && pwd)" + +LIB_RS="$REPO_ROOT/runtime/src/lib.rs" +RUNTIME_TOML="$REPO_ROOT/runtime/Cargo.toml" +OUTPUT_DIR="$TS_TESTS_DIR/tmp" +OUTPUT_WASM="$OUTPUT_DIR/upgraded-runtime.wasm" +UPGRADE_TARGET_DIR="$OUTPUT_DIR/cargo-target" +BUILT_WASM="$UPGRADE_TARGET_DIR/release/wbuild/node-subtensor-runtime/node_subtensor_runtime.compact.compressed.wasm" + +mkdir -p "$OUTPUT_DIR" + +# Skip if existing output is newer than every input source. +if [ -f "$OUTPUT_WASM" ] \ + && [ "$OUTPUT_WASM" -nt "$LIB_RS" ] \ + && [ "$OUTPUT_WASM" -nt "$RUNTIME_TOML" ]; then + echo "==> Upgraded runtime already up-to-date at $OUTPUT_WASM, skipping build." + exit 0 +fi + +# Read current spec_version from source. +CURRENT_VERSION=$(grep -E '^\s*spec_version:' "$LIB_RS" | head -1 | grep -oE '[0-9]+') +if [ -z "$CURRENT_VERSION" ]; then + echo "ERROR: failed to parse spec_version from $LIB_RS" >&2 + exit 1 +fi +NEW_VERSION=$((CURRENT_VERSION + 1)) +echo "==> Bumping spec_version: $CURRENT_VERSION -> $NEW_VERSION (transient, will be restored)" + +# Backup + always-restore guard. +BACKUP="$LIB_RS.upgrade-build-backup" +cp "$LIB_RS" "$BACKUP" +trap 'mv "$BACKUP" "$LIB_RS"' EXIT + +# In-place bump (BSD/macOS sed friendly: -i with empty suffix arg). +sed -i.tmp -E "s/^([[:space:]]*spec_version:[[:space:]]*)[0-9]+,/\1${NEW_VERSION},/" "$LIB_RS" +rm -f "$LIB_RS.tmp" + +echo "==> Building runtime crate (CARGO_TARGET_DIR=$UPGRADE_TARGET_DIR)" +echo " First build is slow (cold deps); subsequent runs are incremental." +( + cd "$REPO_ROOT" + CARGO_TARGET_DIR="$UPGRADE_TARGET_DIR" \ + cargo build --profile release -p node-subtensor-runtime +) + +if [ ! -f "$BUILT_WASM" ]; then + echo "ERROR: expected WASM not found at $BUILT_WASM" >&2 + exit 1 +fi + +cp "$BUILT_WASM" "$OUTPUT_WASM" +echo "==> Wrote $OUTPUT_WASM (spec_version=$NEW_VERSION)" diff --git a/ts-tests/suites/dev/subtensor/governance/test-capacity.ts b/ts-tests/suites/dev/subtensor/governance/test-capacity.ts new file mode 100644 index 0000000000..f617710c95 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-capacity.ts @@ -0,0 +1,143 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { + bootstrapMembership, + castVote, + DEV_TRACK, + fundAccounts, + type GovernanceMembership, + getActiveCount, + getActivePerProposer, + getStatusKind, + inBlock, + lastModuleError, + nudge, + submitOnTrack, + sudoInBlock, + systemEvents, +} from "../../../../utils/governance"; + +describeSuite({ + id: "DEV_SUB_GOV_CAPACITY_01", + title: "Governance — runtime referendum capacity limits", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + const idleProposer = generateKeyringPair("sr25519"); + const beneficiary = generateKeyringPair("sr25519"); + const remark = (amount: bigint) => api.tx.balances.forceSetBalance(beneficiary.address, amount); + + const MAX_QUEUED = 20; + const MAX_ACTIVE_PER_PROPOSER = 5; + const PROPOSERS_NEEDED = MAX_QUEUED / MAX_ACTIVE_PER_PROPOSER; + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + gov = await bootstrapMembership(api, context, sudoer, { + proposers: PROPOSERS_NEEDED, + triumvirate: 3, + economic: 1, + building: 1, + }); + + await fundAccounts(api, context, sudoer, [idleProposer.address]); + await inBlock( + context, + sudoer, + api.tx.sudo.sudo(api.tx.multiCollective.addMember("Proposers", idleProposer.address)) + ); + expect(await lastModuleError(api)).to.be.null; + }); + + it({ + id: "T01", + title: "runtime MaxActivePerProposer is enforced at five active referenda", + test: async () => { + const submitted: number[] = []; + for (let i = 0; i < MAX_ACTIVE_PER_PROPOSER; i++) { + submitted.push( + await submitOnTrack(api, context, gov.proposer, DEV_TRACK.TRIUMVIRATE, remark(BigInt(300 + i))) + ); + expect(await lastModuleError(api)).to.be.null; + } + expect(await getActivePerProposer(api, gov.proposer.address)).to.equal(MAX_ACTIVE_PER_PROPOSER); + + await inBlock(context, gov.proposer, api.tx.referenda.submit(DEV_TRACK.TRIUMVIRATE, remark(399n))); + expect(await lastModuleError(api)).to.deep.equal({ + section: "referenda", + name: "ProposerQuotaExceeded", + }); + + for (const index of submitted) { + await sudoInBlock(api, context, sudoer, api.tx.referenda.kill(index)); + } + expect(await getActivePerProposer(api, gov.proposer.address)).to.equal(0); + }, + }); + + it({ + id: "T02", + title: "delegation is quota-neutral in the concrete two-track runtime", + test: async () => { + const fresh = gov.proposers[1]; + expect(await getActivePerProposer(api, fresh.address)).to.equal(0); + + const parent = await submitOnTrack(api, context, fresh, DEV_TRACK.TRIUMVIRATE, remark(600n)); + expect(await getActivePerProposer(api, fresh.address)).to.equal(1); + + await castVote(api, context, gov.triumvirate[0], parent, true); + await castVote(api, context, gov.triumvirate[1], parent, true); + await nudge(context); + + expect(await getStatusKind(api, parent)).to.equal("delegated"); + expect(await getActivePerProposer(api, fresh.address)).to.equal(1); + + const delegated = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + const data = delegated?.event.data.toJSON() as { review?: number } & Array; + await sudoInBlock(api, context, sudoer, api.tx.referenda.kill(data.review ?? data[1])); + expect(await getActivePerProposer(api, fresh.address)).to.equal(0); + }, + }); + + it({ + id: "T03", + title: "with the queue at capacity, an idle proposer's submit fails with QueueFull", + test: async () => { + expect(await getActiveCount(api)).to.equal(0); + + for (let p = 0; p < PROPOSERS_NEEDED; p++) { + for (let i = 0; i < MAX_ACTIVE_PER_PROPOSER; i++) { + await submitOnTrack( + api, + context, + gov.proposers[p], + DEV_TRACK.TRIUMVIRATE, + api.tx.system.remark(`fill-${p}-${i}`) + ); + expect(await lastModuleError(api)).to.be.null; + } + } + expect(await getActiveCount(api)).to.equal(MAX_QUEUED); + + await inBlock( + context, + idleProposer, + api.tx.referenda.submit(DEV_TRACK.TRIUMVIRATE, api.tx.system.remark("21st-attempt")) + ); + expect(await lastModuleError(api)).to.deep.equal({ + section: "referenda", + name: "QueueFull", + }); + expect(await getActiveCount(api)).to.equal(MAX_QUEUED); + expect((await api.query.referenda.activePerProposer(idleProposer.address)).toJSON()).to.equal(0); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/governance/test-full-flow.ts b/ts-tests/suites/dev/subtensor/governance/test-full-flow.ts new file mode 100644 index 0000000000..d655ec9ed7 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-full-flow.ts @@ -0,0 +1,124 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { freeBalance, referendumCount, referendumStatusFor, systemEvents } from "../../../../utils/governance"; + +describeSuite({ + id: "DEV_SUB_GOV_FULLFLOW_01", + title: "Governance — full two-phase flow (track 0 + track 1)", + foundationMethods: "dev", + testCases: ({ it, context, log }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + + const proposer = generateKeyringPair("sr25519"); + const triumvirate1 = generateKeyringPair("sr25519"); + const triumvirate2 = generateKeyringPair("sr25519"); + const triumvirate3 = generateKeyringPair("sr25519"); + const economic1 = generateKeyringPair("sr25519"); + const economic2 = generateKeyringPair("sr25519"); + const building1 = generateKeyringPair("sr25519"); + const building2 = generateKeyringPair("sr25519"); + const target = generateKeyringPair("sr25519"); + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + + const fund = 1_000_000_000_000n; + for (const inner of [ + api.tx.balances.forceSetBalance(proposer.address, fund), + api.tx.balances.forceSetBalance(triumvirate1.address, fund), + api.tx.balances.forceSetBalance(triumvirate2.address, fund), + api.tx.balances.forceSetBalance(triumvirate3.address, fund), + api.tx.balances.forceSetBalance(economic1.address, fund), + api.tx.balances.forceSetBalance(economic2.address, fund), + api.tx.balances.forceSetBalance(building1.address, fund), + api.tx.balances.forceSetBalance(building2.address, fund), + api.tx.multiCollective.addMember("Proposers", proposer.address), + api.tx.multiCollective.addMember("Triumvirate", triumvirate1.address), + api.tx.multiCollective.addMember("Triumvirate", triumvirate2.address), + api.tx.multiCollective.addMember("Triumvirate", triumvirate3.address), + api.tx.multiCollective.addMember("Economic", economic1.address), + api.tx.multiCollective.addMember("Economic", economic2.address), + api.tx.multiCollective.addMember("Building", building1.address), + api.tx.multiCollective.addMember("Building", building2.address), + ]) { + await context.createBlock([await api.tx.sudo.sudo(inner).signAsync(sudoer)]); + } + const economic = await api.query.multiCollective.members("Economic"); + const building = await api.query.multiCollective.members("Building"); + log(`Economic: ${economic.toJSON()}`); + log(`Building: ${building.toJSON()}`); + expect(economic.toJSON()).to.have.length(2); + expect(building.toJSON()).to.have.length(2); + }); + + it({ + id: "T01", + title: "proposer submits; triumvirate delegates; collective fast-tracks; balance changes", + test: async () => { + const targetAmount = 2_000_000_000n; + const countBefore = await referendumCount(api); + + const payload = api.tx.balances.forceSetBalance(target.address, targetAmount); + + await context.createBlock([await api.tx.referenda.submit(0, payload).signAsync(proposer)]); + const outerPoll = countBefore; + + // Triumvirate reaches 2/3 aye. + await context.createBlock([await api.tx.signedVoting.vote(outerPoll, true).signAsync(triumvirate1)]); + await context.createBlock([await api.tx.signedVoting.vote(outerPoll, true).signAsync(triumvirate2)]); + + // The 2nd vote schedules a `nudge` for the next block, so need to create 1 block + await context.createBlock([]); + + const approveEvents = await systemEvents(api); + const delegated = approveEvents.find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + expect(delegated, "Delegated").to.exist; + + const delegatedData = delegated?.event.data as unknown as { + review: any; + track: any; + }; + expect(delegatedData.track.toString()).to.equal("1"); + + const innerPoll = outerPoll + 1; + expect(delegatedData.review.toString()).to.equal(innerPoll.toString()); + + const innerStatus = await referendumStatusFor(api, innerPoll); + expect(innerStatus.isSome, "inner poll stored").to.be.true; + expect(innerStatus.toJSON()).to.have.property("ongoing"); + + // Track 1 voter_set = Union(Economic, Building) → 4 voters total. + // 3 ayes (3/4 = 75% ≥ 67% fast_track threshold) is enough. + await context.createBlock([await api.tx.signedVoting.vote(innerPoll, true).signAsync(economic1)]); + await context.createBlock([await api.tx.signedVoting.vote(innerPoll, true).signAsync(economic2)]); + await context.createBlock([await api.tx.signedVoting.vote(innerPoll, true).signAsync(building1)]); + + // Same nudge pattern: 3rd vote schedules nudge → next block fast-tracks. + await context.createBlock([]); + + const fastTrackEvents = await systemEvents(api); + const fastTracked = fastTrackEvents.find( + (e) => e.event.section === "referenda" && e.event.method === "FastTracked" + ); + expect(fastTracked, "inner FastTracked").to.exist; + + await context.createBlock([]); + + const finalEvents = await systemEvents(api); + const dispatched = finalEvents.find( + (e) => e.event.section === "scheduler" && e.event.method === "Dispatched" + ); + expect(dispatched, "scheduler.Dispatched").to.exist; + + const targetFinal = await freeBalance(api, target.address); + expect(targetFinal).to.equal(targetAmount); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/governance/test-origin-guards.ts b/ts-tests/suites/dev/subtensor/governance/test-origin-guards.ts new file mode 100644 index 0000000000..b2d6fe419a --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-origin-guards.ts @@ -0,0 +1,184 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { + bootstrapMembership, + DEV_TRACK, + fundAccounts, + type GovernanceMembership, + inBlock, + lastModuleError, + submitOnTrack, +} from "../../../../utils/governance"; + +/** + * Comprehensive proof that every privileged extrinsic in the governance + * surface rejects non-Root callers with `BadOrigin`. Each test exercises a + * single extrinsic so a regression localizes immediately. This is the most + * security-critical file in the suite: governance is the only path to Root + * dispatch, and a leaky origin check would erase that guarantee. + */ +describeSuite({ + id: "DEV_SUB_GOV_ORIGIN_GUARDS_01", + title: "Governance — origin guards on privileged extrinsics", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + const attacker = generateKeyringPair("sr25519"); + const victim = generateKeyringPair("sr25519"); + const accomplice = generateKeyringPair("sr25519"); + + const expectBadOrigin = async () => { + const err = await lastModuleError(api); + expect(err, "ExtrinsicFailed").to.exist; + expect((err as { kind: string }).kind).to.equal("BadOrigin"); + }; + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + // Bootstrap a referendum so `kill`, `advance_referendum`, and + // `enact` have a real index to target. Seating Triumvirate also + // means `attacker` is a strict outsider. + gov = await bootstrapMembership(api, context, sudoer, { + triumvirate: 3, + economic: 1, + building: 1, + }); + await fundAccounts(api, context, sudoer, [attacker.address, victim.address, accomplice.address]); + }); + + it({ + id: "T01", + title: "multiCollective.add_member from a signed non-Root caller → BadOrigin", + test: async () => { + await inBlock(context, attacker, api.tx.multiCollective.addMember("Triumvirate", attacker.address)); + await expectBadOrigin(); + }, + }); + + it({ + id: "T02", + title: "multiCollective.remove_member from non-Root → BadOrigin", + test: async () => { + await inBlock( + context, + attacker, + api.tx.multiCollective.removeMember("Triumvirate", gov.triumvirate[0].address) + ); + await expectBadOrigin(); + }, + }); + + it({ + id: "T03", + title: "multiCollective.swap_member from non-Root → BadOrigin", + test: async () => { + await inBlock( + context, + attacker, + api.tx.multiCollective.swapMember("Triumvirate", gov.triumvirate[0].address, accomplice.address) + ); + await expectBadOrigin(); + }, + }); + + it({ + id: "T04", + title: "multiCollective.set_members from non-Root → BadOrigin", + test: async () => { + await inBlock( + context, + attacker, + api.tx.multiCollective.setMembers("Triumvirate", [ + attacker.address, + accomplice.address, + victim.address, + ]) + ); + await expectBadOrigin(); + }, + }); + + it({ + id: "T05", + title: "multiCollective.force_rotate from non-Root → BadOrigin", + test: async () => { + await inBlock(context, attacker, api.tx.multiCollective.forceRotate("Economic")); + await expectBadOrigin(); + }, + }); + + it({ + id: "T06", + title: "referenda.kill from non-Root → BadOrigin", + test: async () => { + const index = await submitOnTrack( + api, + context, + gov.proposer, + DEV_TRACK.TRIUMVIRATE, + api.tx.system.remark("victim-call") + ); + await inBlock(context, attacker, api.tx.referenda.kill(index)); + await expectBadOrigin(); + }, + }); + + it({ + id: "T07", + title: "referenda.advance_referendum from non-Root → BadOrigin", + test: async () => { + const index = await submitOnTrack( + api, + context, + gov.proposer, + DEV_TRACK.TRIUMVIRATE, + api.tx.system.remark("advance-target") + ); + await inBlock(context, attacker, api.tx.referenda.advanceReferendum(index)); + await expectBadOrigin(); + }, + }); + + it({ + id: "T08", + title: "referenda.enact from non-Root → BadOrigin", + test: async () => { + const phantomCall = api.tx.system.remark("hijack-attempt"); + await inBlock(context, attacker, api.tx.referenda.enact(0, phantomCall)); + await expectBadOrigin(); + }, + }); + + it({ + id: "T09", + title: "sudo.sudo from a non-sudo caller is rejected before runtime (pool-level)", + test: async () => { + // Defense in depth: the sudo pallet pre-validates the caller + // via a signed extension, so a non-sudo signer never even + // reaches runtime dispatch. Any other behavior would let an + // attacker probe sudo'd calls cheaply. + let rejected = false; + try { + await context.createBlock([ + await api.tx.sudo + .sudo(api.tx.multiCollective.addMember("Triumvirate", attacker.address)) + .signAsync(attacker, { era: 0 }), + ]); + } catch (e) { + rejected = true; + expect(String(e)).to.match(/Invalid signing address|RequireSudo|BadOrigin/i); + } + expect(rejected, "transaction must be rejected").to.be.true; + + // The Triumvirate membership remains untouched. + const members = (await api.query.multiCollective.members("Triumvirate")).toJSON() as string[]; + expect(members).to.not.include(attacker.address); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/governance/test-runtime-config.ts b/ts-tests/suites/dev/subtensor/governance/test-runtime-config.ts new file mode 100644 index 0000000000..6eb5fa5c6b --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-runtime-config.ts @@ -0,0 +1,217 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { + addMembers, + castVote, + type Collective, + DEFAULT_FUND, + DEV_TRACK, + fundAccounts, + getMembers, + getStatusKind, + inBlock, + lastModuleError, + nudge, + referendumCount, + submitOnTrack, + sudoInBlock, + systemEvents, +} from "../../../../utils/governance"; + +const fresh = (n: number): KeyringPair[] => Array.from({ length: n }, () => generateKeyringPair("sr25519")); + +describeSuite({ + id: "DEV_SUB_GOV_RUNTIME_CONFIG_01", + title: "Governance — runtime configuration and submission guardrails", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + + const proposers = fresh(1); + const triumvirate = fresh(4); + const economicEligible = fresh(2); + const beneficiary = generateKeyringPair("sr25519"); + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + + await fundAccounts( + api, + context, + sudoer, + [...proposers, ...triumvirate, ...economicEligible].map((kp) => kp.address), + DEFAULT_FUND + ); + await addMembers(api, context, sudoer, [{ collective: "Proposers", account: proposers[0] }]); + }); + + it({ + id: "T01", + title: "all runtime collective enum variants are addressable through metadata", + test: async () => { + const allCollectives: Collective[] = [ + "Proposers", + "Triumvirate", + "Economic", + "Building", + "EconomicEligible", + ]; + + for (const collective of allCollectives) { + const members = await api.query.multiCollective.members(collective); + expect(members.toJSON()).to.be.an("array"); + } + }, + }); + + it({ + id: "T02", + title: "Track 0 submission fails when the runtime Triumvirate voter set is empty", + test: async () => { + expect((await api.query.multiCollective.members("Triumvirate")).toJSON()).to.have.length(0); + + await inBlock( + context, + proposers[0], + api.tx.referenda.submit(DEV_TRACK.TRIUMVIRATE, api.tx.system.remark("attempted-with-no-voters")) + ); + expect(await lastModuleError(api)).to.deep.equal({ + section: "referenda", + name: "EmptyVoterSet", + }); + expect(await referendumCount(api)).to.equal(0); + }, + }); + + it({ + id: "T03", + title: "Track 1 is not directly submittable in the runtime", + test: async () => { + await inBlock( + context, + proposers[0], + api.tx.referenda.submit(DEV_TRACK.REVIEW, api.tx.system.remark("direct-track-1")) + ); + expect(await lastModuleError(api)).to.deep.equal({ + section: "referenda", + name: "TrackNotSubmittable", + }); + }, + }); + + it({ + id: "T04", + title: "Triumvirate is runtime-configured as exactly three seats", + test: async () => { + await addMembers(api, context, sudoer, [ + { collective: "Triumvirate", account: triumvirate[0] }, + { collective: "Triumvirate", account: triumvirate[1] }, + { collective: "Triumvirate", account: triumvirate[2] }, + ]); + expect(await getMembers(api, "Triumvirate")).to.have.length(3); + + await sudoInBlock( + api, + context, + sudoer, + api.tx.multiCollective.addMember("Triumvirate", triumvirate[3].address) + ); + expect(await lastModuleError(api)).to.deep.equal({ + section: "multiCollective", + name: "TooManyMembers", + }); + + await sudoInBlock( + api, + context, + sudoer, + api.tx.multiCollective.removeMember("Triumvirate", triumvirate[0].address) + ); + expect(await lastModuleError(api)).to.deep.equal({ + section: "multiCollective", + name: "TooFewMembers", + }); + }, + }); + + it({ + id: "T05", + title: "Proposers is not rotatable in the runtime", + test: async () => { + await sudoInBlock(api, context, sudoer, api.tx.multiCollective.forceRotate("Proposers")); + expect(await lastModuleError(api)).to.deep.equal({ + section: "multiCollective", + name: "CollectiveDoesNotRotate", + }); + }, + }); + + it({ + id: "T06", + title: "EconomicEligible permits an empty runtime membership set", + test: async () => { + await sudoInBlock( + api, + context, + sudoer, + api.tx.multiCollective.setMembers( + "EconomicEligible", + economicEligible.map((kp) => kp.address) + ) + ); + expect(await lastModuleError(api)).to.be.null; + expect(await getMembers(api, "EconomicEligible")).to.have.length(2); + + await sudoInBlock(api, context, sudoer, api.tx.multiCollective.setMembers("EconomicEligible", [])); + expect(await lastModuleError(api)).to.be.null; + expect(await getMembers(api, "EconomicEligible")).to.have.length(0); + }, + }); + + it({ + id: "T07", + title: "approval with empty review voter set emits ReviewSchedulingFailed; parent stays Ongoing", + test: async () => { + expect((await api.query.multiCollective.members("Economic")).toJSON()).to.have.length(0); + expect((await api.query.multiCollective.members("Building")).toJSON()).to.have.length(0); + + const countBefore = await referendumCount(api); + const index = await submitOnTrack( + api, + context, + proposers[0], + DEV_TRACK.TRIUMVIRATE, + api.tx.balances.forceSetBalance(beneficiary.address, 7n) + ); + + await castVote(api, context, triumvirate[0], index, true); + await castVote(api, context, triumvirate[1], index, true); + await nudge(context); + + const events = await systemEvents(api); + const failed = events.find( + (e) => e.event.section === "referenda" && e.event.method === "ReviewSchedulingFailed" + ); + expect(failed, "ReviewSchedulingFailed event").to.exist; + const data = failed?.event.data.toJSON() as { index?: number; track?: number } | [number, number]; + if (Array.isArray(data)) { + expect(data[0]).to.equal(index); + expect(data[1]).to.equal(1); + } else { + expect(data.index).to.equal(index); + expect(data.track).to.equal(1); + } + + const delegated = events.find((e) => e.event.section === "referenda" && e.event.method === "Delegated"); + expect(delegated, "no Delegated when review scheduling fails").to.be.undefined; + + expect(await getStatusKind(api, index)).to.equal("ongoing"); + expect(await referendumCount(api)).to.equal(countBefore + 1); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/governance/test-runtime-upgrade.ts b/ts-tests/suites/dev/subtensor/governance/test-runtime-upgrade.ts new file mode 100644 index 0000000000..4d61ee6ee8 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-runtime-upgrade.ts @@ -0,0 +1,126 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { referendumCount, systemEvents } from "../../../../utils/governance"; + +const UPGRADED_WASM_PATH = path.resolve(process.cwd(), "tmp/upgraded-runtime.wasm"); + +describeSuite({ + id: "DEV_SUB_GOV_UPGRADE_01", + title: "Governance — runtime upgrade via setCode", + foundationMethods: "dev", + testCases: ({ it, context, log }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + + const proposer = generateKeyringPair("sr25519"); + const triumvirate1 = generateKeyringPair("sr25519"); + const triumvirate2 = generateKeyringPair("sr25519"); + const triumvirate3 = generateKeyringPair("sr25519"); + const economic1 = generateKeyringPair("sr25519"); + const economic2 = generateKeyringPair("sr25519"); + const building1 = generateKeyringPair("sr25519"); + const building2 = generateKeyringPair("sr25519"); + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + + if (!fs.existsSync(UPGRADED_WASM_PATH)) { + throw new Error( + `Upgraded runtime WASM not found at ${UPGRADED_WASM_PATH}. Run ts-tests/scripts/build-upgrade-runtime.sh first (moonwall should run it automatically via runScripts).` + ); + } + + const minimumPeriod = (api.consts.timestamp.minimumPeriod as unknown as { toNumber(): number }).toNumber(); + if (minimumPeriod !== 6000) { + throw new Error( + `node-subtensor binary appears to be built with --features fast-runtime (timestamp.minimumPeriod=${minimumPeriod}, expected 6000). The upgrade WASM is built without fast-runtime; mixing them bricks block production after setCode. Rebuild the node binary without --features fast-runtime: cargo build --release -p node-subtensor` + ); + } + + const fund = 1_000_000_000_000n; + for (const inner of [ + api.tx.balances.forceSetBalance(proposer.address, fund), + api.tx.balances.forceSetBalance(triumvirate1.address, fund), + api.tx.balances.forceSetBalance(triumvirate2.address, fund), + api.tx.balances.forceSetBalance(triumvirate3.address, fund), + api.tx.balances.forceSetBalance(economic1.address, fund), + api.tx.balances.forceSetBalance(economic2.address, fund), + api.tx.balances.forceSetBalance(building1.address, fund), + api.tx.balances.forceSetBalance(building2.address, fund), + api.tx.multiCollective.addMember("Proposers", proposer.address), + api.tx.multiCollective.addMember("Triumvirate", triumvirate1.address), + api.tx.multiCollective.addMember("Triumvirate", triumvirate2.address), + api.tx.multiCollective.addMember("Triumvirate", triumvirate3.address), + api.tx.multiCollective.addMember("Economic", economic1.address), + api.tx.multiCollective.addMember("Economic", economic2.address), + api.tx.multiCollective.addMember("Building", building1.address), + api.tx.multiCollective.addMember("Building", building2.address), + ]) { + await context.createBlock([await api.tx.sudo.sudo(inner).signAsync(sudoer)]); + } + }); + + it({ + id: "T01", + title: "setCode passes governance and bumps specVersion", + test: async () => { + const wasmBytes = fs.readFileSync(UPGRADED_WASM_PATH); + const wasmHex = `0x${wasmBytes.toString("hex")}`; + log(`upgraded runtime size: ${wasmBytes.length} bytes`); + + const versionBefore = await api.rpc.state.getRuntimeVersion(); + const specBefore = versionBefore.specVersion.toNumber(); + log(`specVersion before: ${specBefore}`); + + const setCodePayload = api.tx.system.setCode(wasmHex); + + const countBefore = await referendumCount(api); + + await context.createBlock([await api.tx.referenda.submit(0, setCodePayload).signAsync(proposer)]); + const outerPoll = countBefore; + + await context.createBlock([await api.tx.signedVoting.vote(outerPoll, true).signAsync(triumvirate1)]); + await context.createBlock([await api.tx.signedVoting.vote(outerPoll, true).signAsync(triumvirate2)]); + + await context.createBlock([]); + + const delegatedEvent = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + expect(delegatedEvent, "outer Delegated").to.exist; + const innerPoll = outerPoll + 1; + + await context.createBlock([await api.tx.signedVoting.vote(innerPoll, true).signAsync(economic1)]); + await context.createBlock([await api.tx.signedVoting.vote(innerPoll, true).signAsync(economic2)]); + await context.createBlock([await api.tx.signedVoting.vote(innerPoll, true).signAsync(building1)]); + + await context.createBlock([]); + + const fastTracked = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "FastTracked" + ); + expect(fastTracked, "inner FastTracked").to.exist; + + await context.createBlock([]); + + const enactmentEvents = await systemEvents(api); + const codeUpdated = enactmentEvents.find( + (e) => e.event.section === "system" && e.event.method === "CodeUpdated" + ); + expect(codeUpdated, "system.CodeUpdated").to.exist; + + await context.createBlock([]); + + const versionAfter = await api.rpc.state.getRuntimeVersion(); + const specAfter = versionAfter.specVersion.toNumber(); + log(`specVersion after: ${specAfter}`); + expect(specAfter).to.equal(specBefore + 1); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/governance/test-track0-lifecycle.ts b/ts-tests/suites/dev/subtensor/governance/test-track0-lifecycle.ts new file mode 100644 index 0000000000..7c494391c2 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-track0-lifecycle.ts @@ -0,0 +1,105 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { + bootstrapMembership, + castVote, + DEV_TRACK, + type GovernanceMembership, + getStatusKind, + getTally, + nudge, + referendumCount, + submitOnTrack, + systemEvents, +} from "../../../../utils/governance"; + +describeSuite({ + id: "DEV_SUB_GOV_TRACK0_LIFECYCLE_01", + title: "Governance — Track 0 runtime thresholds", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + const beneficiary = generateKeyringPair("sr25519"); + const remark = (amount: bigint) => api.tx.balances.forceSetBalance(beneficiary.address, amount); + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + gov = await bootstrapMembership(api, context, sudoer, { + triumvirate: 3, + economic: 1, + building: 1, + }); + }); + + it({ + id: "T01", + title: "2-of-3 runtime Triumvirate ayes delegates to the review track", + test: async () => { + const index = await submitOnTrack(api, context, gov.proposer, DEV_TRACK.TRIUMVIRATE, remark(2n)); + + await castVote(api, context, gov.triumvirate[0], index, true); + await castVote(api, context, gov.triumvirate[1], index, true); + await nudge(context); + + const delegated = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + expect(delegated, "Delegated event").to.exist; + + const data = delegated?.event.data.toJSON() as { + index?: number; + review?: number; + track?: number; + } & Array; + const childIndex = data.review ?? data[1]; + expect(data.index ?? data[0]).to.equal(index); + expect(data.track ?? data[2]).to.equal(DEV_TRACK.REVIEW); + expect(await getStatusKind(api, index)).to.equal("delegated"); + expect(await getStatusKind(api, childIndex)).to.equal("ongoing"); + }, + }); + + it({ + id: "T02", + title: "2-of-3 runtime Triumvirate nays reject without creating a review child", + test: async () => { + const index = await submitOnTrack(api, context, gov.proposer, DEV_TRACK.TRIUMVIRATE, remark(3n)); + const countBefore = await referendumCount(api); + + await castVote(api, context, gov.triumvirate[0], index, false); + await castVote(api, context, gov.triumvirate[1], index, false); + await nudge(context); + + const rejected = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Rejected" + ); + expect(rejected, "Rejected event").to.exist; + expect(await getStatusKind(api, index)).to.equal("rejected"); + expect(await referendumCount(api)).to.equal(countBefore); + }, + }); + + it({ + id: "T03", + title: "split Triumvirate votes stay below both runtime thresholds", + test: async () => { + const index = await submitOnTrack(api, context, gov.proposer, DEV_TRACK.TRIUMVIRATE, remark(4n)); + await castVote(api, context, gov.triumvirate[0], index, true); + await castVote(api, context, gov.triumvirate[1], index, false); + await nudge(context, 2); + + expect(await getStatusKind(api, index)).to.equal("ongoing"); + expect(await getTally(api, index)).to.deep.equal({ + ayes: 1, + nays: 1, + total: 3, + }); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/governance/test-track1-lifecycle.ts b/ts-tests/suites/dev/subtensor/governance/test-track1-lifecycle.ts new file mode 100644 index 0000000000..1542e6cfe6 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-track1-lifecycle.ts @@ -0,0 +1,211 @@ +import { beforeAll, type DevModeContext, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { + bootstrapMembership, + castVote, + DEV_TRACK, + freeBalance, + type GovernanceMembership, + getStatusKind, + getTally, + isEnactmentTaskNone, + lastModuleError, + nudge, + submitOnTrack, + sudoInBlock, + systemEvents, +} from "../../../../utils/governance"; + +async function delegateToTrack1( + api: ApiPromise, + context: DevModeContext, + gov: GovernanceMembership, + payload: Parameters[4] +): Promise<{ outer: number; child: number }> { + const outer = await submitOnTrack(api, context, gov.proposer, DEV_TRACK.TRIUMVIRATE, payload); + await castVote(api, context, gov.triumvirate[0], outer, true); + await castVote(api, context, gov.triumvirate[1], outer, true); + await nudge(context); + + const delegated = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + if (!delegated) { + throw new Error("Delegation never fired; the review voter set may be empty"); + } + const data = delegated.event.data.toJSON() as { review?: number } & Array; + return { outer, child: data.review ?? data[1] }; +} + +describeSuite({ + id: "DEV_SUB_GOV_TRACK1_LIFECYCLE_01", + title: "Governance — Track 1 runtime review path", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + + const beneficiary = generateKeyringPair("sr25519"); + const remark = (amount: bigint) => api.tx.balances.forceSetBalance(beneficiary.address, amount); + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + gov = await bootstrapMembership(api, context, sudoer, { + triumvirate: 3, + economic: 2, + building: 2, + }); + }); + + it({ + id: "T01", + title: "delegation creates a Track 1 child with Economic ∪ Building as voters", + test: async () => { + const { child } = await delegateToTrack1(api, context, gov, remark(101n)); + expect(await getStatusKind(api, child)).to.equal("ongoing"); + expect(await getTally(api, child)).to.deep.equal({ + ayes: 0, + nays: 0, + total: 4, + }); + }, + }); + + it({ + id: "T02", + title: "3-of-4 runtime review ayes fast-track and dispatch as Root", + test: async () => { + const targetAmount = 7_777_777_000n; + const target = generateKeyringPair("sr25519"); + const { child } = await delegateToTrack1( + api, + context, + gov, + api.tx.balances.forceSetBalance(target.address, targetAmount) + ); + + await castVote(api, context, gov.economic[0], child, true); + await castVote(api, context, gov.economic[1], child, true); + await castVote(api, context, gov.building[0], child, true); + await nudge(context); + + const fastTracked = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "FastTracked" + ); + expect(fastTracked, "FastTracked event").to.exist; + expect(await getStatusKind(api, child)).to.equal("fastTracked"); + + await nudge(context); + const enacted = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Enacted" + ); + expect(enacted, "Enacted event").to.exist; + expect(await freeBalance(api, target.address)).to.equal(targetAmount); + }, + }); + + it({ + id: "T03", + title: "3-of-4 runtime review nays cancel and clear the enactment task", + test: async () => { + const { child } = await delegateToTrack1(api, context, gov, remark(103n)); + + await castVote(api, context, gov.economic[0], child, false); + await castVote(api, context, gov.economic[1], child, false); + await castVote(api, context, gov.building[0], child, false); + await nudge(context); + + const cancelled = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Cancelled" + ); + expect(cancelled, "Cancelled event").to.exist; + expect(await getStatusKind(api, child)).to.equal("cancelled"); + expect(await isEnactmentTaskNone(api, child), "enactment task cleared").to.be.true; + }, + }); + + it({ + id: "T04", + title: "Root kill in the fast-track block prevents scheduled dispatch", + test: async () => { + const target = generateKeyringPair("sr25519"); + const { child } = await delegateToTrack1( + api, + context, + gov, + api.tx.balances.forceSetBalance(target.address, 42n) + ); + await castVote(api, context, gov.economic[0], child, true); + await castVote(api, context, gov.economic[1], child, true); + await castVote(api, context, gov.building[0], child, true); + + await context.createBlock([ + await api.tx.sudo.sudo(api.tx.referenda.kill(child)).signAsync(sudoer, { era: 0 }), + ]); + + const events = await systemEvents(api); + expect(events.find((e) => e.event.section === "referenda" && e.event.method === "FastTracked")).to + .exist; + expect(events.find((e) => e.event.section === "referenda" && e.event.method === "Killed")).to.exist; + expect(await lastModuleError(api)).to.be.null; + + await nudge(context, 3); + expect(await freeBalance(api, target.address)).to.equal(0n); + }, + }); + + it({ + id: "T05", + title: "runtime Root dispatch errors are recorded in the Enacted event", + test: async () => { + const recipient = generateKeyringPair("sr25519"); + const { child } = await delegateToTrack1( + api, + context, + gov, + api.tx.balances.transferKeepAlive(recipient.address, 100n) + ); + await castVote(api, context, gov.economic[0], child, true); + await castVote(api, context, gov.economic[1], child, true); + await castVote(api, context, gov.building[0], child, true); + await nudge(context); + await nudge(context); + + const enacted = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Enacted" + ); + expect(enacted, "Enacted event").to.exist; + const data = enacted?.event.data.toJSON() as { error?: unknown } | Array; + const errorField = Array.isArray(data) ? data[2] : data.error; + expect(errorField, "Enacted carries a non-null error").to.not.be.null; + expect(await freeBalance(api, recipient.address)).to.equal(0n); + }, + }); + + it({ + id: "T06", + title: "Root can directly enact an Ongoing runtime review referendum", + test: async () => { + const target = generateKeyringPair("sr25519"); + const amount = 12_345_000n; + const innerCall = api.tx.balances.forceSetBalance(target.address, amount); + + const { child } = await delegateToTrack1(api, context, gov, innerCall); + expect(await getStatusKind(api, child)).to.equal("ongoing"); + + await sudoInBlock(api, context, sudoer, api.tx.referenda.enact(child, innerCall)); + + const enacted = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Enacted" + ); + expect(enacted, "Enacted event").to.exist; + expect(await getStatusKind(api, child)).to.equal("enacted"); + expect(await freeBalance(api, target.address)).to.equal(amount); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/governance/test-voter-sets.ts b/ts-tests/suites/dev/subtensor/governance/test-voter-sets.ts new file mode 100644 index 0000000000..eb82997011 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-voter-sets.ts @@ -0,0 +1,142 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { + addMembers, + bootstrapMembership, + castVote, + DEV_TRACK, + fundAccounts, + type GovernanceMembership, + getTally, + lastModuleError, + nudge, + submitOnTrack, + sudoInBlock, + systemEvents, +} from "../../../../utils/governance"; + +describeSuite({ + id: "DEV_SUB_GOV_VOTER_SETS_01", + title: "Governance — runtime voter-set wiring", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + + const latecomer = generateKeyringPair("sr25519"); + const overlap = generateKeyringPair("sr25519"); + const beneficiary = generateKeyringPair("sr25519"); + const remark = (amount: bigint) => api.tx.balances.forceSetBalance(beneficiary.address, amount); + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + gov = await bootstrapMembership(api, context, sudoer, { + proposers: 4, + triumvirate: 3, + economic: 1, + building: 1, + }); + await fundAccounts(api, context, sudoer, [latecomer.address, overlap.address]); + await addMembers(api, context, sudoer, [ + { collective: "Economic", account: overlap }, + { collective: "Building", account: overlap }, + ]); + }); + + it({ + id: "T01", + title: "runtime voter snapshots survive a Triumvirate membership swap", + test: async () => { + const index = await submitOnTrack(api, context, gov.proposers[0], DEV_TRACK.TRIUMVIRATE, remark(208n)); + + const frozenSet = (await api.query.signedVoting.voterSetOf(index)).toJSON() as string[]; + expect(frozenSet).to.have.length(3); + expect(frozenSet).to.not.include(latecomer.address); + + await sudoInBlock( + api, + context, + sudoer, + api.tx.multiCollective.swapMember("Triumvirate", gov.triumvirate[2].address, latecomer.address) + ); + expect(await lastModuleError(api)).to.be.null; + + await castVote(api, context, latecomer, index, true); + expect(await lastModuleError(api)).to.deep.equal({ + section: "signedVoting", + name: "NotInVoterSet", + }); + + await sudoInBlock( + api, + context, + sudoer, + api.tx.multiCollective.swapMember("Triumvirate", latecomer.address, gov.triumvirate[2].address) + ); + }, + }); + + it({ + id: "T02", + title: "Triumvirate members cannot vote on the Track 1 review child", + test: async () => { + const parent = await submitOnTrack(api, context, gov.proposers[1], DEV_TRACK.TRIUMVIRATE, remark(214n)); + await castVote(api, context, gov.triumvirate[0], parent, true); + await castVote(api, context, gov.triumvirate[1], parent, true); + await nudge(context); + + const delegated = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + const data = delegated?.event.data.toJSON() as { review?: number } & Array; + const child = data.review ?? data[1]; + + await castVote(api, context, gov.triumvirate[0], child, true); + expect(await lastModuleError(api)).to.deep.equal({ + section: "signedVoting", + name: "NotInVoterSet", + }); + }, + }); + + it({ + id: "T03", + title: "Economic/Building members cannot vote on the Track 0 parent", + test: async () => { + const index = await submitOnTrack(api, context, gov.proposers[2], DEV_TRACK.TRIUMVIRATE, remark(215n)); + await castVote(api, context, gov.economic[0], index, true); + expect(await lastModuleError(api)).to.deep.equal({ + section: "signedVoting", + name: "NotInVoterSet", + }); + }, + }); + + it({ + id: "T04", + title: "runtime Economic ∪ Building review voters dedupe overlapping accounts", + test: async () => { + const parent = await submitOnTrack(api, context, gov.proposers[3], DEV_TRACK.TRIUMVIRATE, remark(216n)); + await castVote(api, context, gov.triumvirate[0], parent, true); + await castVote(api, context, gov.triumvirate[1], parent, true); + await nudge(context); + + const delegated = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + expect(delegated, "Delegated event").to.exist; + const data = delegated?.event.data.toJSON() as { review?: number } & Array; + const child = data.review ?? data[1]; + + const voterSet = (await api.query.signedVoting.voterSetOf(child)).toJSON() as string[]; + expect(voterSet).to.have.length(3); + expect(voterSet.filter((a) => a === overlap.address)).to.have.length(1); + expect((await getTally(api, child))?.total).to.equal(3); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev_fast/governance/test-track0-expired.ts b/ts-tests/suites/dev_fast/governance/test-track0-expired.ts new file mode 100644 index 0000000000..3f39393ec3 --- /dev/null +++ b/ts-tests/suites/dev_fast/governance/test-track0-expired.ts @@ -0,0 +1,108 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../utils/account"; +import { + bootstrapMembership, + castVote, + DEV_TRACK, + type GovernanceMembership, + getActivePerProposer, + getStatusKind, + nudge, + submitOnTrack, + systemEvents, +} from "../../../utils/governance"; + +/** + * Reachable only with `--features fast-runtime`: + * TRIUMVIRATE_DECISION_PERIOD = prod_or_fast!(50_400, 50) + * + * A Track 0 referendum that never crosses `approve_threshold` (2/3) or + * `reject_threshold` (2/3) before the decision period elapses must time + * out as `Expired`. The deadline alarm is set on submission and re-armed + * on every `expire_or_rearm_deadline` call until it actually fires at + * `submitted + decision_period`. + */ +describeSuite({ + id: "DEV_FAST_GOV_TRACK0_EXPIRED_01", + title: "Governance (fast-runtime) — Track 0 Expired", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + const beneficiary = generateKeyringPair("sr25519"); + + // Mirrors `runtime/src/governance/tracks.rs` under fast-runtime. + const TRIUMVIRATE_DECISION_PERIOD = 50; + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + gov = await bootstrapMembership(api, context, sudoer, { + triumvirate: 3, + economic: 1, + building: 1, + }); + + // Sanity: confirm we're running on a fast-runtime binary. The + // upgrade test uses the opposite check; mismatched binaries would + // silently make this test pass for the wrong reason. + const minimumPeriod = (api.consts.timestamp.minimumPeriod as unknown as { toNumber(): number }).toNumber(); + if (minimumPeriod === 6000) { + throw new Error( + `dev_fast suite requires a binary built with --features fast-runtime (got minimumPeriod=${minimumPeriod})` + ); + } + }); + + it({ + id: "T01", + title: "no threshold crossed before decision_period elapses → Expired", + test: async () => { + const beforeActive = await getActivePerProposer(api, gov.proposer.address); + const index = await submitOnTrack( + api, + context, + gov.proposer, + DEV_TRACK.TRIUMVIRATE, + api.tx.balances.forceSetBalance(beneficiary.address, 7n) + ); + + // 1 aye sits below the 2/3 approve_threshold (≈ 33% vs 66.6%) + // and rejection stays at 0, so neither threshold can ever + // fire. The only way out is the deadline. + await castVote(api, context, gov.triumvirate[0], index, true); + expect(await getStatusKind(api, index)).to.equal("ongoing"); + + // Drive blocks until the status flips to expired, capturing + // the per-block event log so the Expired event from the + // transitioning block isn't lost when the system events + // storage rolls over. + let expiredEvent: unknown = null; + for (let i = 0; i < TRIUMVIRATE_DECISION_PERIOD + 10; i++) { + const ev = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Expired" + ); + if (ev) { + expiredEvent = ev; + break; + } + if ((await getStatusKind(api, index)) === "expired") { + // Status flipped before we observed the event; still + // acceptable — status is the authoritative record. + break; + } + await nudge(context); + } + + expect(await getStatusKind(api, index)).to.equal("expired"); + expect(expiredEvent, "Expired event observed during polling").to.exist; + + // Expiration is terminal → proposer's slot is released. + expect(await getActivePerProposer(api, gov.proposer.address)).to.equal(beforeActive); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev_fast/governance/test-track1-delay-curve.ts b/ts-tests/suites/dev_fast/governance/test-track1-delay-curve.ts new file mode 100644 index 0000000000..d7ba158ba9 --- /dev/null +++ b/ts-tests/suites/dev_fast/governance/test-track1-delay-curve.ts @@ -0,0 +1,157 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../utils/account"; +import { + bootstrapMembership, + castVote, + DEV_TRACK, + type GovernanceMembership, + getStatusKind, + nudge, + referendumStatusFor, + submitOnTrack, + systemEvents, +} from "../../../utils/governance"; + +/** + * Reachable only with `--features fast-runtime`: + * REVIEW_INITIAL_DELAY = prod_or_fast!(7_200, 30) + * REVIEW_MAX_DELAY = prod_or_fast!(14_400, 60) + * + * `do_adjust_delay` interpolates the enactment task's dispatch time + * between `submitted` (under full net approval) and `submitted + max_delay` + * (under full net rejection), shaped by the runtime's ease-out + * `AdjustmentCurve` (`1 - (1 - p)^3`). The exact mapping with a 4-voter set: + * + * - 0 votes → enacts at submitted + initial_delay (30) + * - 1 aye (1/4) → enacts at submitted + 8 + * progress = 25%/75% = 33%, curved = 1 - (2/3)^3, + * delay = floor(0.296 * 30) = 8 + * - 1 nay (1/4) → enacts at submitted + 56 + * progress = 25%/51% = 49%, curved ~= 86.7%, + * delay = 30 + floor(0.867 * 30) = 56 + * + * Three tests exercise the three regimes (net approval, net rejection, + * net zero from cancellation) by observing the actual block at which + * `Enacted` fires. + */ +describeSuite({ + id: "DEV_FAST_GOV_TRACK1_DELAY_CURVE_01", + title: "Governance (fast-runtime) — Track 1 enactment delay adjustment curve", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + const beneficiary = generateKeyringPair("sr25519"); + + const REVIEW_INITIAL_DELAY = 30; + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + gov = await bootstrapMembership(api, context, sudoer, { + proposers: 3, + triumvirate: 3, + economic: 2, + building: 2, + }); + }); + + const delegateToChild = async ( + proposer: KeyringPair + ): Promise<{ + child: number; + childSubmitted: number; + }> => { + const parent = await submitOnTrack( + api, + context, + proposer, + DEV_TRACK.TRIUMVIRATE, + api.tx.balances.forceSetBalance(beneficiary.address, 1n) + ); + await castVote(api, context, gov.triumvirate[0], parent, true); + await castVote(api, context, gov.triumvirate[1], parent, true); + await nudge(context); + const arr = (await systemEvents(api)) + .find((e) => e.event.section === "referenda" && e.event.method === "Delegated") + ?.event.data.toJSON() as Array; + const child = arr[1]; + const status = (await referendumStatusFor(api, child)).toJSON() as { + ongoing: { submitted: number }; + }; + return { child, childSubmitted: status.ongoing.submitted }; + }; + + /** Advance blocks until `index` reaches a terminal status; returns the block of transition. */ + const advanceUntilEnacted = async (index: number, maxBlocks: number): Promise => { + for (let i = 0; i < maxBlocks; i++) { + const kind = await getStatusKind(api, index); + if (kind === "enacted") { + return (await api.query.system.number()).toJSON() as number; + } + await nudge(context); + } + throw new Error(`referendum ${index} did not enact within ${maxBlocks} blocks`); + }; + + it({ + id: "T01", + title: "1 aye → enactment shifts earlier (submitted + 8 with ease-out curve)", + test: async () => { + const { child, childSubmitted } = await delegateToChild(gov.proposers[0]); + await castVote(api, context, gov.economic[0], child, true); + // Let the alarm fire to apply the adjustment. + await nudge(context); + + const enactedAt = await advanceUntilEnacted(child, REVIEW_INITIAL_DELAY + 5); + const expected = childSubmitted + 8; + // Allow ±2 blocks of slack: the alarm fires one block after + // the vote, and the scheduler may include the task one block + // after its scheduled `when`. + expect(enactedAt).to.be.at.least(expected); + expect(enactedAt).to.be.at.most(expected + 2); + expect(enactedAt, "earlier than initial_delay default").to.be.lessThan( + childSubmitted + REVIEW_INITIAL_DELAY + ); + }, + }); + + it({ + id: "T02", + title: "1 nay → enactment shifts later (submitted + 56 with ease-out curve)", + test: async () => { + const { child, childSubmitted } = await delegateToChild(gov.proposers[1]); + await castVote(api, context, gov.economic[0], child, false); + await nudge(context); + + const enactedAt = await advanceUntilEnacted(child, 60); + const expected = childSubmitted + 56; + expect(enactedAt).to.be.at.least(expected); + expect(enactedAt).to.be.at.most(expected + 2); + expect(enactedAt, "later than initial_delay default").to.be.greaterThan( + childSubmitted + REVIEW_INITIAL_DELAY + ); + }, + }); + + it({ + id: "T03", + title: "1 aye + 1 nay (net zero) returns the schedule to submitted + initial_delay", + test: async () => { + const { child, childSubmitted } = await delegateToChild(gov.proposers[2]); + await castVote(api, context, gov.economic[0], child, true); + await nudge(context); + await castVote(api, context, gov.economic[1], child, false); + await nudge(context); + + const enactedAt = await advanceUntilEnacted(child, 45); + const expected = childSubmitted + REVIEW_INITIAL_DELAY; + expect(enactedAt).to.be.at.least(expected); + expect(enactedAt).to.be.at.most(expected + 2); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev_fast/governance/test-track1-natural-enactment.ts b/ts-tests/suites/dev_fast/governance/test-track1-natural-enactment.ts new file mode 100644 index 0000000000..962b69dada --- /dev/null +++ b/ts-tests/suites/dev_fast/governance/test-track1-natural-enactment.ts @@ -0,0 +1,108 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../utils/account"; +import { + bootstrapMembership, + castVote, + DEV_TRACK, + freeBalance, + type GovernanceMembership, + getStatusKind, + nudge, + referendumStatusFor, + submitOnTrack, + systemEvents, +} from "../../../utils/governance"; + +/** + * Reachable only with `--features fast-runtime`: + * REVIEW_INITIAL_DELAY = prod_or_fast!(7_200, 30) + * + * On delegation, a Track 1 child is born with its enactment task already + * scheduled at `submitted + initial_delay`. If voters do nothing (no + * fast-track and no cancel), the wrapper task fires naturally and runs the + * inner call. This locks in the "Adjustable defaults to executing" + * contract: an approved Triumvirate proposal will eventually dispatch even + * without any review activity. + */ +describeSuite({ + id: "DEV_FAST_GOV_TRACK1_NATURAL_01", + title: "Governance (fast-runtime) — Track 1 natural enactment at initial_delay", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + const target = generateKeyringPair("sr25519"); + const targetAmount = 555_000_000n; + + // Mirrors `runtime/src/governance/tracks.rs` under fast-runtime. + const REVIEW_INITIAL_DELAY = 30; + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + gov = await bootstrapMembership(api, context, sudoer, { + triumvirate: 3, + economic: 2, + building: 2, + }); + }); + + it({ + id: "T01", + title: "delegated child enacts at submitted + initial_delay with no Track 1 votes", + test: async () => { + const parent = await submitOnTrack( + api, + context, + gov.proposer, + DEV_TRACK.TRIUMVIRATE, + api.tx.balances.forceSetBalance(target.address, targetAmount) + ); + + await castVote(api, context, gov.triumvirate[0], parent, true); + await castVote(api, context, gov.triumvirate[1], parent, true); + await nudge(context); + + const delegated = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + expect(delegated, "Delegated event").to.exist; + const arr = delegated?.event.data.toJSON() as Array; + const child = arr[1]; + expect(await getStatusKind(api, child)).to.equal("ongoing"); + + // Without any votes on the child, the scheduled enactment + // task fires at submitted + initial_delay. Use submitted from + // the child's status (set at delegation, not at parent + // submission). + const childStatus = (await referendumStatusFor(api, child)).toJSON() as { + ongoing: { submitted: number }; + } | null; + const childSubmitted = childStatus?.ongoing?.submitted; + expect(childSubmitted, "child submitted block").to.be.a("number"); + + const targetBlock = (childSubmitted as number) + REVIEW_INITIAL_DELAY + 2; + while (((await api.query.system.number()).toJSON() as number) < targetBlock) { + await nudge(context); + } + + const enacted = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Enacted" + ); + // The Enacted event may have fired in an earlier block within + // the polling loop; if so, also accept the terminal status. + expect(await getStatusKind(api, child)).to.equal("enacted"); + if (enacted) { + const data = enacted.event.data.toJSON() as { error?: unknown } | Array; + const errorField = Array.isArray(data) ? data[2] : data.error; + expect(errorField, "Enacted carries no error").to.be.null; + } + + expect(await freeBalance(api, target.address)).to.equal(targetAmount); + }, + }); + }, +}); diff --git a/ts-tests/utils/governance.ts b/ts-tests/utils/governance.ts new file mode 100644 index 0000000000..29d96c564f --- /dev/null +++ b/ts-tests/utils/governance.ts @@ -0,0 +1,305 @@ +import type { DevModeContext } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import type { SubmittableExtrinsic } from "@polkadot/api/types"; +import { generateKeyringPair } from "./account"; + +export type Collective = "Proposers" | "Triumvirate" | "Economic" | "Building" | "EconomicEligible"; + +export type ReferendumStatusKind = + | "ongoing" + | "approved" + | "delegated" + | "rejected" + | "cancelled" + | "expired" + | "fastTracked" + | "enacted" + | "killed"; + +export type DispatchModuleError = { section: string; name: string }; +export type DispatchFailure = DispatchModuleError | { kind: string; raw: string }; +export type EventRecordLike = { + event: { + section: string; + method: string; + data: { toJSON(): unknown } & ArrayLike; + }; +}; + +type NumberCodecLike = { toNumber(): number; toJSON(): unknown }; +type OptionCodecLike = { isNone: boolean; isSome: boolean; toJSON(): unknown }; +type AccountInfoLike = { data: { free: { toBigInt(): bigint } } }; + +export const DEV_TRACK = { TRIUMVIRATE: 0, REVIEW: 1 } as const; +export const DEFAULT_FUND = 1_000_000_000_000n; + +type SudoExtrinsic = SubmittableExtrinsic<"promise">; + +/** + * Sign an extrinsic with `signer` and seal it into a fresh block. + * + * Transactions are signed with `era: 0` (immortal). Mortal extrinsics check + * their birth block against `BlockHash`; under the parallel test runner, + * the in-process `ApiPromise` can briefly hold a stale "best block" while + * other forks' nodes drive their own chains forward, and a freshly signed + * mortal tx can be rejected as `AncientBirthBlock` before it reaches the + * pool. Immortal signing sidesteps that race without changing observable + * behavior on the chain under test. + */ +export async function inBlock(context: DevModeContext, signer: KeyringPair, tx: SudoExtrinsic): Promise { + await context.createBlock([await tx.signAsync(signer, { era: 0 })]); +} + +/** Wrap `inner` in `sudo.sudo` and execute it in its own block as `sudoer`. */ +export async function sudoInBlock( + api: ApiPromise, + context: DevModeContext, + sudoer: KeyringPair, + inner: SudoExtrinsic +): Promise { + await inBlock(context, sudoer, api.tx.sudo.sudo(inner)); +} + +/** Top up the free balance of each address. Idempotent on repeat addresses. */ +export async function fundAccounts( + api: ApiPromise, + context: DevModeContext, + sudoer: KeyringPair, + addresses: string[], + fund: bigint = DEFAULT_FUND +): Promise { + const seen = new Set(); + for (const address of addresses) { + if (seen.has(address)) continue; + seen.add(address); + await sudoInBlock(api, context, sudoer, api.tx.balances.forceSetBalance(address, fund)); + } +} + +/** Add each `{collective, account}` entry to its collective. */ +export async function addMembers( + api: ApiPromise, + context: DevModeContext, + sudoer: KeyringPair, + entries: Array<{ collective: Collective; account: KeyringPair | string }> +): Promise { + for (const { collective, account } of entries) { + const address = typeof account === "string" ? account : account.address; + await sudoInBlock(api, context, sudoer, api.tx.multiCollective.addMember(collective, address)); + } +} + +export type GovernanceMembership = { + /** First Proposer; convenient default for tests that only need one. */ + proposer: KeyringPair; + /** Full Proposers list, length matches `layout.proposers` (≥ 1). */ + proposers: KeyringPair[]; + triumvirate: KeyringPair[]; + economic: KeyringPair[]; + building: KeyringPair[]; +}; + +export type MembershipLayout = { + triumvirate: number; + economic: number; + building: number; + /** + * How many Proposers to seat. Distinct proposers are useful when a single + * suite needs to file more than `MaxActivePerProposer` (= 5) referenda + * without freeing slots first. Defaults to 1. + */ + proposers?: number; +}; + +/** + * Mint and seat a standard membership layout. Returns the generated keypairs + * so tests can keep using them. + * + * Triumvirate must equal 3 to satisfy `min_members` once seeded; the others + * accept any size up to the per-collective `max_members`. + */ +export async function bootstrapMembership( + api: ApiPromise, + context: DevModeContext, + sudoer: KeyringPair, + layout: MembershipLayout +): Promise { + const proposerCount = layout.proposers ?? 1; + const proposers = Array.from({ length: proposerCount }, () => generateKeyringPair("sr25519")); + const triumvirate = Array.from({ length: layout.triumvirate }, () => generateKeyringPair("sr25519")); + const economic = Array.from({ length: layout.economic }, () => generateKeyringPair("sr25519")); + const building = Array.from({ length: layout.building }, () => generateKeyringPair("sr25519")); + + await fundAccounts( + api, + context, + sudoer, + [...proposers, ...triumvirate, ...economic, ...building].map((kp) => kp.address) + ); + + const entries: Array<{ collective: Collective; account: KeyringPair }> = [ + ...proposers.map((account) => ({ collective: "Proposers" as Collective, account })), + ...triumvirate.map((account) => ({ collective: "Triumvirate" as Collective, account })), + ...economic.map((account) => ({ collective: "Economic" as Collective, account })), + ...building.map((account) => ({ collective: "Building" as Collective, account })), + ]; + + await addMembers(api, context, sudoer, entries); + + return { proposer: proposers[0], proposers, triumvirate, economic, building }; +} + +/** Submit `inner` on `track` as `proposer`. Returns the assigned index. */ +export async function submitOnTrack( + api: ApiPromise, + context: DevModeContext, + proposer: KeyringPair, + track: number, + inner: SudoExtrinsic +): Promise { + const index = await referendumCount(api); + await inBlock(context, proposer, api.tx.referenda.submit(track, inner)); + return index; +} + +export async function castVote( + api: ApiPromise, + context: DevModeContext, + voter: KeyringPair, + pollIndex: number, + approve: boolean +): Promise { + await inBlock(context, voter, api.tx.signedVoting.vote(pollIndex, approve)); +} + +export async function removeVote( + api: ApiPromise, + context: DevModeContext, + voter: KeyringPair, + pollIndex: number +): Promise { + await inBlock(context, voter, api.tx.signedVoting.removeVote(pollIndex)); +} + +export async function killReferendum( + api: ApiPromise, + context: DevModeContext, + sudoer: KeyringPair, + index: number +): Promise { + await sudoInBlock(api, context, sudoer, api.tx.referenda.kill(index)); +} + +/** Seal `count` empty blocks so the scheduler can fire pending alarms/tasks. */ +export async function nudge(context: DevModeContext, count = 1): Promise { + for (let i = 0; i < count; i++) { + await context.createBlock([]); + } +} + +type RawDispatchError = { + isModule: boolean; + asModule: Parameters[0]; + type?: string; + toString(): string; +}; + +function decodeDispatchError(api: ApiPromise, dispatchError: RawDispatchError): DispatchFailure { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + return { section: decoded.section, name: decoded.name }; + } + return { kind: dispatchError.type ?? "other", raw: dispatchError.toString() }; +} + +export async function systemEvents(api: ApiPromise): Promise { + return (await api.query.system.events()) as unknown as EventRecordLike[]; +} + +export async function referendumCount(api: ApiPromise): Promise { + return ((await api.query.referenda.referendumCount()) as unknown as NumberCodecLike).toNumber(); +} + +export async function referendumStatusFor(api: ApiPromise, index: number): Promise { + return (await api.query.referenda.referendumStatusFor(index)) as unknown as OptionCodecLike; +} + +export async function isReferendumStatusNone(api: ApiPromise, index: number): Promise { + return (await referendumStatusFor(api, index)).isNone; +} + +export async function isEnactmentTaskNone(api: ApiPromise, index: number): Promise { + return ((await api.query.referenda.enactmentTask(index)) as unknown as OptionCodecLike).isNone; +} + +export async function isVotingForNone(api: ApiPromise, index: number, address: string): Promise { + return ((await api.query.signedVoting.votingFor(index, address)) as unknown as OptionCodecLike).isNone; +} + +export async function freeBalance(api: ApiPromise, address: string): Promise { + return ((await api.query.system.account(address)) as unknown as AccountInfoLike).data.free.toBigInt(); +} + +/** + * Decoded summary of the most recent failure in the latest block. + * + * Captures both: + * - `system.ExtrinsicFailed` for direct signed calls, and + * - `sudo.Sudid { sudo_result: Err(...) }` for calls wrapped in `sudo.sudo`, + * where the outer extrinsic succeeds but the wrapped call returns `Err`. + * + * Returns `null` when the block contains neither. + */ +export async function lastModuleError(api: ApiPromise): Promise { + const events = await systemEvents(api); + + const failed = events.find((e) => e.event.section === "system" && e.event.method === "ExtrinsicFailed"); + if (failed) { + return decodeDispatchError(api, failed.event.data[0] as unknown as RawDispatchError); + } + + const sudid = events.find((e) => e.event.section === "sudo" && e.event.method === "Sudid"); + if (sudid) { + const result = sudid.event.data[0] as unknown as { + isErr: boolean; + asErr: RawDispatchError; + }; + if (result.isErr) { + return decodeDispatchError(api, result.asErr); + } + } + + return null; +} + +/** Reads the variant name of `referendumStatusFor(index)`. */ +export async function getStatusKind(api: ApiPromise, index: number): Promise { + const opt = await referendumStatusFor(api, index); + if (opt.isNone) return null; + const json = opt.toJSON() as Record | string | null; + if (!json || typeof json === "string") return null; + const keys = Object.keys(json); + if (keys.length === 0) return null; + return keys[0] as ReferendumStatusKind; +} + +export type Tally = { ayes: number; nays: number; total: number }; + +export async function getTally(api: ApiPromise, index: number): Promise { + const opt = (await api.query.signedVoting.tallyOf(index)) as unknown as OptionCodecLike; + return opt.isNone ? null : (opt.toJSON() as Tally); +} + +export async function getMembers(api: ApiPromise, collective: Collective): Promise { + const members = await api.query.multiCollective.members(collective); + return (members.toJSON() as string[]) ?? []; +} + +export async function getActiveCount(api: ApiPromise): Promise { + return (await api.query.referenda.activeCount()).toJSON() as number; +} + +export async function getActivePerProposer(api: ApiPromise, address: string): Promise { + return (await api.query.referenda.activePerProposer(address)).toJSON() as number; +}