diff --git a/Cargo.lock b/Cargo.lock index 78c1f9d102..563bbac7f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9738,6 +9738,26 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "pallet-governance" +version = "1.0.0" +dependencies = [ + "frame-support", + "frame-system", + "log", + "pallet-balances", + "pallet-preimage", + "pallet-scheduler", + "parity-scale-codec", + "polkadot-sdk-frame", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", + "subtensor-macros", +] + [[package]] name = "pallet-grandpa" version = "41.0.0" diff --git a/pallets/admin-utils/src/tests/mock.rs b/pallets/admin-utils/src/tests/mock.rs index ed62be55d8..4ee2497f2d 100644 --- a/pallets/admin-utils/src/tests/mock.rs +++ b/pallets/admin-utils/src/tests/mock.rs @@ -381,7 +381,6 @@ parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; pub const MaxScheduledPerBlock: u32 = 50; - pub const NoPreimagePostponement: Option = Some(10); } impl pallet_scheduler::Config for Test { diff --git a/pallets/governance/Cargo.toml b/pallets/governance/Cargo.toml new file mode 100644 index 0000000000..ff15c01d59 --- /dev/null +++ b/pallets/governance/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "pallet-governance" +version = "1.0.0" +authors = ["Bittensor Nucleus Team"] +edition.workspace = true +license = "Apache-2.0" +homepage = "https://bittensor.com" +description = "BitTensor governance pallet" +readme = "README.md" + +[lints] +workspace = true + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { workspace = true, features = ["max-encoded-len"] } +frame = { workspace = true, features = ["runtime"] } +scale-info = { workspace = true, features = ["derive"] } +subtensor-macros.workspace = true +frame-support.workspace = true +frame-system.workspace = true +sp-runtime.workspace = true +sp-std.workspace = true +sp-core.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 } +sp-io = { workspace = true, default-features = true } + +[features] +default = ["std"] +std = ["codec/std", "frame/std", "scale-info/std"] +runtime-benchmarks = ["frame/runtime-benchmarks"] +try-runtime = ["frame/try-runtime"] diff --git a/pallets/governance/README.md b/pallets/governance/README.md new file mode 100644 index 0000000000..855fc95f75 --- /dev/null +++ b/pallets/governance/README.md @@ -0,0 +1,192 @@ +# On-Chain Governance System + +## Abstract + +This proposes a comprehensive on-chain governance system to replace the current broken governance implementation that relies on a sudo-based triumvirate multisig. The new system introduces a separation of powers model with three key components: (1) multiple proposer accounts (mostly controlled by OTF) to submit proposals (call executed with root privilege), (2) a three-member Triumvirate that votes on proposals, and (3) two collective bodies (Economic Power and Building Power) that can delay, cancel, or fast-track proposals and vote to replace Triumvirate members. The system will be deployed in two phases: first coexisting with the current sudo implementation for validation, then fully replacing it. + +## Motivation + +The current governance system in Subtensor is broken and relies entirely on a triumvirate multisig with sudo privileges. The runtime contains dead code related to the original triumvirate collective and senate that no longer functions properly. This centralized approach creates several critical issues: + +1. **Single Point of Failure**: The sudo key represents a concentration of power with no on-chain checks or balances (i.e., no blockchain-enforced voting, approval, or oversight mechanisms). +2. **Lack of Transparency**: The governance decision-making process (who voted, when, on what proposal) happens off-chain and is not recorded or auditable on-chain. While the multisig signature itself provides cryptographic proof that the threshold was met, the governance process leading to that decision is opaque. +3. **No Stakeholder Representation**: Major stakeholders (validators and subnet owners) have no formal mechanism to influence protocol upgrades. +4. **Technical Debt**: Dead governance code in the runtime creates maintenance burden and confusion. + +This proposal addresses these issues by implementing a proper separation of powers that balances efficiency with stakeholder representation, while maintaining upgrade capability and security. + +## Specification + +### Overview + +The governance system consists of three main actors working together: + +1. **Allowed Proposers**: Accounts authorized to submit proposals (mostly controlled by OTF) +2. **Triumvirate**: Approval body of 3 members that vote on proposals +3. **Economic and Building Collectives**: Oversight bodies representing major stakeholders: top 16 validators by total stake and top 16 subnet owners by moving average price respectively + +### Actors and Roles + +#### Allowed Proposers (mostly OTF-controlled) + +- **Purpose**: Authorized to submit proposals (calls executed with root privilege) +- **Assignment**: Allowed proposer account keys are configured in the runtime via governance +- **Permissions**: + - Can submit proposals to the main governance track (i.e., runtime upgrade proposals or any root extrinsic) + - Can cancel or withdraw their own proposals anytime before execution (i.e., if they find a bug in the proposal code) + - Can eject its own key from the allowed proposers list (i.e., if it is lost or compromised) + - Can propose an update to the allowed proposers list via proposal flow + +**Open Questions:** + +- Q1: Who can add/remove proposer accounts? Only governance or should Triumvirate have emergency powers? +- Q2: Who validates that proposal code matches stated intent before Triumvirate votes? Share runtime WASM hash like Polkadot fellowship does? + +#### Triumvirate + +- **Composition**: 3 distinct accounts (must always maintain 3 members) +- **Role**: Vote on proposals submitted by allowed proposers +- **Voting Threshold**: 2-of-3 approval required for proposals to pass +- **Term**: Indefinite, subject to replacement by collective vote every 6 months (configurable) +- **Accountability**: Each member can be replaced through collective vote process (see Replacement Mechanism) +- **Permissions**: + - Can vote on proposals submitted by allowed proposers + +**Open Questions:** + +- Q3: How to allow a triumvirate member to resign? + +#### Economic and Building Collectives + +- **Economic Collective**: Top 16 validators by total stake (including delegated stake) (configurable) +- **Building Collective**: Top 16 subnet owners by moving average price (with minimum age of 6 months) (configurable) +- **Recalculation**: Membership refreshed every 6 months (configurable) +- **Permissions**: + - Can vote aye/nay on proposals submitted by allowed proposers and approved by Triumvirate + - More than 2/3 of aye vote for any collective fast tracks the proposal (next block execution) (threshold configurable) + - More than 1/2 of nay vote for any collective cancels the proposal (threshold configurable) + - Nays votes accumulate and delay the proposal execution exponentially until cancellation (see Delay Period section) + - Can replace a Triumvirate member every 6 months via single atomic vote (remove current holder + install replacement candidate, with rotating seat selection) + - Can mark himself as eligible for nomination to the Triumvirate + - Can accept a nomination to the Triumvirate + +**Open Questions:** + +- Q4: How to handle the nomination process? +- Q5: How to incentivize the collective members to vote? + +### Governance Process Flow + +#### Proposal Submission + +1. An allowed proposer account submits a proposal containing runtime upgrade or any root extrinsic +2. Proposal enters "Triumvirate Voting" phase +3. Voting period: 7 days (configurable), after this period, the proposal is automatically rejected if not approved by the Triumvirate. + +- There is a queue limit in the number of proposals that can be submitted at the same time (configurable) +- Proposal can be cancelled by the proposer before the final execution for security reasons (e.g., if they find a bug in the proposal code). +- An allowed proposer can eject its own key from the allowed proposers, removing all its submitted proposals waiting for triumvirate approval from the queue. + +#### Triumvirate Approval + +1. Triumvirate members cast votes (aye/nay) on the proposal + +- 2/3 vote aye, proposal is approved: Proposal is scheduled for execution in 1 hour (configurable) and enters "Delay Period" +- 2/3 vote nay, proposal is rejected: Proposal is cleaned up from storage (it was never scheduled for execution). + +- Triumvirate members can change their vote during the voting period (before the proposal is scheduled or cancelled). +- There is a queue limit in the number of scheduled proposals and in the delay period (configurable). +- If a triumvirate member is replaced, all his votes are removed from the active proposals. + +#### Delay Period (Collective Oversight) + +When a proposal has been approved by the Triumvirate, it is scheduled in 1 hour (configurable) and enters the "Delay Period" where the Economic and Building Collectives can vote to delay, cancel or fast-track the proposal. + +1. Both collectives can vote aye/nay on the proposal +2. Delay is an exponential function of the number of nays votes, set to 2^n (configurable). + +- Initial delay is 1 hour (configurable). +- After 1 nays vote, the delay is 2^1 \* 1 hour = 2 hours. +- After 2 nays votes, the delay is 2^2 \* 1 hour = 4 hours. +- After 3 nays votes, the delay is 2^3 \* 1 hour = 8 hours. +- After 4 nays votes, the delay is 2^4 \* 1 hour = 16 hours. +- After 5 nays votes, the delay is 2^5 \* 1 hour = 32 hours. +- After 6 nays votes, the delay is 2^6 \* 1 hour = 64 hours. +- After 7 nays votes, the delay is 2^7 \* 1 hour = 128 hours. +- After 8 nays votes, the delay is 2^8 \* 1 hour = 256 hours. +- After 9 nays votes, proposal is cancelled (given we have a collective size of 16, hence more than 1/2 of the collective votes nay). + +3. If the delay period expires without cancellation: Proposal executes automatically + +- The delay is calculated based on the collective with the most nays votes (i.e., if Economic has 3 nays and Building has 1 nay, the delay is based on 3 nays = 8 hours). +- More than 2/3 of aye vote for any collective fast tracks the proposal (next block execution) (threshold configurable) +- More than 1/2 of nay vote for any collective cancels the proposal (threshold configurable) +- Collective members can change their vote during the delay period. If changing a nay vote to aye reduces the delay below the time already elapsed, the proposal executes immediately. + - **Example**: A proposal has 3 nays votes, creating a 8 hours delay. After 5 hours have elapsed, a collective member changes their nay vote to aye, reducing the delay to 4 hours. Since 5 hours have already passed (more than the new 4 hours delay), the proposal executes immediately. + +**Open Questions:** + +- Q6: Should the voting be across both collectives or each collective votes independently? What if a collective decide to go rogue and fast track proposals that the other collective is against or vice versa? + +#### Execution + +- Proposals executed automatically after the delay period if not cancelled or when fast-tracked by the collectives. +- If executing fails, the proposal is not retried and is cleaned up from storage. + +### Triumvirate Replacement Mechanism + +Each collective can replace one Triumvirate member every 6 months through a **single atomic vote**: the collective votes to replace the current seat holder with a randomly selected new candidate from the eligible candidates. If the vote succeeds, the replacement happens immediately. The Triumvirate always maintains exactly 3 active members. + +#### Timing + +- Each collective can initiate replacement vote every 6 months (configurable) +- Economic and Building collectives have independent cycles (seat are rotated independently) + +**Open Questions:** + +- Q7: How to have an emergency replacement vote? +- Q8: Can a replaced member be voted back in immediately, or should there be a cooldown period? + +#### Rotating Seat Selection + +- Triumvirate seats are numbered: Seat 0, Seat 1, Seat 2 +- Each collective maintains an independent rotation index that determines which seat they target: +- Economic Power automatically targets the next seat in rotation: + - If last removal was Seat 0, next automatically targets Seat 1 + - If last removal was Seat 1, next automatically targets Seat 2 + - If last removal was Seat 2, next automatically targets Seat 0 +- Building Power has independent automatic rotation +- Rotation ensures no single seat is disproportionately targeted +- Collective members cannot choose which seat to target: it's determined automatically + +#### Replacement Process (Single Atomic Vote) + +The replacement happens in a single vote where the collective votes **both** to remove the current seat holder **and** to install a specific replacement candidate. This is an atomic operation: either both happen or neither happens. + +**Process:** + +1. **Eligibility Phase**: Collective members can mark themselves as eligible for nomination to the Triumvirate. +2. **Voting Phase**: Collective members can vote aye/nay during the voting period to replace the current seat holder. + - Threshold of more than 1/2 of the collective size (configurable) + - **If vote succeeds**: Current seat holder immediately removed, replacement candidate immediately installed + - **If vote fails**: No change, current member remains. +3. **Selection Phase**: The replacement candidate is selected randomly from the eligible candidates. +4. **Validation Phase**: The replacement candidate validates their nomination on-chain to avoid nominating inactive members. +5. **Transition**: Atomic swap ensures Triumvirate always has exactly 3 members with no vacancy period + +### Implementation Phases + +#### Phase 1: Coexistence (Duration: TBD) + +1. Remove dead code: triumvirate collective and senate pallets and related code +2. Implement the governance as a new pallet +3. Deploy new governance pallet to runtime +4. Configure initial Triumvirate members and allowed proposers. +5. Run new governance system in parallel with existing sudo multisig +6. Emergency procedures documented and tested +7. Community review and feedback period + +#### Phase 2: Full Migration + +1. Disable sudo pallet via governance vote (new runtime) +2. New governance system becomes sole authority diff --git a/pallets/governance/src/lib.rs b/pallets/governance/src/lib.rs new file mode 100644 index 0000000000..9c9bf1cdf4 --- /dev/null +++ b/pallets/governance/src/lib.rs @@ -0,0 +1,1185 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +use frame_support::{ + dispatch::{GetDispatchInfo, RawOrigin}, + pallet_prelude::*, + sp_runtime::traits::Dispatchable, + traits::{ + Bounded, ChangeMembers, IsSubType, QueryPreimage, StorePreimage, fungible, + fungible::MutateHold, + schedule::{ + DispatchTime, Priority, + v3::{Named as ScheduleNamed, TaskName}, + }, + }, +}; +use frame_system::pallet_prelude::*; +use sp_runtime::{ + FixedU128, Percent, Saturating, + traits::{Hash, SaturatedConversion, UniqueSaturatedInto}, +}; +use sp_std::{collections::btree_set::BTreeSet, vec::Vec}; +use subtensor_macros::freeze_struct; + +mod mock; +mod tests; +pub use pallet::*; + +/// WARNING: Any changes to these 3 constants require a migration to update the `BoundedVec` in storage +/// for `Triumvirate`, `EconomicCollective`, or `BuildingCollective`. +pub const TRIUMVIRATE_SIZE: u32 = 3; +pub const ECONOMIC_COLLECTIVE_SIZE: u32 = 16; +pub const BUILDING_COLLECTIVE_SIZE: u32 = 16; + +pub const TOTAL_COLLECTIVES_SIZE: u32 = ECONOMIC_COLLECTIVE_SIZE + BUILDING_COLLECTIVE_SIZE; + +pub type CurrencyOf = ::Currency; + +pub type BalanceOf = + as fungible::Inspect<::AccountId>>::Balance; + +pub type LocalCallOf = ::RuntimeCall; + +pub type BoundedCallOf = Bounded, ::Hashing>; + +pub type PalletsOriginOf = + <::RuntimeOrigin as OriginTrait>::PalletsOrigin; + +pub type ScheduleAddressOf = + , LocalCallOf, PalletsOriginOf>>::Address; + +/// Simple index type for proposal counting. +pub type ProposalIndex = u32; + +#[derive(PartialEq, Eq, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] +#[freeze_struct("7b322ade3ccaaba")] +pub struct TriumvirateVotes { + /// The proposal's unique index. + index: ProposalIndex, + /// The set of triumvirate members that approved it. + ayes: BoundedVec>, + /// The set of triumvirate members that rejected it. + nays: BoundedVec>, + /// The hard end time of this vote. + end: BlockNumber, +} + +#[derive(PartialEq, Eq, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] +#[freeze_struct("68b000ed325d45c4")] +pub struct CollectiveVotes { + /// The proposal's unique index. + index: ProposalIndex, + /// The set of collective members that approved it. + ayes: BoundedVec>, + /// The set of collective members that rejected it. + nays: BoundedVec>, + /// The initial dispatch time of the proposal. + initial_dispatch_time: BlockNumber, + /// The additional delay applied to the proposal on top of the initial delay. + delay: BlockNumber, +} + +/// The type of collective. +#[derive( + PartialEq, + Eq, + Clone, + Encode, + Decode, + RuntimeDebug, + TypeInfo, + MaxEncodedLen, + Copy, + DecodeWithMemTracking, +)] +pub enum CollectiveType { + Economic, + Building, +} + +pub trait CollectiveMembersProvider { + fn get_economic_collective() -> BoundedVec>; + fn get_building_collective() -> BoundedVec>; +} + +#[frame_support::pallet] +pub mod pallet { + use super::*; + + 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 overarching call type. + type RuntimeCall: Parameter + + Dispatchable + + GetDispatchInfo + + From> + + IsSubType> + + IsType<::RuntimeCall>; + + /// The overarching hold reason. + type RuntimeHoldReason: From; + + /// The currency mechanism. + type Currency: fungible::MutateHold; + + /// The preimage provider which will be used to store the call to dispatch. + type Preimages: QueryPreimage + StorePreimage; + + /// The scheduler which will be used to schedule the proposal for execution. + type Scheduler: ScheduleNamed< + BlockNumberFor, + LocalCallOf, + PalletsOriginOf, + Hasher = Self::Hashing, + >; + + /// Origin allowed to set allowed proposers. + type SetAllowedProposersOrigin: EnsureOrigin; + + /// Origin allowed to set triumvirate. + type SetTriumvirateOrigin: EnsureOrigin; + + /// The collective members provider. + type CollectiveMembersProvider: CollectiveMembersProvider; + + /// How many accounts allowed to submit proposals. + #[pallet::constant] + type MaxAllowedProposers: Get; + + /// Maximum weight for a proposal. + #[pallet::constant] + type MaxProposalWeight: Get; + + /// Maximum number of proposals allowed to be active in parallel. + #[pallet::constant] + type MaxProposals: Get; + + /// Maximum number of proposals that can be scheduled for execution in parallel. + #[pallet::constant] + type MaxScheduled: Get; + + /// The duration of a motion. + #[pallet::constant] + type MotionDuration: Get>; + + /// Initial scheduling delay for proposal execution. + #[pallet::constant] + type InitialSchedulingDelay: Get>; + + /// The factor to be used to compute the additional delay for a proposal. + #[pallet::constant] + type AdditionalDelayFactor: Get; + + /// Period of time between collective rotations. + #[pallet::constant] + type CollectiveRotationPeriod: Get>; + + /// Period of time between cleanup of proposals and scheduled proposals. + #[pallet::constant] + type CleanupPeriod: Get>; + + /// Percent threshold for a proposal to be cancelled by a collective vote. + #[pallet::constant] + type CancellationThreshold: Get; + + /// Percent threshold for a proposal to be fast-tracked by a collective vote. + #[pallet::constant] + type FastTrackThreshold: Get; + + /// Lock cost for a candidate to be eligible. + #[pallet::constant] + type EligibilityLockCost: Get>; + + /// Percent threshold for a candidate to be nominated. + #[pallet::constant] + type NominationThreshold: Get; + } + + /// Accounts allowed to submit proposals. + #[pallet::storage] + pub type AllowedProposers = + StorageValue<_, BoundedVec, ValueQuery>; + + /// Active members of the triumvirate. + #[pallet::storage] + pub type Triumvirate = + StorageValue<_, BoundedVec>, ValueQuery>; + + #[pallet::storage] + pub type ProposalCount = StorageValue<_, u32, ValueQuery>; + + /// Tuples of account proposer and hash of the active proposals being voted on. + #[pallet::storage] + pub type Proposals = + StorageValue<_, BoundedVec<(T::AccountId, T::Hash), T::MaxProposals>, ValueQuery>; + + /// Actual proposal for a given hash. + #[pallet::storage] + pub type ProposalOf = + StorageMap<_, Identity, T::Hash, BoundedCallOf, OptionQuery>; + + /// Triumvirate votes for a given proposal, if it is ongoing. + #[pallet::storage] + pub type TriumvirateVoting = StorageMap< + _, + Identity, + T::Hash, + TriumvirateVotes>, + OptionQuery, + >; + + /// The hashes of the proposals that have been scheduled for execution. + #[pallet::storage] + pub type Scheduled = + StorageValue<_, BoundedVec, ValueQuery>; + + /// The economic collective members (top 20 validators by total stake). + #[pallet::storage] + pub type EconomicCollective = + StorageValue<_, BoundedVec>, ValueQuery>; + + /// The building collective members (top 20 subnet owners by moving average price). + #[pallet::storage] + pub type BuildingCollective = + StorageValue<_, BoundedVec>, ValueQuery>; + + /// Collectives votes for a given proposal, if it is scheduled. + #[pallet::storage] + pub type CollectiveVoting = StorageMap< + _, + Identity, + T::Hash, + CollectiveVotes>, + OptionQuery, + >; + + /// Eligible candidates from the collectives for the triumvirate. + #[pallet::storage] + pub type EligibleCandidates = + StorageValue<_, BoundedVec>, ValueQuery>; + + /// The current rotation index for the triumvirate seats. + #[pallet::storage] + pub type RotationIndex = StorageValue<_, u32, ValueQuery>; + + /// Votes for a candidate in the current seat replacement period. + #[pallet::storage] + pub type CandidateVotes = StorageMap< + _, + Identity, + T::AccountId, + BoundedVec>, + ValueQuery, + >; + + /// The candidate that a member has voted for in the current seat replacement period. + #[pallet::storage] + pub type MemberVote = + StorageMap<_, Identity, T::AccountId, T::AccountId, OptionQuery>; + + /// The nominated candidate for a collective in the current seat replacement period. + #[pallet::storage] + pub type NominatedCandidate = + StorageMap<_, Identity, CollectiveType, (T::AccountId, BlockNumberFor), OptionQuery>; + + #[pallet::genesis_config] + #[derive(frame_support::DefaultNoBound)] + pub struct GenesisConfig { + pub allowed_proposers: Vec, + pub triumvirate: Vec, + } + + #[pallet::genesis_build] + impl BuildGenesisConfig for GenesisConfig { + fn build(&self) { + let allowed_proposers_set = Pallet::::check_for_duplicates(&self.allowed_proposers) + .expect("Allowed proposers cannot contain duplicate accounts."); + assert!( + self.allowed_proposers.len() <= T::MaxAllowedProposers::get() as usize, + "Allowed proposers length cannot exceed MaxAllowedProposers." + ); + + let triumvirate_set = Pallet::::check_for_duplicates(&self.triumvirate) + .expect("Triumvirate cannot contain duplicate accounts."); + assert!( + self.triumvirate.len() <= TRIUMVIRATE_SIZE as usize, + "Triumvirate length cannot exceed {TRIUMVIRATE_SIZE}." + ); + + assert!( + allowed_proposers_set.is_disjoint(&triumvirate_set), + "Allowed proposers and triumvirate must be disjoint." + ); + + Pallet::::initialize_allowed_proposers(&self.allowed_proposers); + Pallet::::initialize_triumvirate(&self.triumvirate); + } + } + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// The allowed proposers have been set. + AllowedProposersSet { + incoming: Vec, + outgoing: Vec, + removed_proposals: Vec<(T::AccountId, T::Hash)>, + }, + /// The triumvirate has been set. + TriumvirateSet { + incoming: Vec, + outgoing: Vec, + }, + /// A proposal has been submitted. + ProposalSubmitted { + account: T::AccountId, + proposal_index: u32, + proposal_hash: T::Hash, + voting_end: BlockNumberFor, + }, + /// A triumvirate member has voted on a proposal. + VotedOnProposal { + account: T::AccountId, + proposal_hash: T::Hash, + voted: bool, + yes: u32, + no: u32, + }, + /// A collective member has voted on a scheduled proposal. + VotedOnScheduled { + account: T::AccountId, + proposal_hash: T::Hash, + voted: bool, + yes: u32, + no: u32, + }, + /// A proposal has been scheduled for execution by triumvirate. + ProposalScheduled { proposal_hash: T::Hash }, + /// A proposal has been cancelled by triumvirate. + ProposalCancelled { proposal_hash: T::Hash }, + /// A scheduled proposal has been fast-tracked by collectives. + ScheduledProposalFastTracked { proposal_hash: T::Hash }, + /// A scheduled proposal has been cancelled by collectives. + ScheduledProposalCancelled { proposal_hash: T::Hash }, + /// A scheduled proposal schedule time has been delayed by collectives. + ScheduledProposalDelayAdjusted { + proposal_hash: T::Hash, + dispatch_time: DispatchTime>, + }, + /// A new eligible candidate has been added for a collective. + NewEligibleCandidate { + collective: CollectiveType, + account: T::AccountId, + }, + /// A collective member has voted on a candidate to replace a triumvirate seat. + VotedOnSeatReplacement { + account: T::AccountId, + candidate: T::AccountId, + }, + /// A candidate has been nominated by a collective. + CandidateNominated { + collective: CollectiveType, + candidate: T::AccountId, + votes: u32, + }, + } + + #[pallet::error] + pub enum Error { + /// Duplicate accounts not allowed. + DuplicateAccounts, + /// There can only be a maximum of `MaxAllowedProposers` allowed proposers. + TooManyAllowedProposers, + /// Triumvirate length cannot exceed 3. + InvalidTriumvirateLength, + /// Allowed proposers and triumvirate must be disjoint. + AllowedProposersAndTriumvirateMustBeDisjoint, + /// Origin is not an allowed proposer. + NotAllowedProposer, + /// The given weight bound for the proposal was too low. + WrongProposalLength, + /// The given weight bound for the proposal was too low. + WrongProposalWeight, + /// Duplicate proposals not allowed. + DuplicateProposal, + /// There can only be a maximum of `MaxProposals` active proposals in parallel. + TooManyProposals, + /// Origin is not a triumvirate member. + NotTriumvirateMember, + /// Proposal must exist. + ProposalMissing, + /// Mismatched index. + WrongProposalIndex, + /// Duplicate vote not allowed. + DuplicateVote, + /// Unreachable code path. + Unreachable, + /// There can only be a maximum of `MaxScheduled` proposals scheduled for execution. + TooManyScheduled, + /// Call is not available in the preimage storage. + CallUnavailable, + /// Proposal hash is not 32 bytes. + InvalidProposalHashLength, + /// Proposal is already scheduled. + AlreadyScheduled, + /// Origin is not a collective member. + NotCollectiveMember, + /// Proposal is not scheduled. + ProposalNotScheduled, + /// Proposal voting period has ended. + VotingPeriodEnded, + /// Collective member is already marked as eligible. + AlreadyEligible, + /// Insufficient funds for eligibility lock. + InsufficientFundsForEligibilityLock, + /// Candidate is not eligible for nomination. + CandidateNotEligible, + /// A nominee has already been selected for this collective. + NomineeAlreadySelected, + /// Candidate must belong to the same collective as the voter. + CandidateNotSameCollective, + /// Self voting is not allowed. + SelfVoteNotAllowed, + } + + /// A reason for the pallet governance placing a hold on funds. + #[pallet::composite_enum] + pub enum HoldReason { + /// The pallet has reserved it for eligibility lock. + EligibilityLock, + } + + #[pallet::hooks] + impl Hooks> for Pallet { + fn on_initialize(now: BlockNumberFor) -> Weight { + let mut weight = Weight::zero(); + + let economic_collective = EconomicCollective::::get(); + let building_collective = BuildingCollective::::get(); + let is_first_run = economic_collective.is_empty() || building_collective.is_empty(); + let should_rotate = now % T::CollectiveRotationPeriod::get() == Zero::zero(); + let should_cleanup = now % T::CleanupPeriod::get() == Zero::zero(); + + if is_first_run || should_rotate { + weight.saturating_accrue(Self::rotate_collectives()); + } + + if should_cleanup { + weight.saturating_accrue(Self::cleanup_proposals(now)); + weight.saturating_accrue(Self::cleanup_scheduled()); + } + + weight + } + } + + #[pallet::call] + impl Pallet { + /// Set the allowed proposers. + #[pallet::call_index(0)] + #[pallet::weight(Weight::zero())] + pub fn set_allowed_proposers( + origin: OriginFor, + mut new_allowed_proposers: BoundedVec, + ) -> DispatchResult { + T::SetAllowedProposersOrigin::ensure_origin(origin)?; + + let new_allowed_proposers_set = + Pallet::::check_for_duplicates(&new_allowed_proposers) + .ok_or(Error::::DuplicateAccounts)?; + + let triumvirate = Triumvirate::::get(); + let triumvirate_set: BTreeSet<_> = triumvirate.iter().collect(); + ensure!( + triumvirate_set.is_disjoint(&new_allowed_proposers_set), + Error::::AllowedProposersAndTriumvirateMustBeDisjoint + ); + + let mut allowed_proposers = AllowedProposers::::get().to_vec(); + allowed_proposers.sort(); + new_allowed_proposers.sort(); + let (incoming, outgoing) = + <() as ChangeMembers>::compute_members_diff_sorted( + new_allowed_proposers.as_ref(), + &allowed_proposers, + ); + + // Remove proposals from the outgoing allowed proposers. + let mut removed_proposals = vec![]; + for (proposer, proposal_hash) in Proposals::::get() { + if outgoing.contains(&proposer) { + Self::clear_proposal(proposal_hash); + removed_proposals.push((proposer, proposal_hash)); + } + } + + AllowedProposers::::put(new_allowed_proposers); + + Self::deposit_event(Event::::AllowedProposersSet { + incoming, + outgoing, + removed_proposals, + }); + Ok(()) + } + + /// Set the triumvirate. + #[pallet::call_index(1)] + #[pallet::weight(Weight::zero())] + pub fn set_triumvirate( + origin: OriginFor, + mut new_triumvirate: BoundedVec>, + ) -> DispatchResult { + T::SetTriumvirateOrigin::ensure_origin(origin)?; + + let new_triumvirate_set = Pallet::::check_for_duplicates(&new_triumvirate) + .ok_or(Error::::DuplicateAccounts)?; + ensure!( + new_triumvirate.len() == TRIUMVIRATE_SIZE as usize, + Error::::InvalidTriumvirateLength + ); + + let allowed_proposers = AllowedProposers::::get(); + let allowed_proposers_set: BTreeSet<_> = allowed_proposers.iter().collect(); + ensure!( + allowed_proposers_set.is_disjoint(&new_triumvirate_set), + Error::::AllowedProposersAndTriumvirateMustBeDisjoint + ); + + let mut triumvirate = Triumvirate::::get().to_vec(); + triumvirate.sort(); + new_triumvirate.sort(); + let (incoming, outgoing) = + <() as ChangeMembers>::compute_members_diff_sorted( + new_triumvirate.as_ref(), + &triumvirate, + ); + + // Remove votes from the outgoing triumvirate members. + for (_proposer, proposal_hash) in Proposals::::get() { + TriumvirateVoting::::mutate(proposal_hash, |voting| { + if let Some(voting) = voting.as_mut() { + voting.ayes.retain(|a| !outgoing.contains(a)); + voting.nays.retain(|a| !outgoing.contains(a)); + } + }); + } + + Triumvirate::::put(new_triumvirate); + + Self::deposit_event(Event::::TriumvirateSet { incoming, outgoing }); + Ok(()) + } + + /// Propose a new proposal. + #[pallet::call_index(2)] + #[pallet::weight(Weight::zero())] + pub fn propose( + origin: OriginFor, + proposal: Box<::RuntimeCall>, + #[pallet::compact] length_bound: u32, + ) -> DispatchResult { + let who = Self::ensure_allowed_proposer(origin)?; + + let proposal_len = proposal.encoded_size(); + ensure!( + proposal_len <= length_bound as usize, + Error::::WrongProposalLength + ); + let proposal_weight = proposal.get_dispatch_info().call_weight; + ensure!( + proposal_weight.all_lte(T::MaxProposalWeight::get()), + Error::::WrongProposalWeight + ); + + let proposal_hash = T::Hashing::hash_of(&proposal); + ensure!( + !ProposalOf::::contains_key(proposal_hash), + Error::::DuplicateProposal + ); + let scheduled = Scheduled::::get(); + ensure!( + !scheduled.contains(&proposal_hash), + Error::::AlreadyScheduled + ); + + Proposals::::try_append((who.clone(), proposal_hash)) + .map_err(|_| Error::::TooManyProposals)?; + + let proposal_index = ProposalCount::::get(); + ProposalCount::::mutate(|i| i.saturating_inc()); + + let bounded_proposal = T::Preimages::bound(*proposal)?; + ProposalOf::::insert(proposal_hash, bounded_proposal); + + let now = frame_system::Pallet::::block_number(); + let end = now + T::MotionDuration::get(); + TriumvirateVoting::::insert( + proposal_hash, + TriumvirateVotes { + index: proposal_index, + ayes: BoundedVec::new(), + nays: BoundedVec::new(), + end, + }, + ); + + Self::deposit_event(Event::::ProposalSubmitted { + account: who, + proposal_index, + proposal_hash, + voting_end: end, + }); + Ok(()) + } + + /// Vote on a proposal as a triumvirate member. + #[pallet::call_index(3)] + #[pallet::weight(Weight::zero())] + pub fn vote_on_proposed( + origin: OriginFor, + proposal_hash: T::Hash, + #[pallet::compact] proposal_index: ProposalIndex, + approve: bool, + ) -> DispatchResult { + let who = Self::ensure_triumvirate_member(origin)?; + + let proposals = Proposals::::get(); + ensure!( + proposals.iter().any(|(_, h)| h == &proposal_hash), + Error::::ProposalMissing + ); + + let voting = Self::do_vote_on_proposed(&who, proposal_hash, proposal_index, approve)?; + + let yes_votes = voting.ayes.len() as u32; + let no_votes = voting.nays.len() as u32; + + Self::deposit_event(Event::::VotedOnProposal { + account: who, + proposal_hash, + voted: approve, + yes: yes_votes, + no: no_votes, + }); + + if yes_votes >= 2 { + Self::schedule(proposal_hash, proposal_index)?; + } else if no_votes >= 2 { + Self::cancel(proposal_hash)?; + } + + Ok(()) + } + + /// Vote on a proposal as a collective member. + #[pallet::call_index(4)] + #[pallet::weight(Weight::zero())] + pub fn vote_on_scheduled( + origin: OriginFor, + proposal_hash: T::Hash, + #[pallet::compact] proposal_index: ProposalIndex, + approve: bool, + ) -> DispatchResult { + let (who, _) = Self::ensure_collective_member(origin)?; + + let scheduled = Scheduled::::get(); + ensure!( + scheduled.contains(&proposal_hash), + Error::::ProposalNotScheduled + ); + + let voting = Self::do_vote_on_scheduled(&who, proposal_hash, proposal_index, approve)?; + + let yes_votes = voting.ayes.len() as u32; + let no_votes = voting.nays.len() as u32; + + Self::deposit_event(Event::::VotedOnScheduled { + account: who, + proposal_hash, + voted: approve, + yes: yes_votes, + no: no_votes, + }); + + let should_fast_track = + yes_votes >= T::FastTrackThreshold::get().mul_ceil(TOTAL_COLLECTIVES_SIZE); + let should_cancel = + no_votes >= T::CancellationThreshold::get().mul_ceil(TOTAL_COLLECTIVES_SIZE); + + if should_fast_track { + Self::fast_track(proposal_hash)?; + } else if should_cancel { + Self::cancel_scheduled(proposal_hash)?; + } else { + Self::adjust_delay(proposal_hash, voting)?; + } + + Ok(()) + } + + /// Mark a collective member as eligible to replace a triumvirate seat. + #[pallet::call_index(5)] + #[pallet::weight(Weight::zero())] + pub fn mark_as_eligible(origin: OriginFor) -> DispatchResult { + let (who, collective) = Self::ensure_collective_member(origin)?; + + let candidates = EligibleCandidates::::get(); + ensure!(!candidates.contains(&who), Error::::AlreadyEligible); + + T::Currency::hold( + &HoldReason::EligibilityLock.into(), + &who, + T::EligibilityLockCost::get(), + ) + .map_err(|_| Error::::InsufficientFundsForEligibilityLock)?; + + EligibleCandidates::::try_append(&who) + // Unreachable because nobody can double mark themselves as eligible. + .map_err(|_| Error::::Unreachable)?; + + Self::deposit_event(Event::::NewEligibleCandidate { + collective, + account: who, + }); + Ok(()) + } + + /// Vote on a candidate to replace a triumvirate seat. + #[pallet::call_index(6)] + #[pallet::weight(Weight::zero())] + pub fn vote_on_seat_replacement( + origin: OriginFor, + candidate: T::AccountId, + ) -> DispatchResult { + let (who, caller_collective) = Self::ensure_collective_member(origin)?; + + ensure!(who != candidate, Error::::SelfVoteNotAllowed); + + let candidates = EligibleCandidates::::get(); + ensure!( + candidates.contains(&candidate), + Error::::CandidateNotEligible + ); + + let candidate_collective = Self::get_member_collective(&candidate) + // Unreachable because candidates are guaranteed to be collective members. + .ok_or(Error::::Unreachable)?; + ensure!( + caller_collective == candidate_collective, + Error::::CandidateNotSameCollective + ); + + ensure!( + !NominatedCandidate::::contains_key(caller_collective), + Error::::NomineeAlreadySelected + ); + + if let Some(old_candidate) = MemberVote::::get(&who) { + if old_candidate == candidate { + return Err(Error::::DuplicateVote.into()); + } + + // Remove old vote + let mut should_remove = false; + CandidateVotes::::mutate(&old_candidate, |votes| { + if let Some(pos) = votes.iter().position(|x| x == &who) { + votes.swap_remove(pos); + } + should_remove = votes.is_empty(); + }); + if should_remove { + CandidateVotes::::remove(&old_candidate); + } + } + + MemberVote::::insert(&who, &candidate); + CandidateVotes::::try_mutate(&candidate, |votes| { + votes + .try_push(who.clone()) + // Unreachable because this is bounded by total collectives size + // and we prevent double voting. + .map_err(|_| Error::::Unreachable) + })?; + + Self::deposit_event(Event::::VotedOnSeatReplacement { + account: who, + candidate: candidate.clone(), + }); + + let votes_count = CandidateVotes::::get(&candidate).len() as u32; + let collective_size = match caller_collective { + CollectiveType::Economic => ECONOMIC_COLLECTIVE_SIZE, + CollectiveType::Building => BUILDING_COLLECTIVE_SIZE, + }; + let threshold = T::NominationThreshold::get().mul_ceil(collective_size); + + // Check for nomination + if votes_count >= threshold { + let now = frame_system::Pallet::::block_number(); + NominatedCandidate::::insert(caller_collective, (candidate.clone(), now)); + + Self::deposit_event(Event::::CandidateNominated { + collective: caller_collective, + candidate, + votes: votes_count, + }); + } + + Ok(()) + } + } +} + +impl Pallet { + fn initialize_allowed_proposers(allowed_proposers: &[T::AccountId]) { + if !allowed_proposers.is_empty() { + assert!( + AllowedProposers::::get().is_empty(), + "Allowed proposers are already initialized!" + ); + let mut allowed_proposers = BoundedVec::truncate_from(allowed_proposers.to_vec()); + allowed_proposers.sort(); + AllowedProposers::::put(allowed_proposers); + } + } + + fn initialize_triumvirate(triumvirate: &[T::AccountId]) { + assert!( + Triumvirate::::get().is_empty(), + "Triumvirate is already initialized!" + ); + let mut triumvirate = BoundedVec::truncate_from(triumvirate.to_vec()); + triumvirate.sort(); + Triumvirate::::put(triumvirate); + } + + fn check_for_duplicates(accounts: &[T::AccountId]) -> Option> { + let accounts_set: BTreeSet<_> = accounts.iter().collect(); + if accounts_set.len() == accounts.len() { + Some(accounts_set) + } else { + None + } + } + + fn do_vote_on_proposed( + who: &T::AccountId, + proposal_hash: T::Hash, + index: ProposalIndex, + approve: bool, + ) -> Result>, DispatchError> { + TriumvirateVoting::::try_mutate(proposal_hash, |voting| { + let voting = voting.as_mut().ok_or(Error::::ProposalMissing)?; + ensure!(voting.index == index, Error::::WrongProposalIndex); + let now = frame_system::Pallet::::block_number(); + ensure!(voting.end > now, Error::::VotingPeriodEnded); + Self::vote_inner(who, approve, &mut voting.ayes, &mut voting.nays)?; + Ok(voting.clone()) + }) + } + + fn do_vote_on_scheduled( + who: &T::AccountId, + proposal_hash: T::Hash, + index: ProposalIndex, + approve: bool, + ) -> Result>, DispatchError> { + CollectiveVoting::::try_mutate(proposal_hash, |voting| { + // No voting here but we have proposal in scheduled, proposal + // has been fast-tracked. + let voting = voting.as_mut().ok_or(Error::::VotingPeriodEnded)?; + ensure!(voting.index == index, Error::::WrongProposalIndex); + Self::vote_inner(who, approve, &mut voting.ayes, &mut voting.nays)?; + Ok(voting.clone()) + }) + } + + fn vote_inner>( + who: &T::AccountId, + approve: bool, + ayes: &mut BoundedVec, + nays: &mut BoundedVec, + ) -> DispatchResult { + let has_yes_vote = ayes.iter().any(|a| a == who); + let has_no_vote = nays.iter().any(|a| a == who); + + if approve { + if !has_yes_vote { + ayes.try_push(who.clone()) + // Unreachable because nobody can double vote. + .map_err(|_| Error::::Unreachable)?; + } else { + return Err(Error::::DuplicateVote.into()); + } + if has_no_vote { + nays.retain(|a| a != who); + } + } else { + if !has_no_vote { + nays.try_push(who.clone()) + // Unreachable because nobody can double vote. + .map_err(|_| Error::::Unreachable)?; + } else { + return Err(Error::::DuplicateVote.into()); + } + if has_yes_vote { + ayes.retain(|a| a != who); + } + } + + Ok(()) + } + + fn schedule(proposal_hash: T::Hash, proposal_index: ProposalIndex) -> DispatchResult { + Scheduled::::try_append(proposal_hash).map_err(|_| Error::::TooManyScheduled)?; + + let bounded = ProposalOf::::get(proposal_hash).ok_or(Error::::ProposalMissing)?; + ensure!(T::Preimages::have(&bounded), Error::::CallUnavailable); + + let now = frame_system::Pallet::::block_number(); + let name = Self::task_name_from_hash(proposal_hash)?; + let dispatch_time = now.saturating_add(T::InitialSchedulingDelay::get()); + T::Scheduler::schedule_named( + name, + DispatchTime::At(dispatch_time), + None, + Priority::default(), + RawOrigin::Root.into(), + bounded, + )?; + Self::clear_proposal(proposal_hash); + + CollectiveVoting::::insert( + proposal_hash, + CollectiveVotes { + index: proposal_index, + ayes: BoundedVec::new(), + nays: BoundedVec::new(), + initial_dispatch_time: dispatch_time, + delay: Zero::zero(), + }, + ); + + Self::deposit_event(Event::::ProposalScheduled { proposal_hash }); + Ok(()) + } + + fn cancel(proposal_hash: T::Hash) -> DispatchResult { + Self::clear_proposal(proposal_hash); + Self::deposit_event(Event::::ProposalCancelled { proposal_hash }); + Ok(()) + } + + fn fast_track(proposal_hash: T::Hash) -> DispatchResult { + let name = Self::task_name_from_hash(proposal_hash)?; + T::Scheduler::reschedule_named( + name, + // It will be scheduled on the next block because scheduler already ran for this block. + DispatchTime::After(Zero::zero()), + )?; + CollectiveVoting::::remove(proposal_hash); + Self::deposit_event(Event::::ScheduledProposalFastTracked { proposal_hash }); + Ok(()) + } + + fn cancel_scheduled(proposal_hash: T::Hash) -> DispatchResult { + let name = Self::task_name_from_hash(proposal_hash)?; + T::Scheduler::cancel_named(name)?; + Scheduled::::mutate(|scheduled| scheduled.retain(|h| h != &proposal_hash)); + CollectiveVoting::::remove(proposal_hash); + Self::deposit_event(Event::::ScheduledProposalCancelled { proposal_hash }); + Ok(()) + } + + fn adjust_delay( + proposal_hash: T::Hash, + mut voting: CollectiveVotes>, + ) -> DispatchResult { + let net_score = (voting.nays.len() as i32).saturating_sub(voting.ayes.len() as i32); + let additional_delay = Self::compute_additional_delay(net_score); + + // No change, no need to reschedule + if voting.delay == additional_delay { + return Ok(()); + } + + let now = frame_system::Pallet::::block_number(); + let elapsed_time = now.saturating_sub(voting.initial_dispatch_time); + + // We are past new delay, fast track + if elapsed_time > additional_delay { + return Self::fast_track(proposal_hash); + } + + let name = Self::task_name_from_hash(proposal_hash)?; + let dispatch_time = DispatchTime::At( + voting + .initial_dispatch_time + .saturating_add(additional_delay), + ); + T::Scheduler::reschedule_named(name, dispatch_time)?; + + voting.delay = additional_delay; + CollectiveVoting::::insert(proposal_hash, voting); + + Self::deposit_event(Event::::ScheduledProposalDelayAdjusted { + proposal_hash, + dispatch_time, + }); + Ok(()) + } + + fn clear_proposal(proposal_hash: T::Hash) { + Proposals::::mutate(|proposals| { + proposals.retain(|(_, h)| h != &proposal_hash); + }); + ProposalOf::::remove(proposal_hash); + TriumvirateVoting::::remove(proposal_hash); + } + + fn rotate_collectives() -> Weight { + let mut weight = Weight::zero(); + + let economic_collective_members = T::CollectiveMembersProvider::get_economic_collective(); + let building_collective_members = T::CollectiveMembersProvider::get_building_collective(); + // TODO: handle weights + + EconomicCollective::::put(economic_collective_members); + BuildingCollective::::put(building_collective_members); + weight.saturating_accrue(T::DbWeight::get().writes(2)); + + weight + } + + fn cleanup_proposals(now: BlockNumberFor) -> Weight { + let mut weight = Weight::zero(); + + let mut proposals = Proposals::::get(); + weight.saturating_accrue(T::DbWeight::get().reads(1)); + + proposals.retain(|(_, proposal_hash)| { + let voting = TriumvirateVoting::::get(proposal_hash); + weight.saturating_accrue(T::DbWeight::get().reads(1)); + + match voting { + Some(voting) if voting.end > now => true, + _ => { + ProposalOf::::remove(proposal_hash); + TriumvirateVoting::::remove(proposal_hash); + weight.saturating_accrue(T::DbWeight::get().writes(2)); + false + } + } + }); + + Proposals::::put(proposals); + weight.saturating_accrue(T::DbWeight::get().writes(1)); + + weight + } + + fn cleanup_scheduled() -> Weight { + let mut weight = Weight::zero(); + + let mut scheduled = Scheduled::::get(); + weight.saturating_accrue(T::DbWeight::get().reads(1)); + + scheduled.retain( + |proposal_hash| match Self::task_name_from_hash(*proposal_hash) { + Ok(name) => { + let dispatch_time = T::Scheduler::next_dispatch_time(name); + CollectiveVoting::::remove(proposal_hash); + weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 1)); + dispatch_time.is_ok() + } + // Unreachable because proposal hash is always 32 bytes. + Err(_) => false, + }, + ); + + Scheduled::::put(scheduled); + weight.saturating_accrue(T::DbWeight::get().writes(1)); + + weight + } + + fn ensure_allowed_proposer(origin: OriginFor) -> Result { + let who = ensure_signed(origin)?; + let allowed_proposers = AllowedProposers::::get(); + ensure!( + allowed_proposers.contains(&who), + Error::::NotAllowedProposer + ); + Ok(who) + } + + fn ensure_triumvirate_member(origin: OriginFor) -> Result { + let who = ensure_signed(origin)?; + let triumvirate = Triumvirate::::get(); + ensure!(triumvirate.contains(&who), Error::::NotTriumvirateMember); + Ok(who) + } + + fn ensure_collective_member( + origin: OriginFor, + ) -> Result<(T::AccountId, CollectiveType), DispatchError> { + let who = ensure_signed(origin)?; + + let economic_collective = EconomicCollective::::get(); + if economic_collective.contains(&who) { + return Ok((who, CollectiveType::Economic)); + } + + let building_collective = BuildingCollective::::get(); + if building_collective.contains(&who) { + return Ok((who, CollectiveType::Building)); + } + + Err(Error::::NotCollectiveMember.into()) + } + + fn task_name_from_hash(proposal_hash: T::Hash) -> Result { + Ok(proposal_hash + .as_ref() + .try_into() + .map_err(|_| Error::::InvalidProposalHashLength)?) + } + + fn compute_additional_delay(net_score: i32) -> BlockNumberFor { + if net_score > 0 { + let initial_delay = + FixedU128::from_inner(T::InitialSchedulingDelay::get().unique_saturated_into()); + let multiplier = + T::AdditionalDelayFactor::get().saturating_pow(net_score.unsigned_abs() as usize); + multiplier + .saturating_mul(initial_delay) + .into_inner() + .saturated_into() + } else { + Zero::zero() + } + } + + fn get_member_collective(who: &T::AccountId) -> Option { + let economic_collective = T::CollectiveMembersProvider::get_economic_collective(); + if economic_collective.contains(who) { + return Some(CollectiveType::Economic); + } + + let building_collective = T::CollectiveMembersProvider::get_building_collective(); + if building_collective.contains(who) { + return Some(CollectiveType::Building); + } + + None + } +} diff --git a/pallets/governance/src/mock.rs b/pallets/governance/src/mock.rs new file mode 100644 index 0000000000..a435b8812a --- /dev/null +++ b/pallets/governance/src/mock.rs @@ -0,0 +1,276 @@ +#![cfg(test)] +#![allow( + clippy::arithmetic_side_effects, + clippy::expect_used, + clippy::unwrap_used +)] +use frame_support::{derive_impl, pallet_prelude::*, parameter_types, traits::EqualPrivilegeOnly}; +use frame_system::{EnsureRoot, limits, pallet_prelude::*}; +use sp_core::U256; +use sp_runtime::{BuildStorage, FixedU128, Perbill, Percent, traits::IdentityLookup}; +use sp_std::cell::RefCell; +use std::marker::PhantomData; + +use crate::{ + BUILDING_COLLECTIVE_SIZE, BalanceOf, CollectiveMembersProvider, ECONOMIC_COLLECTIVE_SIZE, + pallet as pallet_governance, +}; + +type Block = frame_system::mocking::MockBlock; +pub(crate) type AccountOf = ::AccountId; + +frame_support::construct_runtime!( + pub enum Test + { + System: frame_system = 1, + Balances: pallet_balances = 2, + Preimage: pallet_preimage = 3, + Scheduler: pallet_scheduler = 4, + Governance: pallet_governance = 5, + TestPallet: pallet_test = 6, + } +); + +#[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 FakeCollectiveMembersProvider(PhantomData); +impl CollectiveMembersProvider for FakeCollectiveMembersProvider +where + T::AccountId: From>, +{ + fn get_economic_collective() -> BoundedVec> { + BoundedVec::truncate_from( + ECONOMIC_COLLECTIVE + .with(|c| c.borrow().iter().map(|a| T::AccountId::from(*a)).collect()), + ) + } + fn get_building_collective() -> BoundedVec> { + BoundedVec::truncate_from( + BUILDING_COLLECTIVE + .with(|c| c.borrow().iter().map(|a| T::AccountId::from(*a)).collect()), + ) + } +} + +thread_local! { + pub static ECONOMIC_COLLECTIVE: RefCell>> = const { RefCell::new(vec![]) }; + pub static BUILDING_COLLECTIVE: RefCell>> = const { RefCell::new(vec![]) }; +} + +#[macro_export] +macro_rules! set_next_economic_collective { + ($members:expr) => {{ + assert_eq!($members.len(), ECONOMIC_COLLECTIVE_SIZE as usize); + ECONOMIC_COLLECTIVE.with_borrow_mut(|c| *c = $members.clone()); + }}; +} + +#[macro_export] +macro_rules! set_next_building_collective { + ($members:expr) => {{ + assert_eq!($members.len(), BUILDING_COLLECTIVE_SIZE as usize); + BUILDING_COLLECTIVE.with_borrow_mut(|c| *c = $members.clone()); + }}; +} + +parameter_types! { + pub const MaxAllowedProposers: u32 = 5; + pub const MaxProposalWeight: Weight = Weight::from_parts(1_000_000_000_000, 0); + pub const MaxProposals: u32 = 5; + pub const MaxScheduled: u32 = 10; + pub const MotionDuration: BlockNumberFor = 20; + pub const InitialSchedulingDelay: BlockNumberFor = 20; + pub const AdditionalDelayFactor: FixedU128 = FixedU128::from_rational(3, 2); // 1.5 + pub const CollectiveRotationPeriod: BlockNumberFor = 100; + pub const CleanupPeriod: BlockNumberFor = 500; + pub const FastTrackThreshold: Percent = Percent::from_percent(67); // ~2/3 + pub const CancellationThreshold: Percent = Percent::from_percent(51); + pub const EligibilityLockCost: BalanceOf = 1_000_000_000; + pub const NominationThreshold: Percent = Percent::from_percent(51); +} + +impl pallet_governance::Config for Test { + type RuntimeCall = RuntimeCall; + type RuntimeHoldReason = RuntimeHoldReason; + type Currency = Balances; + type Preimages = Preimage; + type Scheduler = Scheduler; + type SetAllowedProposersOrigin = EnsureRoot>; + type SetTriumvirateOrigin = EnsureRoot>; + type CollectiveMembersProvider = FakeCollectiveMembersProvider; + type MaxAllowedProposers = MaxAllowedProposers; + type MaxProposalWeight = MaxProposalWeight; + type MaxProposals = MaxProposals; + type MaxScheduled = MaxScheduled; + type MotionDuration = MotionDuration; + type InitialSchedulingDelay = InitialSchedulingDelay; + type AdditionalDelayFactor = AdditionalDelayFactor; + type CollectiveRotationPeriod = CollectiveRotationPeriod; + type CleanupPeriod = CleanupPeriod; + type CancellationThreshold = CancellationThreshold; + type FastTrackThreshold = FastTrackThreshold; + type EligibilityLockCost = EligibilityLockCost; + type NominationThreshold = NominationThreshold; +} + +#[frame_support::pallet] +pub(crate) mod pallet_test { + use super::MaxProposalWeight; + use frame_support::pallet_prelude::*; + use frame_system::pallet_prelude::*; + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config + Sized {} + + #[pallet::call] + impl Pallet { + #[pallet::call_index(0)] + #[pallet::weight(MaxProposalWeight::get() * 2)] + pub fn expensive_call(_origin: OriginFor) -> DispatchResult { + Ok(()) + } + } +} + +impl pallet_test::Config for Test {} + +pub(crate) struct TestState { + block_number: BlockNumberFor, + balances: Vec<(AccountOf, BalanceOf)>, + allowed_proposers: Vec>, + triumvirate: Vec>, + economic_collective: BoundedVec, ConstU32>, + building_collective: BoundedVec, ConstU32>, +} + +impl Default for TestState { + fn default() -> Self { + Self { + block_number: 1, + balances: vec![], + allowed_proposers: vec![U256::from(1), U256::from(2), U256::from(3)], + triumvirate: vec![U256::from(1001), U256::from(1002), U256::from(1003)], + economic_collective: BoundedVec::truncate_from( + (1..=ECONOMIC_COLLECTIVE_SIZE) + .map(|i| U256::from(2000 + i)) + .collect::>(), + ), + building_collective: BoundedVec::truncate_from( + (1..=BUILDING_COLLECTIVE_SIZE) + .map(|i| U256::from(3000 + i)) + .collect::>(), + ), + } + } +} + +impl TestState { + pub(crate) fn with_balance(mut self, who: AccountOf, balance: BalanceOf) -> Self { + self.balances.push((who, balance)); + self + } + + pub(crate) fn with_allowed_proposers( + mut self, + allowed_proposers: Vec>, + ) -> Self { + self.allowed_proposers = allowed_proposers; + self + } + + pub(crate) fn with_triumvirate(mut self, triumvirate: Vec>) -> Self { + self.triumvirate = triumvirate; + self + } + + pub(crate) fn build(self) -> sp_io::TestExternalities { + let mut ext: sp_io::TestExternalities = RuntimeGenesisConfig { + system: frame_system::GenesisConfig::default(), + balances: pallet_balances::GenesisConfig { + balances: self.balances, + ..Default::default() + }, + governance: pallet_governance::GenesisConfig { + allowed_proposers: self.allowed_proposers, + triumvirate: self.triumvirate, + }, + } + .build_storage() + .unwrap() + .into(); + ext.execute_with(|| { + set_next_economic_collective!(self.economic_collective.to_vec()); + set_next_building_collective!(self.building_collective.to_vec()); + run_to_block(self.block_number); + }); + ext + } + + pub(crate) fn build_and_execute(self, test: impl FnOnce()) { + self.build().execute_with(|| { + test(); + }); + } +} + +pub(crate) fn nth_last_event(n: usize) -> RuntimeEvent { + System::events() + .into_iter() + .rev() + .nth(n) + .expect("RuntimeEvent expected") + .event +} + +pub(crate) fn last_event() -> RuntimeEvent { + nth_last_event(0) +} + +pub(crate) fn run_to_block(n: BlockNumberFor) { + System::run_to_block::(n); +} diff --git a/pallets/governance/src/tests.rs b/pallets/governance/src/tests.rs new file mode 100644 index 0000000000..ddf7643784 --- /dev/null +++ b/pallets/governance/src/tests.rs @@ -0,0 +1,2021 @@ +#![cfg(test)] +use super::*; +use crate::mock::*; +use frame_support::{assert_noop, assert_ok, traits::fungible::InspectHold}; +use sp_core::U256; + +#[test] +fn environment_works() { + TestState::default().build_and_execute(|| { + assert_eq!( + AllowedProposers::::get(), + vec![U256::from(1), U256::from(2), U256::from(3)] + ); + assert_eq!( + Triumvirate::::get(), + vec![U256::from(1001), U256::from(1002), U256::from(1003)] + ); + }); +} + +#[test] +fn environment_members_are_sorted() { + TestState::default() + .with_allowed_proposers(vec![U256::from(2), U256::from(3), U256::from(1)]) + .with_triumvirate(vec![U256::from(1002), U256::from(1001), U256::from(1003)]) + .build_and_execute(|| { + assert_eq!( + AllowedProposers::::get(), + vec![U256::from(1), U256::from(2), U256::from(3)] + ); + assert_eq!( + Triumvirate::::get(), + vec![U256::from(1001), U256::from(1002), U256::from(1003)] + ); + }); +} + +#[test] +#[should_panic(expected = "Allowed proposers cannot contain duplicate accounts.")] +fn environment_with_duplicate_allowed_proposers_panics() { + TestState::default() + .with_allowed_proposers(vec![U256::from(1), U256::from(2), U256::from(2)]) + .build_and_execute(|| {}); +} + +#[test] +#[should_panic(expected = "Allowed proposers length cannot exceed MaxAllowedProposers.")] +fn environment_with_too_many_allowed_proposers_panics() { + let max_allowed_proposers = ::MaxAllowedProposers::get() as usize; + let allowed_proposers = (0..=max_allowed_proposers).map(U256::from).collect(); + TestState::default() + .with_allowed_proposers(allowed_proposers) + .build_and_execute(|| {}); +} + +#[test] +#[should_panic(expected = "Triumvirate cannot contain duplicate accounts.")] +fn environment_with_duplicate_triumvirate_panics() { + TestState::default() + .with_triumvirate(vec![U256::from(1001), U256::from(1002), U256::from(1002)]) + .build_and_execute(|| {}); +} + +#[test] +#[should_panic(expected = "Triumvirate length cannot exceed 3.")] +fn environment_with_too_many_triumvirate_panics() { + let triumvirate = (1..=4).map(U256::from).collect(); + TestState::default() + .with_triumvirate(triumvirate) + .build_and_execute(|| {}); +} + +#[test] +#[should_panic(expected = "Allowed proposers and triumvirate must be disjoint.")] +fn environment_with_overlapping_allowed_proposers_and_triumvirate_panics() { + TestState::default() + .with_allowed_proposers(vec![U256::from(1), U256::from(2), U256::from(3)]) + .with_triumvirate(vec![U256::from(1001), U256::from(1002), U256::from(1)]) + .build_and_execute(|| {}); +} + +#[test] +fn set_allowed_proposers_works() { + TestState::default() + .with_allowed_proposers(vec![]) + .build_and_execute(|| { + let allowed_proposers = BoundedVec::truncate_from(vec![ + U256::from(5), + U256::from(1), + U256::from(4), + U256::from(3), + U256::from(2), + ]); + assert!(AllowedProposers::::get().is_empty()); + + assert_ok!(Pallet::::set_allowed_proposers( + // SetAllowedProposersOrigin is EnsureRoot + RuntimeOrigin::root(), + allowed_proposers.clone() + )); + + assert_eq!( + AllowedProposers::::get().to_vec(), + // Sorted allowed proposers + vec![ + U256::from(1), + U256::from(2), + U256::from(3), + U256::from(4), + U256::from(5) + ] + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::AllowedProposersSet { + incoming: vec![ + U256::from(1), + U256::from(2), + U256::from(3), + U256::from(4), + U256::from(5) + ], + outgoing: vec![], + removed_proposals: vec![], + }) + ); + }); +} + +#[test] +fn set_allowed_proposers_removes_proposals_of_outgoing_proposers() { + TestState::default().build_and_execute(|| { + let (proposal_hash1, _proposal_index1) = create_custom_proposal!( + U256::from(1), + frame_system::Call::::set_storage { + items: vec![(b"Foobar".to_vec(), 1i32.to_be_bytes().to_vec())], + } + ); + let (proposal_hash2, _proposal_index2) = create_custom_proposal!( + U256::from(1), + frame_system::Call::::set_storage { + items: vec![(b"Foobar".to_vec(), 2i32.to_be_bytes().to_vec())], + } + ); + let (proposal_hash3, _proposal_index3) = create_custom_proposal!( + U256::from(3), + frame_system::Call::::set_storage { + items: vec![(b"Foobar".to_vec(), 3i32.to_be_bytes().to_vec())], + } + ); + assert_eq!( + AllowedProposers::::get(), + vec![U256::from(1), U256::from(2), U256::from(3)] + ); + + let allowed_proposers = + BoundedVec::truncate_from(vec![U256::from(2), U256::from(3), U256::from(4)]); + assert_ok!(Pallet::::set_allowed_proposers( + RuntimeOrigin::root(), + allowed_proposers.clone() + )); + + assert_eq!(AllowedProposers::::get(), allowed_proposers); + assert_eq!( + Proposals::::get(), + vec![(U256::from(3), proposal_hash3)] + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::AllowedProposersSet { + incoming: vec![U256::from(4)], + outgoing: vec![U256::from(1)], + removed_proposals: vec![ + (U256::from(1), proposal_hash1), + (U256::from(1), proposal_hash2) + ], + }) + ); + }); +} + +#[test] +fn set_allowed_proposers_with_bad_origin_fails() { + TestState::default() + .with_allowed_proposers(vec![]) + .build_and_execute(|| { + let allowed_proposers = + BoundedVec::truncate_from((1..=5).map(U256::from).collect::>()); + + assert_noop!( + Pallet::::set_allowed_proposers( + RuntimeOrigin::signed(U256::from(42)), + allowed_proposers.clone() + ), + DispatchError::BadOrigin + ); + + assert_noop!( + Pallet::::set_allowed_proposers(RuntimeOrigin::none(), allowed_proposers), + DispatchError::BadOrigin + ); + }); +} + +#[test] +fn set_allowed_proposers_with_duplicate_accounts_fails() { + TestState::default() + .with_allowed_proposers(vec![]) + .build_and_execute(|| { + let allowed_proposers = BoundedVec::truncate_from( + std::iter::repeat_n(U256::from(1), 2).collect::>(), + ); + + assert_noop!( + Pallet::::set_allowed_proposers(RuntimeOrigin::root(), allowed_proposers), + Error::::DuplicateAccounts + ); + }); +} + +#[test] +fn set_allowed_proposers_with_triumvirate_intersection_fails() { + TestState::default() + .with_allowed_proposers(vec![]) + .with_triumvirate(vec![U256::from(1), U256::from(2), U256::from(3)]) + .build_and_execute(|| { + let allowed_proposers = + BoundedVec::truncate_from((3..=8).map(U256::from).collect::>()); + + assert_noop!( + Pallet::::set_allowed_proposers(RuntimeOrigin::root(), allowed_proposers), + Error::::AllowedProposersAndTriumvirateMustBeDisjoint + ); + }); +} + +#[test] +fn set_triumvirate_works() { + TestState::default() + .with_triumvirate(vec![]) + .build_and_execute(|| { + let triumvirate = BoundedVec::truncate_from(vec![ + U256::from(1003), + U256::from(1001), + U256::from(1002), + ]); + assert!(Triumvirate::::get().is_empty()); + + assert_ok!(Pallet::::set_triumvirate( + // SetTriumvirateOrigin is EnsureRoot + RuntimeOrigin::root(), + triumvirate.clone() + )); + + assert_eq!( + Triumvirate::::get(), + // Sorted triumvirate + vec![U256::from(1001), U256::from(1002), U256::from(1003)] + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::TriumvirateSet { + incoming: vec![U256::from(1001), U256::from(1002), U256::from(1003)], + outgoing: vec![], + }) + ); + }); +} + +#[test] +fn set_triumvirate_removes_votes_of_outgoing_triumvirate_members() { + TestState::default().build_and_execute(|| { + let (proposal_hash1, proposal_index1) = create_custom_proposal!( + U256::from(1), + frame_system::Call::::set_storage { + items: vec![(b"Foobar".to_vec(), 1i32.to_be_bytes().to_vec())], + } + ); + let (proposal_hash2, proposal_index2) = create_custom_proposal!( + U256::from(2), + frame_system::Call::::set_storage { + items: vec![(b"Foobar".to_vec(), 2i32.to_be_bytes().to_vec())], + } + ); + let (proposal_hash3, proposal_index3) = create_custom_proposal!( + U256::from(3), + frame_system::Call::::set_storage { + items: vec![(b"Foobar".to_vec(), 3i32.to_be_bytes().to_vec())], + } + ); + assert_eq!( + Triumvirate::::get(), + vec![U256::from(1001), U256::from(1002), U256::from(1003)] + ); + + vote_aye_on_proposed!(U256::from(1001), proposal_hash1, proposal_index1); + + vote_nay_on_proposed!(U256::from(1002), proposal_hash2, proposal_index2); + vote_aye_on_proposed!(U256::from(1003), proposal_hash2, proposal_index2); + + vote_nay_on_proposed!(U256::from(1001), proposal_hash3, proposal_index3); + vote_aye_on_proposed!(U256::from(1002), proposal_hash3, proposal_index3); + + let triumvirate = + BoundedVec::truncate_from(vec![U256::from(1001), U256::from(1003), U256::from(1004)]); + assert_ok!(Pallet::::set_triumvirate( + RuntimeOrigin::root(), + triumvirate.clone() + )); + assert_eq!(Triumvirate::::get(), triumvirate); + let voting1 = TriumvirateVoting::::get(proposal_hash1).unwrap(); + assert_eq!(voting1.ayes.to_vec(), vec![U256::from(1001)]); + assert!(voting1.nays.to_vec().is_empty()); + let voting2 = TriumvirateVoting::::get(proposal_hash2).unwrap(); + assert_eq!(voting2.ayes.to_vec(), vec![U256::from(1003)]); + assert!(voting2.nays.to_vec().is_empty()); + let voting3 = TriumvirateVoting::::get(proposal_hash3).unwrap(); + assert!(voting3.ayes.to_vec().is_empty()); + assert_eq!(voting3.nays.to_vec(), vec![U256::from(1001)]); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::TriumvirateSet { + incoming: vec![U256::from(1004)], + outgoing: vec![U256::from(1002)], + }) + ); + }); +} + +#[test] +fn set_triumvirate_with_bad_origin_fails() { + TestState::default() + .with_triumvirate(vec![]) + .build_and_execute(|| { + let triumvirate = BoundedVec::truncate_from( + (1..=3).map(|i| U256::from(1000 + i)).collect::>(), + ); + + assert_noop!( + Pallet::::set_triumvirate( + RuntimeOrigin::signed(U256::from(42)), + triumvirate.clone() + ), + DispatchError::BadOrigin + ); + + assert_noop!( + Pallet::::set_triumvirate(RuntimeOrigin::none(), triumvirate), + DispatchError::BadOrigin + ); + }); +} + +#[test] +fn set_triumvirate_with_duplicate_accounts_fails() { + TestState::default() + .with_triumvirate(vec![]) + .build_and_execute(|| { + let triumvirate = BoundedVec::truncate_from( + std::iter::repeat_n(U256::from(1001), 2).collect::>(), + ); + + assert_noop!( + Pallet::::set_triumvirate(RuntimeOrigin::root(), triumvirate), + Error::::DuplicateAccounts + ); + }); +} + +#[test] +fn set_triumvirate_with_allowed_proposers_intersection_fails() { + TestState::default() + .with_allowed_proposers(vec![U256::from(1), U256::from(2), U256::from(3)]) + .build_and_execute(|| { + let triumvirate = + BoundedVec::truncate_from((3..=8).map(U256::from).collect::>()); + + assert_noop!( + Pallet::::set_triumvirate(RuntimeOrigin::root(), triumvirate), + Error::::AllowedProposersAndTriumvirateMustBeDisjoint + ); + }); +} + +#[test] +fn propose_works_with_inline_preimage() { + TestState::default().build_and_execute(|| { + let key_value = (b"Foobar".to_vec(), 42u32.to_be_bytes().to_vec()); + let proposal = Box::new(RuntimeCall::System( + frame_system::Call::::set_storage { + items: vec![key_value], + }, + )); + let length_bound = proposal.encoded_size() as u32; + + let proposal_index = ProposalCount::::get(); + assert_eq!(proposal_index, 0); + assert_ok!(Pallet::::propose( + RuntimeOrigin::signed(U256::from(1)), + proposal.clone(), + length_bound + )); + + let proposal_hash = ::Hashing::hash_of(&proposal); + let bounded_proposal = ::Preimages::bound(*proposal).unwrap(); + assert_eq!( + Proposals::::get(), + vec![(U256::from(1), proposal_hash)] + ); + assert_eq!(ProposalCount::::get(), 1); + assert_eq!( + ProposalOf::::get(proposal_hash), + Some(bounded_proposal) + ); + let now = frame_system::Pallet::::block_number(); + assert_eq!( + TriumvirateVoting::::get(proposal_hash), + Some(TriumvirateVotes { + index: proposal_index, + ayes: BoundedVec::new(), + nays: BoundedVec::new(), + end: now + MotionDuration::get(), + }) + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::ProposalSubmitted { + account: U256::from(1), + proposal_index: 0, + proposal_hash, + voting_end: now + MotionDuration::get(), + }) + ); + }); +} + +#[test] +fn propose_works_with_lookup_preimage() { + TestState::default().build_and_execute(|| { + let key_value = (b"Foobar".to_vec(), 42u32.to_be_bytes().to_vec()); + let proposal = Box::new(RuntimeCall::System( + frame_system::Call::::set_storage { + // We deliberately create a large proposal to avoid inlining. + items: std::iter::repeat_n(key_value, 50).collect::>(), + }, + )); + let length_bound = proposal.encoded_size() as u32; + + let proposal_index = ProposalCount::::get(); + assert_eq!(proposal_index, 0); + assert_ok!(Pallet::::propose( + RuntimeOrigin::signed(U256::from(1)), + proposal.clone(), + length_bound + )); + + let proposal_hash = ::Hashing::hash_of(&proposal); + assert_eq!( + Proposals::::get(), + vec![(U256::from(1), proposal_hash)] + ); + assert_eq!(ProposalCount::::get(), 1); + let stored_proposals = ProposalOf::::iter().collect::>(); + assert_eq!(stored_proposals.len(), 1); + let (stored_hash, bounded_proposal) = &stored_proposals[0]; + assert_eq!(stored_hash, &proposal_hash); + assert!(::Preimages::have(bounded_proposal)); + let now = frame_system::Pallet::::block_number(); + assert_eq!( + TriumvirateVoting::::get(proposal_hash), + Some(TriumvirateVotes { + index: proposal_index, + ayes: BoundedVec::new(), + nays: BoundedVec::new(), + end: now + MotionDuration::get(), + }) + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::ProposalSubmitted { + account: U256::from(1), + proposal_index: 0, + proposal_hash, + voting_end: now + MotionDuration::get(), + }) + ); + }); +} + +#[test] +fn propose_with_bad_origin_fails() { + TestState::default().build_and_execute(|| { + let proposal = Box::new(RuntimeCall::System( + frame_system::Call::::set_storage { + items: vec![(b"Foobar".to_vec(), 42u32.to_be_bytes().to_vec())], + }, + )); + let length_bound = proposal.encoded_size() as u32; + + assert_noop!( + Pallet::::propose(RuntimeOrigin::root(), proposal.clone(), length_bound), + DispatchError::BadOrigin + ); + + assert_noop!( + Pallet::::propose(RuntimeOrigin::none(), proposal.clone(), length_bound), + DispatchError::BadOrigin + ); + }); +} + +#[test] +fn propose_with_non_allowed_proposer_fails() { + TestState::default().build_and_execute(|| { + let proposal = Box::new(RuntimeCall::System( + frame_system::Call::::set_storage { + items: vec![(b"Foobar".to_vec(), 42u32.to_be_bytes().to_vec())], + }, + )); + let length_bound = proposal.encoded_size() as u32; + + assert_noop!( + Pallet::::propose( + RuntimeOrigin::signed(U256::from(42)), + proposal.clone(), + length_bound + ), + Error::::NotAllowedProposer + ); + }); +} + +#[test] +fn propose_with_incorrect_length_bound_fails() { + TestState::default().build_and_execute(|| { + let proposal = Box::new(RuntimeCall::System( + frame_system::Call::::set_storage { + items: vec![(b"Foobar".to_vec(), 42u32.to_be_bytes().to_vec())], + }, + )); + // We deliberately set the length bound to be one less than the proposal length. + let length_bound = proposal.encoded_size() as u32 - 1; + + assert_noop!( + Pallet::::propose( + RuntimeOrigin::signed(U256::from(1)), + proposal.clone(), + length_bound + ), + Error::::WrongProposalLength + ); + }); +} + +#[test] +fn propose_with_incorrect_weight_bound_fails() { + TestState::default().build_and_execute(|| { + let proposal = Box::new(RuntimeCall::TestPallet( + pallet_test::Call::::expensive_call {}, + )); + let length_bound = proposal.encoded_size() as u32; + + assert_noop!( + Pallet::::propose( + RuntimeOrigin::signed(U256::from(1)), + proposal.clone(), + length_bound + ), + Error::::WrongProposalWeight + ); + }); +} + +#[test] +fn propose_with_duplicate_proposal_fails() { + TestState::default().build_and_execute(|| { + let proposal = Box::new(RuntimeCall::System( + frame_system::Call::::set_storage { + items: vec![(b"Foobar".to_vec(), 42u32.to_be_bytes().to_vec())], + }, + )); + let length_bound = proposal.encoded_size() as u32; + + assert_ok!(Pallet::::propose( + RuntimeOrigin::signed(U256::from(1)), + proposal.clone(), + length_bound + )); + + assert_noop!( + Pallet::::propose( + RuntimeOrigin::signed(U256::from(1)), + proposal.clone(), + length_bound + ), + Error::::DuplicateProposal + ); + }); +} + +#[test] +fn propose_with_already_scheduled_proposal_fails() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_proposal!(); + + vote_aye_on_proposed!(U256::from(1001), proposal_hash, proposal_index); + vote_aye_on_proposed!(U256::from(1002), proposal_hash, proposal_index); + + let proposal = Box::new(RuntimeCall::System( + frame_system::Call::::set_storage { + items: vec![(b"Foobar".to_vec(), 42u32.to_be_bytes().to_vec())], + }, + )); + let length_bound = proposal.encoded_size() as u32; + assert_noop!( + Pallet::::propose( + RuntimeOrigin::signed(U256::from(1)), + proposal.clone(), + length_bound + ), + Error::::AlreadyScheduled + ); + }); +} + +#[test] +fn propose_with_too_many_proposals_fails() { + TestState::default().build_and_execute(|| { + // Create the maximum number of proposals. + let proposals = (1..=MaxProposals::get()) + .map(|i| { + let proposal = Box::new(RuntimeCall::System( + frame_system::Call::::set_storage { + items: vec![( + format!("Foobar{i}").as_bytes().to_vec(), + 42u32.to_be_bytes().to_vec(), + )], + }, + )); + let length_bound = proposal.encoded_size() as u32; + (proposal, length_bound) + }) + .collect::>(); + + for (proposal, length_bound) in proposals { + assert_ok!(Pallet::::propose( + RuntimeOrigin::signed(U256::from(1)), + proposal, + length_bound + )); + } + + let proposal = Box::new(RuntimeCall::System( + frame_system::Call::::set_storage { + items: vec![(b"Foobar".to_vec(), 42u32.to_be_bytes().to_vec())], + }, + )); + let length_bound = proposal.encoded_size() as u32; + assert_noop!( + Pallet::::propose(RuntimeOrigin::signed(U256::from(1)), proposal, length_bound), + Error::::TooManyProposals + ); + }); +} + +#[test] +fn triumirate_vote_aye_as_first_voter_works() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_proposal!(); + + let approve = true; + assert_ok!(Pallet::::vote_on_proposed( + RuntimeOrigin::signed(U256::from(1001)), + proposal_hash, + proposal_index, + approve + )); + + let votes = TriumvirateVoting::::get(proposal_hash).unwrap(); + assert_eq!(votes.ayes.to_vec(), vec![U256::from(1001)]); + assert!(votes.nays.to_vec().is_empty()); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::VotedOnProposal { + account: U256::from(1001), + proposal_hash, + voted: true, + yes: 1, + no: 0, + }) + ); + }); +} + +#[test] +fn triumvirate_vote_nay_as_first_voter_works() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_proposal!(); + + let approve = false; + assert_ok!(Pallet::::vote_on_proposed( + RuntimeOrigin::signed(U256::from(1001)), + proposal_hash, + proposal_index, + approve + )); + + let votes = TriumvirateVoting::::get(proposal_hash).unwrap(); + assert_eq!(votes.nays.to_vec(), vec![U256::from(1001)]); + assert!(votes.ayes.to_vec().is_empty()); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::VotedOnProposal { + account: U256::from(1001), + proposal_hash, + voted: false, + yes: 0, + no: 1, + }) + ); + }); +} + +#[test] +fn triumvirate_vote_can_be_updated() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_proposal!(); + + // Vote aye initially + vote_aye_on_proposed!(U256::from(1001), proposal_hash, proposal_index); + let votes = TriumvirateVoting::::get(proposal_hash).unwrap(); + assert_eq!(votes.ayes.to_vec(), vec![U256::from(1001)]); + assert!(votes.nays.to_vec().is_empty()); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::VotedOnProposal { + account: U256::from(1001), + proposal_hash, + voted: true, + yes: 1, + no: 0, + }) + ); + + // Then vote nay, replacing the aye vote + vote_nay_on_proposed!(U256::from(1001), proposal_hash, proposal_index); + let votes = TriumvirateVoting::::get(proposal_hash).unwrap(); + assert_eq!(votes.nays.to_vec(), vec![U256::from(1001)]); + assert!(votes.ayes.to_vec().is_empty()); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::VotedOnProposal { + account: U256::from(1001), + proposal_hash, + voted: false, + yes: 0, + no: 1, + }) + ); + + // Then vote aye again, replacing the nay vote + vote_aye_on_proposed!(U256::from(1001), proposal_hash, proposal_index); + let votes = TriumvirateVoting::::get(proposal_hash).unwrap(); + assert_eq!(votes.ayes.to_vec(), vec![U256::from(1001)]); + assert!(votes.nays.to_vec().is_empty()); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::VotedOnProposal { + account: U256::from(1001), + proposal_hash, + voted: true, + yes: 1, + no: 0, + }) + ); + }); +} + +#[test] +fn two_triumvirate_aye_votes_schedule_proposal() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_proposal!(); + + vote_aye_on_proposed!(U256::from(1001), proposal_hash, proposal_index); + vote_nay_on_proposed!(U256::from(1002), proposal_hash, proposal_index); + vote_aye_on_proposed!(U256::from(1003), proposal_hash, proposal_index); + + assert!(Proposals::::get().is_empty()); + assert!(!TriumvirateVoting::::contains_key(proposal_hash)); + assert_eq!(Scheduled::::get(), vec![proposal_hash]); + let now = frame_system::Pallet::::block_number(); + assert_eq!( + CollectiveVoting::::get(proposal_hash), + Some(CollectiveVotes { + index: proposal_index, + ayes: BoundedVec::new(), + nays: BoundedVec::new(), + initial_dispatch_time: now + MotionDuration::get(), + delay: Zero::zero(), + }) + ); + let now = frame_system::Pallet::::block_number(); + assert_eq!( + get_scheduler_proposal_task(proposal_hash).unwrap().0, + now + MotionDuration::get() + ); + assert_eq!( + nth_last_event(2), + RuntimeEvent::Governance(Event::::VotedOnProposal { + account: U256::from(1003), + proposal_hash, + voted: true, + yes: 2, + no: 1, + }) + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::ProposalScheduled { proposal_hash }) + ); + }); +} + +#[test] +fn two_triumvirate_nay_votes_cancel_proposal() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_proposal!(); + + vote_nay_on_proposed!(U256::from(1001), proposal_hash, proposal_index); + vote_aye_on_proposed!(U256::from(1002), proposal_hash, proposal_index); + vote_nay_on_proposed!(U256::from(1003), proposal_hash, proposal_index); + + assert!(Proposals::::get().is_empty()); + assert!(!TriumvirateVoting::::contains_key(proposal_hash)); + assert!(Scheduled::::get().is_empty()); + assert!(ProposalOf::::get(proposal_hash).is_none()); + assert_eq!( + nth_last_event(1), + RuntimeEvent::Governance(Event::::VotedOnProposal { + account: U256::from(1003), + proposal_hash, + voted: false, + yes: 1, + no: 2, + }) + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::ProposalCancelled { proposal_hash }) + ); + }); +} + +#[test] +fn triumvirate_vote_as_bad_origin_fails() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_proposal!(); + + assert_noop!( + Pallet::::vote_on_proposed( + RuntimeOrigin::root(), + proposal_hash, + proposal_index, + true + ), + DispatchError::BadOrigin + ); + assert_noop!( + Pallet::::vote_on_proposed( + RuntimeOrigin::none(), + proposal_hash, + proposal_index, + true + ), + DispatchError::BadOrigin + ); + }); +} + +#[test] +fn triumvirate_vote_as_non_triumvirate_member_fails() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_proposal!(); + + assert_noop!( + Pallet::::vote_on_proposed( + RuntimeOrigin::signed(U256::from(42)), + proposal_hash, + proposal_index, + true + ), + Error::::NotTriumvirateMember + ); + }); +} + +#[test] +fn triumvirate_vote_on_missing_proposal_fails() { + TestState::default().build_and_execute(|| { + let invalid_proposal_hash = + ::Hashing::hash(b"Invalid proposal"); + assert_noop!( + Pallet::::vote_on_proposed( + RuntimeOrigin::signed(U256::from(1001)), + invalid_proposal_hash, + 0, + true + ), + Error::::ProposalMissing + ); + }); +} + +#[test] +fn triumvirate_vote_on_scheduled_proposal_fails() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_proposal!(); + + vote_aye_on_proposed!(U256::from(1001), proposal_hash, proposal_index); + vote_aye_on_proposed!(U256::from(1002), proposal_hash, proposal_index); + + assert!(Proposals::::get().is_empty()); + assert_eq!(Scheduled::::get(), vec![proposal_hash]); + + assert_noop!( + Pallet::::vote_on_proposed( + RuntimeOrigin::signed(U256::from(1003)), + proposal_hash, + proposal_index, + true + ), + Error::::ProposalMissing + ); + }) +} + +#[test] +fn triumvirate_vote_on_proposal_with_wrong_index_fails() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_proposal!(); + + assert_noop!( + Pallet::::vote_on_proposed( + RuntimeOrigin::signed(U256::from(1001)), + proposal_hash, + proposal_index + 1, + true + ), + Error::::WrongProposalIndex + ); + }); +} + +#[test] +fn triumvirate_vote_after_voting_period_ended_fails() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_proposal!(); + + let now = frame_system::Pallet::::block_number(); + run_to_block(now + MotionDuration::get() + 1); + + assert_noop!( + Pallet::::vote_on_proposed( + RuntimeOrigin::signed(U256::from(1001)), + proposal_hash, + proposal_index, + true + ), + Error::::VotingPeriodEnded + ); + }); +} + +#[test] +fn duplicate_triumvirate_vote_on_proposal_already_voted_fails() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_proposal!(); + + let aye_voter = RuntimeOrigin::signed(U256::from(1001)); + let approve = true; + assert_ok!(Pallet::::vote_on_proposed( + aye_voter.clone(), + proposal_hash, + proposal_index, + approve + )); + assert_noop!( + Pallet::::vote_on_proposed(aye_voter, proposal_hash, proposal_index, approve), + Error::::DuplicateVote + ); + + let nay_voter = RuntimeOrigin::signed(U256::from(1002)); + let approve = false; + assert_ok!(Pallet::::vote_on_proposed( + nay_voter.clone(), + proposal_hash, + proposal_index, + approve + )); + assert_noop!( + Pallet::::vote_on_proposed(nay_voter, proposal_hash, proposal_index, approve), + Error::::DuplicateVote + ); + }); +} + +#[test] +fn triumvirate_aye_vote_on_proposal_with_too_many_scheduled_fails() { + TestState::default().build_and_execute(|| { + // We fill the scheduled proposals up to the maximum. + for i in 0..MaxScheduled::get() { + let (proposal_hash, proposal_index) = create_custom_proposal!( + U256::from(1), + frame_system::Call::::set_storage { + items: vec![(b"Foobar".to_vec(), i.to_be_bytes().to_vec())], + } + ); + vote_aye_on_proposed!(U256::from(1001), proposal_hash, proposal_index); + vote_aye_on_proposed!(U256::from(1002), proposal_hash, proposal_index); + } + + let (proposal_hash, proposal_index) = create_proposal!(); + + vote_aye_on_proposed!(U256::from(1001), proposal_hash, proposal_index); + assert_noop!( + Pallet::::vote_on_proposed( + RuntimeOrigin::signed(U256::from(1002)), + proposal_hash, + proposal_index, + true + ), + Error::::TooManyScheduled + ); + }); +} + +#[test] +fn collective_member_aye_vote_on_scheduled_proposal_works() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_scheduled_proposal!(); + + // Add an aye vote from an economic collective member. + let economic_member = U256::from(2001); + assert_ok!(Pallet::::vote_on_scheduled( + RuntimeOrigin::signed(economic_member), + proposal_hash, + proposal_index, + true + )); + let now = frame_system::Pallet::::block_number(); + assert_eq!( + CollectiveVoting::::get(proposal_hash), + Some(CollectiveVotes { + index: proposal_index, + ayes: BoundedVec::truncate_from(vec![economic_member]), + nays: BoundedVec::new(), + initial_dispatch_time: now + MotionDuration::get(), + delay: Zero::zero(), + }) + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::VotedOnScheduled { + account: economic_member, + proposal_hash, + voted: true, + yes: 1, + no: 0, + }) + ); + + // Add a second aye vote from a building collective member. + let building_member = U256::from(3001); + assert_ok!(Pallet::::vote_on_scheduled( + RuntimeOrigin::signed(building_member), + proposal_hash, + proposal_index, + true + )); + + assert_eq!( + CollectiveVoting::::get(proposal_hash), + Some(CollectiveVotes { + index: proposal_index, + ayes: BoundedVec::truncate_from(vec![economic_member, building_member]), + nays: BoundedVec::new(), + initial_dispatch_time: now + MotionDuration::get(), + delay: Zero::zero(), + }) + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::VotedOnScheduled { + account: building_member, + proposal_hash, + voted: true, + yes: 2, + no: 0, + }) + ); + }); +} + +#[test] +fn collective_member_votes_succession_on_scheduled_proposal_adjust_delay_and_can_fast_track() { + TestState::default().build_and_execute(|| { + let now = frame_system::Pallet::::block_number(); + let (proposal_hash, proposal_index) = create_scheduled_proposal!(); + let voting = CollectiveVoting::::get(proposal_hash).unwrap(); + assert_eq!(voting.delay, 0); + + // Adding a nay vote increases the delay + vote_nay_on_scheduled!(U256::from(2001), proposal_hash, proposal_index); + let initial_delay = InitialSchedulingDelay::get() as f64; + let initial_dispatch_time = now + MotionDuration::get(); + let delay = (initial_delay * 1.5_f64.powi(1)).ceil() as u64; + assert_eq!( + CollectiveVoting::::get(proposal_hash), + Some(CollectiveVotes { + index: proposal_index, + ayes: BoundedVec::new(), + nays: BoundedVec::truncate_from(vec![U256::from(2001)]), + initial_dispatch_time, + delay, + }) + ); + assert_eq!( + get_scheduler_proposal_task(proposal_hash).unwrap().0, + initial_dispatch_time + delay + ); + assert_eq!( + nth_last_event(3), + RuntimeEvent::Governance(Event::::VotedOnScheduled { + account: U256::from(2001), + proposal_hash, + voted: false, + yes: 0, + no: 1, + }) + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::ScheduledProposalDelayAdjusted { + proposal_hash, + dispatch_time: DispatchTime::At(initial_dispatch_time + delay), + }) + ); + + // Adding a second nay vote increases the delay + vote_nay_on_scheduled!(U256::from(2002), proposal_hash, proposal_index); + let delay = (initial_delay * 1.5_f64.powi(2)).ceil() as u64; + assert_eq!( + CollectiveVoting::::get(proposal_hash), + Some(CollectiveVotes { + index: proposal_index, + ayes: BoundedVec::new(), + nays: BoundedVec::truncate_from(vec![U256::from(2001), U256::from(2002)]), + initial_dispatch_time, + delay, + }) + ); + assert_eq!( + get_scheduler_proposal_task(proposal_hash).unwrap().0, + initial_dispatch_time + delay + ); + assert_eq!( + nth_last_event(3), + RuntimeEvent::Governance(Event::::VotedOnScheduled { + account: U256::from(2002), + proposal_hash, + voted: false, + yes: 0, + no: 2, + }) + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::ScheduledProposalDelayAdjusted { + proposal_hash, + dispatch_time: DispatchTime::At(initial_dispatch_time + delay), + }) + ); + + // Adding a third nay vote increases the delay + vote_nay_on_scheduled!(U256::from(2003), proposal_hash, proposal_index); + let delay = (initial_delay * 1.5_f64.powi(3)) as u64; + assert_eq!( + CollectiveVoting::::get(proposal_hash), + Some(CollectiveVotes { + index: proposal_index, + ayes: BoundedVec::new(), + nays: BoundedVec::truncate_from(vec![ + U256::from(2001), + U256::from(2002), + U256::from(2003) + ]), + initial_dispatch_time, + delay, + }) + ); + assert_eq!( + get_scheduler_proposal_task(proposal_hash).unwrap().0, + initial_dispatch_time + delay + ); + assert_eq!( + nth_last_event(3), + RuntimeEvent::Governance(Event::::VotedOnScheduled { + account: U256::from(2003), + proposal_hash, + voted: false, + yes: 0, + no: 3, + }) + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::ScheduledProposalDelayAdjusted { + proposal_hash, + dispatch_time: DispatchTime::At(initial_dispatch_time + delay), + }) + ); + + // Adding a aye vote decreases the delay because net score become lower + vote_aye_on_scheduled!(U256::from(2004), proposal_hash, proposal_index); + let delay = (initial_delay * 1.5_f64.powi(2)).ceil() as u64; + assert_eq!( + CollectiveVoting::::get(proposal_hash), + Some(CollectiveVotes { + index: proposal_index, + ayes: BoundedVec::truncate_from(vec![U256::from(2004)]), + nays: BoundedVec::truncate_from(vec![ + U256::from(2001), + U256::from(2002), + U256::from(2003) + ]), + initial_dispatch_time, + delay, + }) + ); + assert_eq!( + get_scheduler_proposal_task(proposal_hash).unwrap().0, + initial_dispatch_time + delay + ); + assert_eq!( + nth_last_event(3), + RuntimeEvent::Governance(Event::::VotedOnScheduled { + account: U256::from(2004), + proposal_hash, + voted: true, + yes: 1, + no: 3, + }) + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::ScheduledProposalDelayAdjusted { + proposal_hash, + dispatch_time: DispatchTime::At(initial_dispatch_time + delay), + }) + ); + + // Now let's run some blocks until before the sheduled time + run_to_block(initial_dispatch_time + delay - 5); + // Task hasn't been executed yet + assert!(get_scheduler_proposal_task(proposal_hash).is_some()); + + // Adding a new aye vote should fast track the proposal because the delay will + // fall below the elapsed time + vote_aye_on_scheduled!(U256::from(2005), proposal_hash, proposal_index); + assert!(CollectiveVoting::::get(proposal_hash).is_none()); + let now = frame_system::Pallet::::block_number(); + assert_eq!( + get_scheduler_proposal_task(proposal_hash).unwrap().0, + // Fast track here means next block scheduling + now + 1 + ); + // The proposal is still scheduled, even if next block, we keep track of it + assert_eq!(Scheduled::::get(), vec![proposal_hash]); + assert_eq!( + nth_last_event(3), + RuntimeEvent::Governance(Event::::VotedOnScheduled { + account: U256::from(2005), + proposal_hash, + voted: true, + yes: 2, + no: 3, + }) + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::ScheduledProposalFastTracked { proposal_hash }) + ); + + // Now let run one block to see the proposal executed + assert_eq!(sp_io::storage::get(b"Foobar"), None); // Not executed yet + run_to_block(now + delay + 1); + assert!(get_scheduler_proposal_task(proposal_hash).is_none()); + let stored_value = 42u32.to_be_bytes().to_vec().into(); + assert_eq!(sp_io::storage::get(b"Foobar"), Some(stored_value)); // Executed + }); +} + +#[test] +fn collective_member_vote_on_scheduled_proposal_can_be_updated() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_scheduled_proposal!(); + let economic_member = U256::from(2001); + + // Vote aye initially as an economic collective member + vote_aye_on_scheduled!(economic_member, proposal_hash, proposal_index); + let votes = CollectiveVoting::::get(proposal_hash).unwrap(); + assert_eq!(votes.ayes.to_vec(), vec![economic_member]); + assert!(votes.nays.to_vec().is_empty()); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::VotedOnScheduled { + account: economic_member, + proposal_hash, + voted: true, + yes: 1, + no: 0, + }) + ); + + // Then vote nay, replacing the aye vote + vote_nay_on_scheduled!(economic_member, proposal_hash, proposal_index); + let votes = CollectiveVoting::::get(proposal_hash).unwrap(); + assert!(votes.ayes.to_vec().is_empty()); + assert_eq!(votes.nays.to_vec(), vec![economic_member]); + assert_eq!( + System::events().into_iter().rev().nth(3).unwrap().event, + RuntimeEvent::Governance(Event::::VotedOnScheduled { + account: economic_member, + proposal_hash, + voted: false, + yes: 0, + no: 1, + }) + ); + + // Then vote aye again, replacing the nay vote + vote_aye_on_scheduled!(economic_member, proposal_hash, proposal_index); + let votes = CollectiveVoting::::get(proposal_hash).unwrap(); + assert_eq!(votes.ayes.to_vec(), vec![economic_member]); + assert!(votes.nays.to_vec().is_empty()); + assert_eq!( + System::events().into_iter().rev().nth(3).unwrap().event, + RuntimeEvent::Governance(Event::::VotedOnScheduled { + account: economic_member, + proposal_hash, + voted: true, + yes: 1, + no: 0, + }) + ); + }); +} + +#[test] +fn collective_member_aye_votes_above_threshold_on_scheduled_proposal_fast_tracks() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_scheduled_proposal!(); + let threshold = FastTrackThreshold::get().mul_ceil(TOTAL_COLLECTIVES_SIZE); + let combined_collective = EconomicCollective::::get() + .into_iter() + .chain(BuildingCollective::::get().into_iter()); + + for member in combined_collective.into_iter().take(threshold as usize) { + vote_aye_on_scheduled!(member, proposal_hash, proposal_index); + } + + assert!(CollectiveVoting::::get(proposal_hash).is_none()); + let now = frame_system::Pallet::::block_number(); + assert_eq!( + get_scheduler_proposal_task(proposal_hash).unwrap().0, + now + 1 + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::ScheduledProposalFastTracked { proposal_hash }) + ); + + // Now let run one block to see the proposal executed + assert_eq!(sp_io::storage::get(b"Foobar"), None); // Not executed yet + run_to_block(now + 1); + assert!(get_scheduler_proposal_task(proposal_hash).is_none()); + let stored_value = 42u32.to_be_bytes().to_vec().into(); + assert_eq!(sp_io::storage::get(b"Foobar"), Some(stored_value)); // Executed + }); +} + +#[test] +fn collective_member_nay_votes_above_threshold_on_scheduled_proposal_cancels() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_scheduled_proposal!(); + let threshold = CancellationThreshold::get().mul_ceil(TOTAL_COLLECTIVES_SIZE); + let combined_collective = EconomicCollective::::get() + .into_iter() + .chain(BuildingCollective::::get().into_iter()); + + for member in combined_collective.into_iter().take(threshold as usize) { + vote_nay_on_scheduled!(member, proposal_hash, proposal_index); + } + + assert!(Scheduled::::get().is_empty()); + assert!(CollectiveVoting::::get(proposal_hash).is_none()); + assert!(get_scheduler_proposal_task(proposal_hash).is_none()); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::ScheduledProposalCancelled { proposal_hash }) + ); + }); +} + +#[test] +fn collective_member_aye_vote_triggering_fast_track_on_next_block_scheduled_proposal_fails() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_scheduled_proposal!(); + let threshold = FastTrackThreshold::get().mul_ceil(TOTAL_COLLECTIVES_SIZE); + let combined_collective = EconomicCollective::::get() + .into_iter() + .chain(BuildingCollective::::get().into_iter()); + + let below_threshold = (threshold - 1) as usize; + for member in combined_collective.clone().take(below_threshold) { + vote_aye_on_scheduled!(member, proposal_hash, proposal_index); + } + + let voting = CollectiveVoting::::get(proposal_hash).unwrap(); + run_to_block(voting.initial_dispatch_time - 1); + + let voter = combined_collective.skip(below_threshold).next().unwrap(); + assert_noop!( + Pallet::::vote_on_scheduled( + RuntimeOrigin::signed(voter), + proposal_hash, + proposal_index, + true + ), + pallet_scheduler::Error::::RescheduleNoChange + ); + }); +} + +#[test] +fn collective_member_vote_on_scheduled_proposal_from_non_collective_member_fails() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_scheduled_proposal!(); + + assert_noop!( + Pallet::::vote_on_scheduled( + RuntimeOrigin::signed(U256::from(42)), + proposal_hash, + proposal_index, + true + ), + Error::::NotCollectiveMember + ); + }); +} + +#[test] +fn collective_member_vote_on_non_scheduled_proposal_fails() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_proposal!(); + + assert_noop!( + Pallet::::vote_on_scheduled( + RuntimeOrigin::signed(U256::from(2001)), + proposal_hash, + proposal_index, + true + ), + Error::::ProposalNotScheduled + ); + }); +} + +#[test] +fn collective_member_vote_on_fast_tracked_scheduled_proposal_fails() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_scheduled_proposal!(); + let threshold = FastTrackThreshold::get().mul_ceil(TOTAL_COLLECTIVES_SIZE); + let combined_collective = EconomicCollective::::get() + .into_iter() + .chain(BuildingCollective::::get().into_iter()); + + for member in combined_collective.clone().take(threshold as usize) { + vote_aye_on_scheduled!(member, proposal_hash, proposal_index); + } + + let voter = combined_collective.skip(threshold as usize).next().unwrap(); + assert_noop!( + Pallet::::vote_on_scheduled( + RuntimeOrigin::signed(voter), + proposal_hash, + proposal_index, + true + ), + Error::::VotingPeriodEnded + ); + }); +} + +#[test] +fn collective_member_vote_on_scheduled_proposal_with_wrong_index_fails() { + TestState::default().build_and_execute(|| { + let (proposal_hash, _proposal_index) = create_scheduled_proposal!(); + + assert_noop!( + Pallet::::vote_on_scheduled( + RuntimeOrigin::signed(U256::from(2001)), + proposal_hash, + 42, + true + ), + Error::::WrongProposalIndex + ); + }); +} + +#[test] +fn duplicate_collective_member_vote_on_scheduled_proposal_already_voted_fails() { + TestState::default().build_and_execute(|| { + let (proposal_hash, proposal_index) = create_scheduled_proposal!(); + + let aye_voter = U256::from(2001); + vote_aye_on_scheduled!(aye_voter, proposal_hash, proposal_index); + assert_noop!( + Pallet::::vote_on_scheduled( + RuntimeOrigin::signed(aye_voter), + proposal_hash, + proposal_index, + true + ), + Error::::DuplicateVote + ); + + let nay_voter = U256::from(2002); + vote_nay_on_scheduled!(nay_voter, proposal_hash, proposal_index); + assert_noop!( + Pallet::::vote_on_scheduled( + RuntimeOrigin::signed(nay_voter), + proposal_hash, + proposal_index, + false + ), + Error::::DuplicateVote + ); + }); +} + +#[test] +fn collective_member_can_mark_himself_as_eligible() { + TestState::default() + .with_balance(U256::from(2001), 2 * EligibilityLockCost::get()) + .build_and_execute(|| { + let member = U256::from(2001); + assert_eq!(EligibleCandidates::::get(), vec![]); + assert_eq!( + >::total_balance_on_hold(&member), + 0 + ); + + assert_ok!(Pallet::::mark_as_eligible(RuntimeOrigin::signed( + member + ))); + + assert_eq!(EligibleCandidates::::get(), vec![member]); + assert_eq!( + >::total_balance_on_hold(&member), + EligibilityLockCost::get() + ); + }); +} + +#[test] +fn collective_member_cant_mark_himself_as_eligible_if_already_eligible() { + TestState::default().build_and_execute(|| { + let member = U256::from(2001); + EligibleCandidates::::try_append(member).unwrap(); + assert_eq!(EligibleCandidates::::get(), vec![member]); + + assert_noop!( + Pallet::::mark_as_eligible(RuntimeOrigin::signed(member)), + Error::::AlreadyEligible + ); + }); +} + +#[test] +fn collective_member_cant_mark_himself_as_eligible_if_cant_afford_the_eligibility_lock_cost() { + TestState::default().build_and_execute(|| { + let member = U256::from(2001); + assert_eq!(EligibleCandidates::::get(), vec![]); + assert_eq!( + >::total_balance_on_hold(&member), + 0 + ); + + assert_noop!( + Pallet::::mark_as_eligible(RuntimeOrigin::signed(member)), + Error::::InsufficientFundsForEligibilityLock + ); + }); +} + +#[test] +fn collective_member_vote_on_seat_replacement_works() { + TestState::default().build_and_execute(|| { + let member1 = EconomicCollective::::get()[0]; + let candidate1 = EconomicCollective::::get()[1]; + let member2 = BuildingCollective::::get()[0]; + let candidate2 = BuildingCollective::::get()[1]; + EligibleCandidates::::try_append(candidate1).unwrap(); + EligibleCandidates::::try_append(candidate2).unwrap(); + assert_eq!(CandidateVotes::::iter().collect::>(), vec![]); + assert_eq!(MemberVote::::iter().collect::>(), vec![]); + assert_eq!( + NominatedCandidate::::iter().collect::>(), + vec![] + ); + + // First vote + assert_ok!(Pallet::::vote_on_seat_replacement( + RuntimeOrigin::signed(member1), + candidate1 + )); + assert_eq!( + CandidateVotes::::iter().collect::>(), + vec![(candidate1, BoundedVec::truncate_from(vec![member1]))] + ); + assert_eq!( + MemberVote::::iter().collect::>(), + vec![(member1, candidate1)] + ); + assert_eq!( + NominatedCandidate::::iter().collect::>(), + vec![], + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::VotedOnSeatReplacement { + account: member1, + candidate: candidate1, + }) + ); + + // Second vote + assert_ok!(Pallet::::vote_on_seat_replacement( + RuntimeOrigin::signed(member2), + candidate2 + )); + let mut candidate_votes = CandidateVotes::::iter().collect::>(); + candidate_votes.sort_by_key(|c| c.0); + assert_eq!( + candidate_votes, + vec![ + (candidate1, BoundedVec::truncate_from(vec![member1])), + (candidate2, BoundedVec::truncate_from(vec![member2])) + ] + ); + let mut member_vote = MemberVote::::iter().collect::>(); + member_vote.sort_by_key(|c| c.0); + assert_eq!( + member_vote, + vec![(member1, candidate1), (member2, candidate2)] + ); + assert_eq!( + NominatedCandidate::::iter().collect::>(), + vec![], + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::VotedOnSeatReplacement { + account: member2, + candidate: candidate2, + }) + ); + }); +} + +#[test] +fn collective_member_votes_on_seat_replacement_above_nomination_threshold_works() { + TestState::default().build_and_execute(|| { + let threshold = NominationThreshold::get().mul_ceil(ECONOMIC_COLLECTIVE_SIZE); + let candidate = EconomicCollective::::get()[0]; + EligibleCandidates::::try_append(candidate).unwrap(); + assert_eq!( + NominatedCandidate::::iter().collect::>(), + vec![] + ); + + for member in EconomicCollective::::get() + .into_iter() + .skip(1) + .take(threshold as usize) + { + assert_ok!(Pallet::::vote_on_seat_replacement( + RuntimeOrigin::signed(member), + candidate + )); + } + + let now = frame_system::Pallet::::block_number(); + assert_eq!( + NominatedCandidate::::iter().collect::>(), + vec![(CollectiveType::Economic, (candidate, now))], + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::CandidateNominated { + collective: CollectiveType::Economic, + candidate, + votes: threshold + }) + ); + }); +} + +#[test] +fn collective_member_vote_on_seat_replacement_can_be_updated() { + TestState::default().build_and_execute(|| { + let member1 = EconomicCollective::::get()[0]; + let candidate1 = EconomicCollective::::get()[1]; + let candidate2 = EconomicCollective::::get()[2]; + let candidate3 = EconomicCollective::::get()[3]; + EligibleCandidates::::try_append(candidate1).unwrap(); + EligibleCandidates::::try_append(candidate2).unwrap(); + EligibleCandidates::::try_append(candidate3).unwrap(); + assert_eq!(CandidateVotes::::iter().collect::>(), vec![]); + assert_eq!(MemberVote::::iter().collect::>(), vec![]); + + // First vote + assert_ok!(Pallet::::vote_on_seat_replacement( + RuntimeOrigin::signed(member1), + candidate1 + )); + assert_eq!( + CandidateVotes::::iter().collect::>(), + vec![(candidate1, BoundedVec::truncate_from(vec![member1]))] + ); + assert_eq!( + MemberVote::::iter().collect::>(), + vec![(member1, candidate1)] + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::VotedOnSeatReplacement { + account: member1, + candidate: candidate1, + }) + ); + + // Second vote + assert_ok!(Pallet::::vote_on_seat_replacement( + RuntimeOrigin::signed(member1), + candidate2 + )); + assert_eq!( + CandidateVotes::::iter().collect::>(), + vec![(candidate2, BoundedVec::truncate_from(vec![member1])),] + ); + assert_eq!( + MemberVote::::iter().collect::>(), + vec![(member1, candidate2)] + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::VotedOnSeatReplacement { + account: member1, + candidate: candidate2, + }) + ); + + // Third vote + assert_ok!(Pallet::::vote_on_seat_replacement( + RuntimeOrigin::signed(member1), + candidate3 + )); + assert_eq!( + CandidateVotes::::iter().collect::>(), + vec![(candidate3, BoundedVec::truncate_from(vec![member1]))] + ); + assert_eq!( + MemberVote::::iter().collect::>(), + vec![(member1, candidate3)] + ); + assert_eq!( + last_event(), + RuntimeEvent::Governance(Event::::VotedOnSeatReplacement { + account: member1, + candidate: candidate3, + }) + ); + }); +} + +#[test] +fn collective_member_vote_on_seat_replacement_on_himself_fails() { + TestState::default().build_and_execute(|| { + let member = EconomicCollective::::get()[0]; + + assert_noop!( + Pallet::::vote_on_seat_replacement(RuntimeOrigin::signed(member), member), + Error::::SelfVoteNotAllowed + ); + }); +} + +#[test] +fn collective_member_vote_on_seat_replacement_if_not_collective_member_fails() { + TestState::default().build_and_execute(|| { + let member = U256::from(4242); + + assert_noop!( + Pallet::::vote_on_seat_replacement(RuntimeOrigin::signed(member), member), + Error::::NotCollectiveMember + ); + }); +} + +#[test] +fn collective_member_vote_on_seat_replacement_if_candidate_not_eligible_fails() { + TestState::default().build_and_execute(|| { + let member = EconomicCollective::::get()[0]; + let candidate = U256::from(4242); + + assert_noop!( + Pallet::::vote_on_seat_replacement(RuntimeOrigin::signed(member), candidate), + Error::::CandidateNotEligible + ); + }); +} + +#[test] +fn collective_member_vote_on_seat_replacement_if_candidate_and_caller_not_same_collective_fails() { + TestState::default().build_and_execute(|| { + let member = EconomicCollective::::get()[0]; + let candidate = BuildingCollective::::get()[0]; + EligibleCandidates::::try_append(candidate).unwrap(); + + assert_noop!( + Pallet::::vote_on_seat_replacement(RuntimeOrigin::signed(member), candidate), + Error::::CandidateNotSameCollective + ); + }); +} + +#[test] +fn collective_member_vote_on_seat_replacement_if_already_nominee_selected_fails() { + TestState::default().build_and_execute(|| { + let now = frame_system::Pallet::::block_number(); + let member = EconomicCollective::::get()[0]; + let nominated = EconomicCollective::::get()[1]; + let candidate = EconomicCollective::::get()[2]; + NominatedCandidate::::set(CollectiveType::Economic, Some((nominated, now))); + EligibleCandidates::::try_append(candidate).unwrap(); + + assert_noop!( + Pallet::::vote_on_seat_replacement(RuntimeOrigin::signed(member), candidate), + Error::::NomineeAlreadySelected + ); + }); +} + +#[test] +fn collective_member_vote_on_seat_replacement_with_duplicate_vote_fails() { + TestState::default().build_and_execute(|| { + let member = EconomicCollective::::get()[0]; + let candidate = EconomicCollective::::get()[1]; + EligibleCandidates::::try_append(candidate).unwrap(); + + assert_ok!(Pallet::::vote_on_seat_replacement( + RuntimeOrigin::signed(member), + candidate + )); + assert_noop!( + Pallet::::vote_on_seat_replacement(RuntimeOrigin::signed(member), candidate), + Error::::DuplicateVote + ); + }); +} + +#[test] +fn collective_rotation_run_on_initialize() { + TestState::default().build_and_execute(|| { + let next_economic_collective = (1..=ECONOMIC_COLLECTIVE_SIZE) + .map(|i| U256::from(4000 + i)) + .collect::>(); + let next_building_collective = (1..=BUILDING_COLLECTIVE_SIZE) + .map(|i| U256::from(5000 + i)) + .collect::>(); + + assert_eq!( + EconomicCollective::::get().len(), + ECONOMIC_COLLECTIVE_SIZE as usize, + ); + assert_ne!( + EconomicCollective::::get().to_vec(), + next_economic_collective + ); + assert_eq!( + BuildingCollective::::get().len(), + BUILDING_COLLECTIVE_SIZE as usize, + ); + assert_ne!( + BuildingCollective::::get().to_vec(), + next_building_collective + ); + + set_next_economic_collective!(next_economic_collective.clone()); + set_next_building_collective!(next_building_collective.clone()); + + run_to_block(CollectiveRotationPeriod::get()); + + assert_eq!( + EconomicCollective::::get().to_vec(), + next_economic_collective + ); + assert_eq!( + BuildingCollective::::get().to_vec(), + next_building_collective + ); + }); +} + +#[macro_export] +macro_rules! create_custom_proposal { + ($proposer:expr, $call:expr) => {{ + let proposal: Box<::RuntimeCall> = Box::new($call.into()); + let length_bound = proposal.encoded_size() as u32; + let proposal_hash = ::Hashing::hash_of(&proposal); + let proposal_index = ProposalCount::::get(); + + assert_ok!(Pallet::::propose( + RuntimeOrigin::signed($proposer), + proposal.clone(), + length_bound + )); + + (proposal_hash, proposal_index) + }}; +} + +#[macro_export] +macro_rules! create_proposal { + () => {{ + create_custom_proposal!( + U256::from(1), + frame_system::Call::::set_storage { + items: vec![(b"Foobar".to_vec(), 42u32.to_be_bytes().to_vec())], + } + ) + }}; +} + +#[macro_export] +macro_rules! create_scheduled_proposal { + () => {{ + let (proposal_hash, proposal_index) = create_proposal!(); + vote_aye_on_proposed!(U256::from(1001), proposal_hash, proposal_index); + vote_aye_on_proposed!(U256::from(1002), proposal_hash, proposal_index); + (proposal_hash, proposal_index) + }}; +} + +#[macro_export] +macro_rules! vote_aye_on_proposed { + ($voter:expr, $proposal_hash:expr, $proposal_index:expr) => {{ + assert_ok!(Pallet::::vote_on_proposed( + RuntimeOrigin::signed($voter), + $proposal_hash, + $proposal_index, + true + )); + }}; +} + +#[macro_export] +macro_rules! vote_nay_on_proposed { + ($voter:expr, $proposal_hash:expr, $proposal_index:expr) => {{ + assert_ok!(Pallet::::vote_on_proposed( + RuntimeOrigin::signed($voter), + $proposal_hash, + $proposal_index, + false + )); + }}; +} + +#[macro_export] +macro_rules! vote_aye_on_scheduled { + ($voter:expr, $proposal_hash:expr, $proposal_index:expr) => {{ + assert_ok!(Pallet::::vote_on_scheduled( + RuntimeOrigin::signed($voter), + $proposal_hash, + $proposal_index, + true + )); + }}; +} + +#[macro_export] +macro_rules! vote_nay_on_scheduled { + ($voter:expr, $proposal_hash:expr, $proposal_index:expr) => {{ + assert_ok!(Pallet::::vote_on_scheduled( + RuntimeOrigin::signed($voter), + $proposal_hash, + $proposal_index, + false + )); + }}; +} + +pub(crate) fn get_scheduler_proposal_task( + proposal_hash: ::Hash, +) -> Option>> { + let task_name: [u8; 32] = proposal_hash.as_ref().try_into().unwrap(); + pallet_scheduler::Lookup::::get(task_name) +} diff --git a/pallets/subtensor/src/tests/mock.rs b/pallets/subtensor/src/tests/mock.rs index 73f8581d5e..9b0ffe10c3 100644 --- a/pallets/subtensor/src/tests/mock.rs +++ b/pallets/subtensor/src/tests/mock.rs @@ -341,7 +341,6 @@ parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; pub const MaxScheduledPerBlock: u32 = 50; - pub const NoPreimagePostponement: Option = Some(10); } impl pallet_scheduler::Config for Test { diff --git a/pallets/transaction-fee/src/tests/mock.rs b/pallets/transaction-fee/src/tests/mock.rs index 8e48c2e4fc..df401aa930 100644 --- a/pallets/transaction-fee/src/tests/mock.rs +++ b/pallets/transaction-fee/src/tests/mock.rs @@ -427,7 +427,6 @@ parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; pub const MaxScheduledPerBlock: u32 = 50; - pub const NoPreimagePostponement: Option = Some(10); } impl pallet_scheduler::Config for Test { diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 389a01a983..93ebedd273 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -793,7 +793,6 @@ parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; pub const MaxScheduledPerBlock: u32 = 50; - pub const NoPreimagePostponement: Option = Some(10); } /// Used the compare the privilege of an origin inside the scheduler.