diff --git a/Cargo.lock b/Cargo.lock index 381f607ddf..3f00ac1cb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10378,6 +10378,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" @@ -12719,7 +12741,7 @@ dependencies = [ "pallet-proxy", "pallet-ranked-collective", "pallet-recovery", - "pallet-referenda", + "pallet-referenda 41.0.0", "pallet-remark", "pallet-revive", "pallet-root-offences", @@ -14122,7 +14144,7 @@ dependencies = [ "pallet-proxy", "pallet-ranked-collective", "pallet-recovery", - "pallet-referenda", + "pallet-referenda 41.0.0", "pallet-root-testing", "pallet-scheduler", "pallet-session", @@ -20268,7 +20290,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/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..a40dba2caf --- /dev/null +++ b/pallets/referenda/README.md @@ -0,0 +1,194 @@ +# 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`. +- `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..e5c3393bb3 --- /dev/null +++ b/pallets/referenda/src/lib.rs @@ -0,0 +1,1127 @@ +#![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 cancels any prior alarm with the same name before +//! scheduling a new one. +//! +//! `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`. +//! * `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: cancels 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> { + let call = T::Preimages::bound(CallOf::::from(Call::advance_referendum { index }))?; + let _ = T::Scheduler::cancel_named(alarm_name(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..f39f5ff4f1 --- /dev/null +++ b/pallets/referenda/src/tests.rs @@ -0,0 +1,1846 @@ +#![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) + ); + + // 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_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..29904fd7fe --- /dev/null +++ b/pallets/referenda/src/types.rs @@ -0,0 +1,411 @@ +//! 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. + /// * `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"); + } + 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)) + } +}